Manager lifecycle#
Where the side effects of writing and deleting belong β recounts, notifications,
invoices, indexing. The source: app/Core/Traits/Api/Models/Managers/{SaveTrait,DeleteTrait}.php.
The hooks are opt-in#
An Api manager offers a set of hooks for both save() and delete() that engage only
when the manager declares them β SaveTrait looks for them through method_exists().
A manager with none of them runs exactly as it did before.
| Hook | Returns | When it runs | A failure means |
|---|---|---|---|
beforeSave / beforeDelete |
?Result |
before the operation, inside the transaction | a rollback of the whole operation |
afterSave / afterDelete |
?Result |
after the operation, still inside the transaction | a rollback of the whole operation |
completedSave / completedDelete |
void |
after the commit, outside the transaction | it is only logged |
beforeSave(bool $isInsert, T &$entity, array &$meta): ?Result
afterSave(bool $isInsert, T &$entity, Result $result, array $meta): ?Result
completedSave(bool $isInsert, T $entity, Result $result, array $meta): void
beforeDelete(array $entities, array $meta): ?Result
afterDelete(array $entities, Result $result, array $meta): ?Result
completedDelete(array $entities, Result $result, array $meta): void
Data computed in before* flow into after* through $meta['__beforeSave'] (or
__beforeDelete) β not as another argument, so that the signature does not change and
existing managers do not break.
Which hook to use#
| Effect | Hook | Why |
|---|---|---|
| validation, capturing context, filling in a derived field | beforeSave |
the save can still be prevented |
| recomputing derived state from the stored data | afterSave / afterDelete |
the data exists but can still be rolled back |
| email, notification, PDF, indexing, payment | completedSave / completedDelete |
must not bring down already stored data |
Additive effects do NOT belong in afterDelete
Credits granted, emails sent, invoices issued. After the commit they cannot be
recomputed and a naive subtraction breaks the balance. What belongs in afterDelete is
only a recount of derived state from the remaining rows β reversible and idempotent.
That is why completed* is best-effort
SaveTrait wraps it in try/catch and only logs the exception. A failure to send an
email must not bring down an order that is already saved.
Procedure: adding a side effect of writing#
- Decide by reversibility: can the effect be taken back or not? A reversible one
belongs in
afterSave, an irreversible one incompletedSave. - Declare the method on the manager with the exact signature β the base class finds it itself.
- In
beforeSavedo not take request data as truth β sanitise whatever came from outside (see below). - Do not call
find()orfindAll()inside hooks (see below). - Return
Result::failure(...)when the operation should fail β the base class rolls back for you. - Verify the failure path too: what stays in the database when
afterSavereturns a failure.
protected function beforeSave(bool $isInsert, Invoice &$entity, array &$meta = []): ?Result
{
$this->calculator->recalculate($entity); // derived fields from the inputs
if ($isInsert && $entity->getBillingLineId() === null) {
return Result::failure(ResultCode::VALIDATION_FAILED, 'Invoice needs a billing line.');
}
return null; // null = carry on
}
π΄ Never find() or findAll() inside a hook#
A foreign Api read detaches the whole object graph
Hooks run over an already hydrated graph. Every Api read calls clearDoctrine(),
that is, empties the identity map β and the next flush() ends with
A new entity was found through the relationship β¦. This holds for a foreign module's
manager called "just for one code list" too.
| What I need in a hook | The safe substitute |
|---|---|
| one code list row | a raw DBAL query |
| a setting | SettingContext |
| a foreign key | $em->getReference() |
| an existence check | SELECT COUNT through DBAL |
In detail: Identity map.
Transactions#
Both save and delete wrap the hooks in a transaction (useTransactionForSave +
beginTransaction() on the service). A failing Result and a thrown exception in
before* / after* both roll the whole operation back; completed* runs only after the
commit.
After a successful commit the base class also bumps the cache tag versions
(bumpCacheTags()) β generic invalidation the manager does not have to write. On a
rollback nothing is bumped, which is consistent: nothing was stored, so there is nothing to
invalidate. In detail: Cache.
Deleting has two peculiarities#
- The entities are loaded BEFORE the delete and handed to the hooks as an array β
after the
DELETEthe row no longer exists. The preload happens only when the manager declares hooks. - Bulk and tree deletes take a different path.
deleteAll(filters) and deleteNode() do NOT fire the hooks
Anyone relying on a hook finds out by nothing being recomputed after a bulk delete β the counts in the categories stay stale and there is no error anywhere.
Monotonic versus absolute recalculation#
A denormalised column fed from payments has two variants, distinguished by a flag in
$meta:
| Variant | Who calls it | Behaviour |
|---|---|---|
update* |
the payment gateway, the front end | only extends β never shortens |
recalc* |
the administration | can shorten too, even to nothing |
afterDelete always recalculates absolutely β after a payment is deleted the value has
to drop.
The monotonic variant is protection against callback ordering
Gateway callbacks do not arrive in a guaranteed order. If an extension could shorten, a late callback from an older payment would remove validity added by a newer one.
No database triggers#
Denormalised state belongs in PHP hooks, not in triggers
A trigger cannot be versioned, tested or read from the code. A new developer does not know about it and looks for the bug where it is not. What triggers used to do is in the hooks today β and it should stay that way.
External writes#
On a genuinely external path $meta['__external'] === true.
A hook has to sanitise sensitive fields server-side
Without that the client sets "paid" for itself. The declarative protection is the column write attributes; a hook is the second layer for whatever an attribute cannot express.
Where to look#
| I want | Where |
|---|---|
| the hook order and the transaction | app/Core/Traits/Api/Models/Managers/SaveTrait.php |
| the entity preload before deleting | app/Core/Traits/Api/Models/Managers/DeleteTrait.php |
| the cache invalidation after the commit | app/Core/Base/BaseManager.php β bumpCacheTags() |
a finished example with beforeSave and completedSave |
app/UI/Api/Invoicer/Models/Managers/InvoiceManager.php |
Follow-up chapters: Identity map Β· Hydrators Β· Column write attributes Β· Cache