Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Column attributes

Column write attributes#

Who may overwrite a specific column from outside. The generic updateColumns is deliberately for administrators only — yet the front end legitimately changes a few counters and flags through it. The exception is therefore carried by the column itself, as a PHP attribute on the entity's property.

The attributes live in app/Core/Attributes/Hydration/ and are evaluated by app/Core/Traits/Api/Presenters/SaveTrait.php.

Two generations of attributes side by side#

Attribute Occurrences Role
#[ExternalReadonly] 474 the column must not arrive from outside (the server determines the value)
#[ExternalAdminOnly] 16 only a privileged caller may write it
#[PubliclyUpdatable] 4 anyone may write it through updateColumns
#[ConditionallyUpdatable] 4 a named method on the Api presenter decides
#[FieldAccess] 12 the new, unifying one — three axes: read / insert / update

The old four are not being removed, they are just not being added to

#[FieldAccess] is the target shape and both are read; a sweeping migration of the 474 occurrences will come with a parity test. Write FieldAccess in new code, do not rewrite finished places for cosmetics. Field audiences in detail: RBAC in the API.

The old four apply only on the EXTERNAL path, FieldAccess everywhere

The old strips run exclusively under $meta['__external'], which only actionSave() sets. FieldAccess is by role, so it applies on the internal path too. That is not an oversight, it is the reason the new attribute exists.

The decision order for updateColumns#

  1. The updateColumns permission — if it passes, the attributes are not read at all.
  2. Only on a denial is the request walked column by column looking for an attribute.
  3. One column without an attribute brings the whole call down. No partial writes.
Permission denied: column "banned" is not updatable without the updateColumns privilege

An attribute-granted write is logged

An API_COLATTR line appears in the auth channel. Without it the log would show a denial followed by a successful write — and the log would be lying.

#[PubliclyUpdatable]#

"Anyone may write this column."

#[ORM\Column(name: 'reported', type: 'boolean')]
#[ExternalReadonly]
#[PubliclyUpdatable]
protected bool $reported = false;

The typical case is a "reported" flag on an advert or a post.

It permits ANY value, not just a sensible one

That is why it is unsuitable for counters — votesUp could be set to a million in one request. Counters are incremented by the registerEvent action, which does a server-side + 1.

It does not check record ownership

It does not look at who the record belongs to. When that matters, it is a case for the conditional variant.

#[ConditionallyUpdatable]#

"The rule for this column is over there."

#[ConditionallyUpdatable(check: 'checkPermissionForRead')]
protected bool $read = false;

And on the Api presenter:

protected function checkPermissionForRead(int $id, mixed $value, ?int $userId): bool

Today it is used by the "read" flag on a reply to an advert and on a reply in the company catalogue — that is, the case where the owner is not on this record but on its parent.

Decide against the database, not against the request

$id only says which record we are talking about. Anything else from the request is the client's claim — a check over a value from the same request verifies nothing.

The method name is WRITTEN, not derived

A convention like readcheckReadPermission() is a trap: a typo would surface not as an error but as a silent denial saying "you have no rights". The existence of the methods is guarded by tests/Unit/Security/ConditionallyUpdatableContractTest.php.

Fail-closed: anything unclear is a denial

A single false on any id brings the whole call down. A missing record and an exception in the check both count as a denial.

Procedure: I need to allow writing one column from outside#

  1. First ask whether it really is a column. Incrementing a counter is registerEvent; a write with business logic is a dedicated Api action with its own permission key.
  2. Decide from the table below which attribute it is.
  3. Write it on the property in the shared base entity — so that it holds for every section.
  4. For the conditional variant write the method on the Api presenter and decide inside it with a database query.
  5. Verify both ways — that the permitted write passes and that a denied one really fails.
  6. Check the log: an attribute-granted write must be visible as API_COLATTR.
Situation Solution
anyone may write it and the value has no degrees #[PubliclyUpdatable]
only someone specific may write it #[ConditionallyUpdatable]
a counter that should go up by 1 neither — the registerEvent action
a write with its own business logic neither — a dedicated Api action
the value is determined solely by the server #[ExternalReadonly]

An attribute on a property means: anyone signed in, as many times as they like

It sits on the entity and is read by the boundary both paths go through. Marking a column is not "allowing one form" — it is a blanket permission.

A property must not carry both attributes at once

It would be unclear which rule applies. The same test guards it.

Coverage is guarded by structural tests#

Test What it checks
ExternalReadonlyCoverageTest that server-computed fields carry #[ExternalReadonly]
ExternalAdminOnlyCoverageTest the same for admin-only columns
ConditionallyUpdatableContractTest that the named check method exists
FieldAccessCoverageTest that a field whose name looks like a secret is annotated or has an exception with a reason

Coverage by field name is a sieve, not a protection

The test recognises password or secret. A field with an innocent name that still carries a secret (meta, config, note) has to be annotated by a human.

Where to look#

I want Where
the attribute definitions app/Core/Attributes/Hydration/
the evaluation on write app/Core/Traits/Api/Presenters/SaveTrait.php
stripping fields by permission the same file → stripExternalReadonlyFields(), stripExternalAdminOnlyFields()
the coverage tests tests/Unit/Security/

Follow-up chapters: RBAC in the API · Hydrators · Manager lifecycle