CrudTrait uses trait:short, trait:short, trait:short, trait:short, trait:short
Complete set of CRUD actions of an Api presenter: aggregates reading and writing so that the admin gets full management of a section through `ApiBridge` (get, get-by, get-all, save, update-columns, delete, delete-all).
Depends only on $this->manager (from BasePresenter) and
$this->hydrator (injected by the concrete presenter). No trait has colliding method names,
so they combine without insteadof.
Tree operations (TreeTrait: move-up, move-down, delete-node, insert-tree-node, move-subtree, move-subtree-to) and domain events (RegisterEventTrait) are deliberately absent; they are opt-in where they make sense (tree entities, statistics).
Table of Contents
Methods
- actionDelete() : void
- actionDeleteAll() : void
- actionGet() : void
- actionGetAll() : void
- actionGetBy() : void
- actionSave() : void
- actionUpdateColumns() : void
- collectWriteAudiences() : array<string, Audience>
- Returns the `insert`/`update` axis audience for every entity field with `#[FieldAccess]`; the key is the name without underscores, lowercase.
- internalActionDelete() : array<string, mixed>
- internalActionDeleteAll() : array<string, mixed>
- internalActionGet() : array<string, mixed>
- internalActionGetAll() : array<string, mixed>
- internalActionGetBy() : array<string, mixed>
- internalActionSave() : array<string, mixed>
- internalActionUpdateColumns() : array<string, mixed>
- applyExternalOwner() : array<string, mixed>
- On the external path when creating, sets the record owner from the caller JWT identity.
- applyExternalRemoteIp() : array<string, mixed>
- On the external path when creating, sets the caller IP from the server ({@see \App\Core\Base\Api\Models\Managers\Manager::getExternalRemoteIpData()}).
- columnsOutsideExternalAllowlist() : array<int, string>
- Returns the columns from `$values` that are not in `manager->getExternalUpdateColumnsAllowlist()`, exactly the ones {@see filterExternalUpdateColumns()} drops on the external path.
- filterExternalMeta() : array<string, mixed>
- Keeps in `$meta` only the keys from `manager->getExternalMetaAllowlist()`; the rest is dropped and logged so a lost external key is visible. The default allowlist is `[]`, so without a per-manager override the whole `$meta` is dropped (privileged flags such as `roleIds`, `groupIds`, `skipNotifications`, `rating.*`, `recalcTopedUntil` have no external way in).
- filterExternalUpdateColumns() : array<string, mixed>
- Keeps in `$values` only the columns from `manager->getExternalUpdateColumnsAllowlist()` (default `[]` rejects external `updateColumns` entirely); the rest is dropped and logged.
-
moveGraphKeysToMeta()
: array{0: array
, 1: array } - Moves the keys from `manager->getGraphDataKeys()` from `$data` to `$meta`, so the existing afterSave/beforeSave manager sync traits read them unchanged (same mechanism as the internal `meta`, only from another source).
- ownedEntityLoader() : callable(): Array
- Returns a lazy entity loader for `ALLOW_IF_OWNER` (see {@see internalActionSave()}); from `$data` only the identifier is taken. Shared by the trait `internalActionSave()` and by presenters that override `internalActionSave()`.
- ownedForUpdateColumns() : Closure|null
- Returns a lazy entity loader for `ALLOW_IF_OWNER` on `updateColumns`.
- refineSaveAction() : "save"|"insert"|"update"
- Refines the `save` action to `insert` or `update`.
- saveEntityData() : array<string, mixed>
- Saves the entity without a guard. Call only from `internalActionSave()` after `requireApiPermission()` (the trait and presenters that override `internalActionSave()` for post-save orchestration, e.g. a bell notification). `ApiPermissionCoverageTest` checks that every `internalAction*()` has a guard; this method is not `internalAction*` and the bridge never calls it.
- stripDefaultSkipFields() : array<string|int, mixed>
- Removes from `$data` the keys listed in `manager->getDefaultSkipFields()` before hydration.
- stripExternalAdminOnlyFields() : array<string|int, mixed>
- Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalAdminOnly} on the entity `$entityClass`.
- stripExternalReadonlyFields() : array<string|int, mixed>
- Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalReadonly} on the entity `$entityClass`.
- stripExternalUploadPaths() : array<string, mixed>
- Strips the `path` key from all nested `meta` items on the external boundary.
- stripFieldsOutsideWriteAudience() : array<string|int, mixed>
- Removes from the `$data` root the fields whose `#[FieldAccess]` axis for the operation (`insert` when creating, `update` when editing) does not admit the caller audience.
- firstColumnWithoutPrivilegeFreeGrant() : string|null
- Second stage of `updateColumns`: attributes on columns.
- logPrivilegeFreeUpdate() : void
- Writes the `API_COLATTR` line for the case where the guard denied but the attributes allowed the write.
- normalizeDeleteId() : int|array<int, int>|null
- Normalizes the id: `'5'` to `5`; `['1','2']` to `[1, 2]`; `'abc'`, `''`, `[]`, `0` and `null` to `null`.
- probeCallerTierForSave() : void
- Measures the caller tier on the internal path for `save`; changes nothing.
- probeCallerTierForUpdateColumns() : void
- Measures the caller tier on the internal path for `updateColumns`.
- updateColumnsDeniedPayload() : ApiPermissionDeniedShape
- Builds the `updateColumns` denial naming the column that caused it.
Methods
actionDelete()
public
actionDelete() : void
actionDeleteAll()
public
actionDeleteAll() : void
actionGet()
public
actionGet([int|null $id = null ]) : void
Parameters
- $id : int|null = null
actionGetAll()
public
actionGetAll() : void
actionGetBy()
public
actionGetBy() : void
actionSave()
public
actionSave() : void
actionUpdateColumns()
public
actionUpdateColumns() : void
collectWriteAudiences()
Returns the `insert`/`update` axis audience for every entity field with `#[FieldAccess]`; the key is the name without underscores, lowercase.
public
static collectWriteAudiences(class-string $entityClass, bool $isInsert) : array<string, Audience>
The hierarchy is walked from the leaf and the first declaration wins (like
OrmEntityHydrator::collectAllProperties()): a leaf that redeclares a property must carry
the attribute itself. Fields writable by nobody (Audience::None) are already trimmed by
OrmEntityHydrator::stripNobodyWritableFields(); they are
returned here too so the result is complete for the guard test.
Parameters
- $entityClass : class-string
- $isInsert : bool
Return values
array<string, Audience>internalActionDelete()
public
internalActionDelete(int|array<string|int, mixed> $id) : array<string, mixed>
Parameters
- $id : int|array<string|int, mixed>
-
one id or an array of ids (bulk delete)
Return values
array<string, mixed> —response envelope
internalActionDeleteAll()
public
internalActionDeleteAll([array<string|int, Filter|array<string, mixed>> $filters = [] ]) : array<string, mixed>
Parameters
- $filters : array<string|int, Filter|array<string, mixed>> = []
Return values
array<string, mixed> —response envelope
internalActionGet()
public
internalActionGet(int|null $id, string|array<string|int, mixed> $includes) : array<string, mixed>
Parameters
- $id : int|null
- $includes : string|array<string|int, mixed>
Return values
array<string, mixed> —response envelope
internalActionGetAll()
public
internalActionGetAll([array<string|int, Filter|array<string, mixed>> $filters = [] ][, array<string|int, mixed>|null $orderBys = null ][, int|null $offset = null ][, int|null $limit = null ][, array<string|int, mixed>|null $columns = null ][, bool $computeTotalCount = false ][, string|array<string|int, mixed> $includes = [] ]) : array<string, mixed>
Parameters
- $filters : array<string|int, Filter|array<string, mixed>> = []
-
filter definitions (shape checked by FilterBuilder::isBuildable())
- $orderBys : array<string|int, mixed>|null = null
- $offset : int|null = null
- $limit : int|null = null
- $columns : array<string|int, mixed>|null = null
- $computeTotalCount : bool = false
- $includes : string|array<string|int, mixed> = []
Return values
array<string, mixed> —response envelope (success, code, message, data, ...)
internalActionGetBy()
public
internalActionGetBy([array<string|int, Filter|array<string, mixed>> $filters = [] ][, string|array<string|int, mixed> $includes = [] ]) : array<string, mixed>
Parameters
- $filters : array<string|int, Filter|array<string, mixed>> = []
- $includes : string|array<string|int, mixed> = []
Return values
array<string, mixed> —response envelope
internalActionSave()
public
internalActionSave(array<string|int, mixed> $data[, array<string|int, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : array<string, mixed>
Parameters
- $data : array<string|int, mixed>
- $meta : array<string|int, mixed> = []
- $skipFields : array<int, string> = []
Return values
array<string, mixed> —response envelope
internalActionUpdateColumns()
public
internalActionUpdateColumns(array<string|int, mixed> $ids, array<string, mixed> $values) : array<string, mixed>
Parameters
- $ids : array<string|int, mixed>
- $values : array<string, mixed>
Return values
array<string, mixed> —response envelope
applyExternalOwner()
On the external path when creating, sets the record owner from the caller JWT identity.
protected
applyExternalOwner(array<string, mixed> $data) : array<string, mixed>
It lives here and not in the manager because the manager does not know the caller identity
(a transport detail). This is the same boundary where #[ExternalReadonly] is stripped and
the __external marker is set: the only place in the chain with the token and certainty that
it is a real external HTTP request. The ownership check, in contrast, is not in the presenter
but in the manager (OwnershipCheckTrait).
It does not run:
- on the internal path (
ApiBridge::internalCall()callsinternalActionSave()directly):$meta['__external']is not set. Front end advert creation fillsinsertUserIdfrom the session itself; if the internal path got here, creating adverts as an anonymous user (advert password) would break. - on UPDATE (
data.idfilled): setting the owner on edit would mean "whoever saves a foreign record owns it", the opposite of#[ExternalReadonly]. - without a token (anonymous or X-API-Key client): there is nobody to set. The record is created without an owner; whether to allow it is a gate decision.
Parameters
- $data : array<string, mixed>
Return values
array<string, mixed>applyExternalRemoteIp()
On the external path when creating, sets the caller IP from the server ({@see \App\Core\Base\Api\Models\Managers\Manager::getExternalRemoteIpData()}).
protected
applyExternalRemoteIp(array<string, mixed> $data) : array<string, mixed>
The IP is IRequest::getRemoteAddress(), a transport value from the HTTP boundary, never a
payload value (that one is already stripped as #[ExternalReadonly]). Same conditions as
applyExternalOwner(): INSERT only; UPDATE keeps the IP from the database.
Parameters
- $data : array<string, mixed>
Return values
array<string, mixed>columnsOutsideExternalAllowlist()
Returns the columns from `$values` that are not in `manager->getExternalUpdateColumnsAllowlist()`, exactly the ones {@see filterExternalUpdateColumns()} drops on the external path.
protected
columnsOutsideExternalAllowlist(array<string|int, mixed> $values) : array<int, string>
The single implementation of that rule: used by the filter and by the measurement probe
probeCallerTierForUpdateColumns(), so would_drop in the log cannot diverge from what
enforcement would drop. A numeric key ($values[0]) cannot be in the allowlist, so it is
always outside.
Parameters
- $values : array<string|int, mixed>
Return values
array<int, string>filterExternalMeta()
Keeps in `$meta` only the keys from `manager->getExternalMetaAllowlist()`; the rest is dropped and logged so a lost external key is visible. The default allowlist is `[]`, so without a per-manager override the whole `$meta` is dropped (privileged flags such as `roleIds`, `groupIds`, `skipNotifications`, `rating.*`, `recalcTopedUntil` have no external way in).
protected
filterExternalMeta(array<string|int, mixed> $meta) : array<string, mixed>
Parameters
- $meta : array<string|int, mixed>
Return values
array<string, mixed>filterExternalUpdateColumns()
Keeps in `$values` only the columns from `manager->getExternalUpdateColumnsAllowlist()` (default `[]` rejects external `updateColumns` entirely); the rest is dropped and logged.
protected
filterExternalUpdateColumns(array<string, mixed> $values) : array<string, mixed>
Parameters
- $values : array<string, mixed>
Return values
array<string, mixed>moveGraphKeysToMeta()
Moves the keys from `manager->getGraphDataKeys()` from `$data` to `$meta`, so the existing afterSave/beforeSave manager sync traits read them unchanged (same mechanism as the internal `meta`, only from another source).
protected
moveGraphKeysToMeta(array<string, mixed> $data, array<string, mixed> $meta) : array{0: array, 1: array}
Explicit meta wins: when a client sends the same key in data and meta (typically admin,
which sends categoryRels in meta as flat rows while hydrator references from the form clone
also land in data), the meta value stays and the data copy is dropped. If data won,
it would be replaced by references and the sync trait would read categoryId=0 from them,
giving delete-all instead of update.
The key is always unset from $data, even when meta won: that is the real hydration
protection, not the move itself (same principle as self::stripDefaultSkipFields()).
Parameters
- $data : array<string, mixed>
- $meta : array<string, mixed>
Return values
array{0: arrayownedEntityLoader()
Returns a lazy entity loader for `ALLOW_IF_OWNER` (see {@see internalActionSave()}); from `$data` only the identifier is taken. Shared by the trait `internalActionSave()` and by presenters that override `internalActionSave()`.
protected
ownedEntityLoader(array<string, mixed> $data) : callable(): Array
Parameters
- $data : array<string, mixed>
Return values
callable(): ArrayownedForUpdateColumns()
Returns a lazy entity loader for `ALLOW_IF_OWNER` on `updateColumns`.
protected
ownedForUpdateColumns(array<string|int, mixed>|int $ids) : Closure|null
Fail-closed default null: without a closure a conditional grant ends as deny, so this hook
changes nothing for any CRUD presenter until it is overridden.
IMPORTANT: override it only in a presenter that knows what the bulk action changes.
updateColumns is a generic column setter, so "owner" makes sense only for one record; for
several ids the implementation returns null (a collection has no owner).
Parameters
- $ids : array<string|int, mixed>|int
-
id or array of ids from the call
Return values
Closure|nullrefineSaveAction()
Refines the `save` action to `insert` or `update`.
protected
refineSaveAction(array<string|int, mixed> $data) : "save"|"insert"|"update"
One key carries one policy, but creating and editing are two different sentences about the
same role: an advert may be created by anyone, even anonymous, but edited only by the owner or
an administrator. That cannot be written on :Api:...:save, hence two refining keys.
Insert is recognized by OwnershipPayload::entityId($data) === null (missing data.id), the
same implementation as the $owned closure and the external verifyOwnership(), so the two
branches cannot diverge.
IMPORTANT: the refining key is used only when it exists in the policy
(PermissionResolver::hasKey()); otherwise 'save' is returned,
i.e. the previous behavior. Without it every presenter would deny even an administrator
between deploying the code and running the SQL. This does not weaken fail-closed: the
fallback is a restrictive seeded key, not a bypass, and a typo in the literal ends at save.
NOTE: the hasKey() probe is free; it reads the per-request branchCache filled by the
first decide() in the guard.
Parameters
- $data : array<string|int, mixed>
Return values
"save"|"insert"|"update"saveEntityData()
Saves the entity without a guard. Call only from `internalActionSave()` after `requireApiPermission()` (the trait and presenters that override `internalActionSave()` for post-save orchestration, e.g. a bell notification). `ApiPermissionCoverageTest` checks that every `internalAction*()` has a guard; this method is not `internalAction*` and the bridge never calls it.
protected
saveEntityData(array<string, mixed> $data[, array<string, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : array<string, mixed>
Parameters
- $data : array<string, mixed>
- $meta : array<string, mixed> = []
- $skipFields : array<int, string> = []
Return values
array<string, mixed>stripDefaultSkipFields()
Removes from `$data` the keys listed in `manager->getDefaultSkipFields()` before hydration.
protected
stripDefaultSkipFields(array<string|int, mixed> $data) : array<string|int, mixed>
Name normalization copies the hydrator (OrmEntityHydrator/EntityHydrator::normalizePropertyName,
both private): extract emits keys 1:1 with the property name (camelCase), but a client may
also send the snake_case form of the same field and the strip must not miss it. Extracted
into its own method so it can be unit-tested without DI.
Parameters
- $data : array<string|int, mixed>
Return values
array<string|int, mixed>stripExternalAdminOnlyFields()
Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalAdminOnly} on the entity `$entityClass`.
protected
stripExternalAdminOnlyFields(array<string|int, mixed> $data, class-string $entityClass) : array<string|int, mixed>
Sibling of stripExternalReadonlyFields(), called only when the caller is not
privileged (see the gate in internalActionSave(), step (0a)). Recursion into owned
ToOne/ToMany associations is the same as for #[ExternalReadonly]:
OrmEntityHydrator::stripExternalAdminOnlyFieldsDeep().
Parameters
- $data : array<string|int, mixed>
- $entityClass : class-string
Return values
array<string|int, mixed>stripExternalReadonlyFields()
Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalReadonly} on the entity `$entityClass`.
protected
stripExternalReadonlyFields(array<string|int, mixed> $data, class-string $entityClass) : array<string|int, mixed>
Called only on the external HTTP path ($meta['__external'] === true, see
self::internalActionSave()). Protects server-controlled fields (payment, moderation,
topping: typically paid, banned, approvedAt) that a client could otherwise set directly
(privilege escalation). Name normalization copies self::stripDefaultSkipFields():
extract emits keys 1:1 with the property name (camelCase), but a client may also send the
snake_case form of the same field.
The recursion (OrmEntityHydrator::stripExternalReadonlyFieldsDeep())
also trims owned cascade-persist collections and ToOne (items[], shipper, ...). A
top-level-only strip would leave #[ExternalReadonly] on nested entities (OrderItem::$price)
without effect, because OrderCalculator trusts item prices as input.
Extracted into its own method so it can be unit-tested without DI.
Parameters
- $data : array<string|int, mixed>
- $entityClass : class-string
Return values
array<string|int, mixed>stripExternalUploadPaths()
Strips the `path` key from all nested `meta` items on the external boundary.
protected
stripExternalUploadPaths(array<string, mixed> $meta) : array<string, mixed>
IMPORTANT: a reference to a file in the dropzone temp folder ({name, path}) may be sent
only by the internal form through the bridge, because path means "read this file on the
server". It is removed recursively whether the item passed the allowlist or arrived in
data as a graph key (moveGraphKeysToMeta()). A second, independent safeguard is in
the reader (UploadedItem): only an internal call may pass path.
Parameters
- $meta : array<string, mixed>
Return values
array<string, mixed>stripFieldsOutsideWriteAudience()
Removes from the `$data` root the fields whose `#[FieldAccess]` axis for the operation (`insert` when creating, `update` when editing) does not admit the caller audience.
protected
stripFieldsOutsideWriteAudience(array<string|int, mixed> $data, class-string $entityClass, bool $isInsert, array<string, mixed> $meta) : array<string|int, mixed>
Decided by FieldAudienceContext::allows(), the same rule that
filters the emit (read axis). Called only on the external path (step (0a') in
saveEntityData()); the internal bridge (admin, front end, cron) is unchanged.
FieldAccess is used instead of #[ExternalAdminOnly] because the latter cannot tell
creating from editing: Comment::$threadId must be sendable on create but not on edit (a move
into a foreign thread). FieldAccess has those axes and is the declared successor of the old
four attributes; on writes only Audience::None was evaluated before
(OrmEntityHydrator::stripNobodyWritableFields()). This step
also enforces the other audiences, e.g. PaymentGate::$settings and LoginProvider::$clientSecret.
On the external path the audience context is always set (the guard in
requireApiPermission(), called first by internalActionSave(), fills it). If it is not, the
decision is fail-closed by the tier from actionSave(): only Audience::All passes, and for a
privileged caller also Privileged/Owner/Authenticated. Audience::Owner on a write
means the same as on a read, currently as strict as Privileged.
Only the payload root is trimmed (like stripNobodyWritableFields()); keys are compared
ignoring case and underscores, because the hydrator looks up setters just as loosely.
Parameters
- $data : array<string|int, mixed>
- $entityClass : class-string
- $isInsert : bool
- $meta : array<string, mixed>
Return values
array<string|int, mixed>firstColumnWithoutPrivilegeFreeGrant()
Second stage of `updateColumns`: attributes on columns.
private
firstColumnWithoutPrivilegeFreeGrant(int|array<int, mixed> $ids, array<string|int, mixed> $values) : string|null
Returns the name of the first column that a write without the privilege does not allow, or
null when all pass. Called only when the updateColumns guard denied. The whole call is
denied (no partial write), so once one column fails the others need not be checked, and its
name is what whoever debugs the PERMISSION_DENIED needs.
Fate of a column by attribute:
- PubliclyUpdatable: passes;
- ConditionallyUpdatable: the named presenter method is
called for each id; a single
falseblocks the column; - none: blocks.
IMPORTANT: fail-closed on any ambiguity: no manager, no entity class, a missing check method or an exception from it blocks the column. A missing method is a typo in the attribute, guarded by ConditionallyUpdatableContractTest.
Parameters
- $ids : int|array<int, mixed>
- $values : array<string|int, mixed>
Return values
string|nulllogPrivilegeFreeUpdate()
Writes the `API_COLATTR` line for the case where the guard denied but the attributes allowed the write.
private
logPrivilegeFreeUpdate(array<int, string|int> $columns) : void
Without it log/auth.log would hold decision=DENY followed by a successful write, i.e. it
would lie, and later phases read from this log what really happens.
Parameters
- $columns : array<int, string|int>
normalizeDeleteId()
Normalizes the id: `'5'` to `5`; `['1','2']` to `[1, 2]`; `'abc'`, `''`, `[]`, `0` and `null` to `null`.
private
normalizeDeleteId(mixed $id) : int|array<int, int>|null
Parameters
- $id : mixed
Return values
int|array<int, int>|nullprobeCallerTierForSave()
Measures the caller tier on the internal path for `save`; changes nothing.
private
probeCallerTierForSave(array<string, mixed> $data, class-string $entityClass) : void
Takes $data by value and only writes to the log the answer to: "if the
#[ExternalReadonly]/#[ExternalAdminOnly] strip followed the role instead of the transport,
what would this caller lose?". Active only on the internal path and only when internalMode
is log or enforce (ApiPermissionGuardTrait::isCallerTierProbeActive());
in off it is two comparisons and a return (no queries, no lines).
A privileged caller (currently all of admin) is not logged: the strip would take nothing, so
it would be noise. A tier=privileged line in the measurement would be a bug in tier
resolution, so that branch must return before anything is computed.
Parameters
- $data : array<string, mixed>
- $entityClass : class-string
probeCallerTierForUpdateColumns()
Measures the caller tier on the internal path for `updateColumns`.
private
probeCallerTierForUpdateColumns(array<string|int, mixed> $values) : void
Sibling of probeCallerTierForSave(); would_drop is the per-manager allowlist
(columnsOutsideExternalAllowlist()), not an attribute strip: updateColumns goes
straight to DBAL, bypassing the hydrator, so there are no #[External*] attributes.
Parameters
- $values : array<string|int, mixed>
updateColumnsDeniedPayload()
Builds the `updateColumns` denial naming the column that caused it.
private
static updateColumnsDeniedPayload(string $column) : ApiPermissionDeniedShape
The shape is bit for bit the same as the guard one
(ApiPermissionGuardTrait::permissionDeniedPayload()), only message differs. The
column name is schema information, not data, and the schema is visible from every get*
response anyway. Without it a denial on a ten-column call would be debugged blind.
Parameters
- $column : string