CoolCMS API

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

actionGet()

public actionGet([int|null $id = null ]) : void
Parameters
$id : int|null = null

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() calls internalActionSave() directly): $meta['__external'] is not set. Front end advert creation fills insertUserId from the session itself; if the internal path got here, creating adverts as an anonymous user (advert password) would break.
  • on UPDATE (data.id filled): 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: array, 1: array}

ownedEntityLoader()

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(): Array

ownedForUpdateColumns()

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|null

refineSaveAction()

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:

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|null

logPrivilegeFreeUpdate()

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>|null

probeCallerTierForSave()

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
Return values
ApiPermissionDeniedShape
On this page

Search results