Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Manager lifecycle

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#

  1. Decide by reversibility: can the effect be taken back or not? A reversible one belongs in afterSave, an irreversible one in completedSave.
  2. Declare the method on the manager with the exact signature β€” the base class finds it itself.
  3. In beforeSave do not take request data as truth β€” sanitise whatever came from outside (see below).
  4. Do not call find() or findAll() inside hooks (see below).
  5. Return Result::failure(...) when the operation should fail β€” the base class rolls back for you.
  6. Verify the failure path too: what stays in the database when afterSave returns 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#

  1. The entities are loaded BEFORE the delete and handed to the hooks as an array β€” after the DELETE the row no longer exists. The preload happens only when the manager declares hooks.
  2. 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