← Architecture

3.2 Model behaviors

KMP behaviors package persistence concerns that are shared by several tables. Attach them in a table’s initialize() method and use their public finder or method APIs. Behaviors run against the table’s datasource, so the table must be resolved inside the correct tenant scope.

ActiveWindowBehavior

ActiveWindowBehavior applies temporal queries to tables with start_on and expires_on fields.

$this->addBehavior('ActiveWindow');

$current = $this->find('current');
$upcoming = $this->find('upcoming');
$previous = $this->find('previous');

Each finder accepts an optional Cake\I18n\Datetime effective date. Without one, it uses Datetime::now().

Finder Implemented boundary
current start_on <= effective and (expires_on >= effective or null)
upcoming start_on > effective and (expires_on > effective or null)
previous expires_on < effective

The end boundary is inclusive for current. A record expiring exactly at the effective time is therefore current, not previous. Tables using this behavior include member roles, service-principal roles, warrants, Activities authorizations, and Officers records.

The behavior filters; it does not persist status strings. Use sync_active_window_statuses only for entity types that also maintain a denormalized status and are registered by that command.

PublicIdBehavior

PublicIdBehavior creates a random, non-sequential identifier for URL-facing records. The default field is public_id, the default length is eight, and the character set omits visually ambiguous characters.

$this->addBehavior('PublicId');

$entity = $this->find('byPublicId', [$publicId])->firstOrFail();

New entities receive an ID in beforeSave when the field is empty. The behavior checks uniqueness and validates the configured length/alphanumeric format. Current core uses include members, branches, and gatherings.

A public ID reduces easy enumeration; it is not access control. Load by public ID, then authorize the returned entity. Keep the database uniqueness constraint, and do not expose internal IDs alongside public IDs without a domain reason.

Prefer the proven find('byPublicId', ...) call shape used by controllers and tests. generate_public_ids backfills missing values for supported tables; run its dry-run/target options before a production backfill.

JsonFieldBehavior

JsonFieldBehavior::addJsonWhere() adds a portable equality predicate for a validated $.path in a JSON field. It emits PostgreSQL jsonb extraction for the current platform and MySQL JSON extraction for the retained compatibility path.

$this->addBehavior('JsonField');

$query = $this->find();
$this->addJsonWhere(
    $query,
    'Members.additional_info',
    '$.preferences.email',
    'person@example.test',
);

Paths must be $ or start with $.; every segment is limited to letters, numbers, and underscores. Invalid paths throw InvalidArgumentException. Path or field names must come from trusted application definitions, not arbitrary request parameters. Values are bound by the query expression.

Use relational columns for frequently queried, indexed, constrained, or security-relevant data. JSON is appropriate for genuinely flexible metadata, not as a shortcut around a migration.

SortableBehavior

SortableBehavior maintains a numeric position field, optionally within group columns. Its configuration keys are:

$this->addBehavior('Sortable', [
    'field' => 'position',
    'group' => ['parent_id'],
    'start' => 1,
    'step' => 1,
]);

It exposes toTop(), toBottom(), move(), moveBefore(), moveAfter(), getStart(), getStep(), getNew(), getLast(), and isFirst(). Saving a new entity assigns an end position; changing the configured position shifts nearby records in the same group.

This is a legacy shared behavior and no current core/plugin table attaches it. Its bulk-update expressions require focused PostgreSQL and concurrency testing before new use. For a new ordered aggregate, also add a uniqueness/index strategy and execute reordering in a transaction so concurrent moves cannot silently create duplicates.

WorkflowTriggerBehavior

WorkflowTriggerBehavior converts table lifecycle callbacks into the shared Workflow.trigger CakePHP event. It supports afterSave, afterSave.new, afterSave.existing, and afterDelete trigger mappings.

$this->addBehavior('WorkflowTrigger', [
    'triggers' => [
        'afterSave.existing' => [
            'trigger' => 'Waivers.ClosureChanged',
            'onlyIfChanged' => ['status'],
        ],
    ],
    'contextFields' => ['id', 'gathering_id', 'status'],
    'contextAliases' => ['closure_id' => 'id'],
]);

The event context includes filtered entity data, create/update/delete event, table name, primary key, the current HTTP identity when present, and optionally old/new values for dirty fields. onlyIfChanged fires when at least one named field is dirty. eventDataKey controls where the context is nested for the workflow dispatcher.

WorkflowTriggerBehavior::$suppressTriggers prevents recursive dispatch during controlled workflow/migration operations. Always restore the flag in finally in long-running processes and tests. The behavior logs dispatch exceptions; it does not turn the entity save into a durable outbox. Do not use it for an irreversible side effect that must be atomic with persistence without adding an explicit durable delivery design.

Choosing the right abstraction

Use a behavior when the concern is reusable table-level persistence or query behavior. Use:

Do not attach a behavior to a platform table and then call it through a tenant-scoped locator by accident. Data ownership remains explicit even when an API is reusable.

Testing

Behavior tests live under app/tests/TestCase/Model/Behavior. Cover:

For a behavior attached to a tenant table, add a sequential tenant A/B test when it uses static state, cached configuration, a table locator, or an external service.

Source files