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:
- a finder for a query specific to one table/domain;
- an entity method for local record behavior;
- a policy for authorization;
- a service for multi-table or external workflows; and
- a workflow provider for reusable lifecycle automation.
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:
- exact date boundaries and a supplied effective time;
- PostgreSQL JSON SQL generation, invalid paths, and bound values;
- public-ID generation/collision behavior plus authorization at the caller;
- group isolation and concurrent ordering for any new Sortable use; and
- workflow create/update/delete filtering, suppression cleanup, and context minimization.
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
app/src/Model/Behavior/ActiveWindowBehavior.phpapp/src/Model/Behavior/PublicIdBehavior.phpapp/src/Model/Behavior/JsonFieldBehavior.phpapp/src/Model/Behavior/SortableBehavior.phpapp/src/Model/Behavior/WorkflowTriggerBehavior.php