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#
- The
updateColumnspermission — if it passes, the attributes are not read at all. - Only on a denial is the request walked column by column looking for an attribute.
- One column without an attribute brings the whole call down. No partial writes.
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."
And on the Api presenter:
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 read → checkReadPermission() 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#
- 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. - Decide from the table below which attribute it is.
- Write it on the property in the shared base entity — so that it holds for every section.
- For the conditional variant write the method on the Api presenter and decide inside it with a database query.
- Verify both ways — that the permitted write passes and that a denied one really fails.
- 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