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.
After the delete, getId() of a removed entity returns null
Doctrine nulls the ID of a removed managed instance on flush(), so in afterDelete and
completedDelete $entity->getId() no longer returns what the entity had.
DeleteTrait::delete() therefore captures the IDs before beforeDelete and passes them to
every hook in $meta['__deletedIds'] (the constant DeleteTrait::DELETED_IDS_META, a
WeakMap keyed by the entity object). Read them only through
$this->getDeletedId($meta, $entity); it returns null for an entity this delete did not
load. The key is always set, so a value the caller sends under it is overwritten.
A hook with a different signature rolls back every save
SaveTrait finds the hook through method_exists() and calls it positionally in the
order above. When bool $isInsert is missing in the first position, the parameter typed
as the entity receives a bool and PHP throws a TypeError. save() catches it like any
exception, rolls the transaction back and answers DB_ERROR; the cause is only in the
save.error log. So it is not one save that fails but every save of that entity. Copy a
new hook from the signature above.
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.
A failed service save is rolled back entirely
When service->save() returns a failure (for example DB_DUPLICATE_ENTRY from the
repository), SaveTrait rolls the transaction back and runs neither afterSave nor
completedSave. Writes already made by beforeSave (a voucher counter, the order number
series) therefore do not stay without the record.
afterCommit(): an effect only after the real commit#
A nested save (a manager called from another operation) "commits" only logically and its
completedSave still runs inside the outer transaction. That one may roll back — and an email
sent straight to SMTP cannot be taken back. Register an irreversible effect with
$this->service->afterCommit(fn () => …) (TransactionManager::afterCommit()):
| Situation | When the callback runs |
|---|---|
| outside a transaction | at once |
| inside a transaction (nested too) | after the commit of the outermost transaction |
| rollback, rollback-only, failed commit | never, the callback is dropped |
Callbacks run outside the transaction and are best-effort like completedSave: an exception is
only written to the transaction.afterCommit log and the other callbacks still run. It sees
only TransactionManager transactions; a bare DBAL transaction opened around it
(beginTransaction() on the connection) does not exist for it and the callback runs at once.
The email about an advert response and the new-message notice of a conversation go out this way.
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
}
Load foreign data before the hook builds anything#
A hook runs over a hydrated graph inside one operation
Reads (find(), findAll()) do not empty the identity map; only the boundary of an
operation does. A hook that calls a foreign manager merely joins the
running operation. Still, it is safer to load code lists and settings before the graph
is built, or take them through DBAL: an entity a hook carries out of the operation is
detached once the operation ends, and flush() then fails with
A new entity was found through the relationship ….
| 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.
A rollback at the outermost level (TransactionManager::rollbackPhysically()) also empties the
UnitOfWork ($em->clear()). The hydrator writes into managed entities before beforeSave(),
so after a rejected save the overwritten collections and the scheduled orphanRemoval stay in
memory; the next flush in the same process (the next cron or import item, another manager)
would otherwise write them. A nested rollback does not clear the UnitOfWork, it only marks the
transaction "rollback-only".
| Where it failed | What is left in the UnitOfWork |
|---|---|
| a save at the outermost level | nothing, the identity map is empty |
| a save nested in a foreign transaction | the whole graph under construction until the caller rolls back |
a rejection through ProductSetManager::reject() or ShipperTariffManager::rejectImmutable() |
nothing, the manager calls UnitOfWorkReset::abort() itself |
Reload the entity after a failed save
The instance the caller held is detached after the clear. Another save() or flush works
with a graph Doctrine does not know, and the changes are silently lost or the save fails.
To retry, load the entity again from the Api.
A custom operation over several rows: inAtomicTransaction()#
An operation outside save()/delete() that changes several rows (a matrix, a reorder,
a bulk setting) belongs into AtomicTransactionTrait::inAtomicTransaction($connection, $work).
The closure returns a Result: a failure and an exception mean rollback, an exception is
logged and returned as DB_ERROR, success means commit.
return $this->inAtomicTransaction($this->em->getConnection(), function (): Result {
// several writes; return the first failure as Result::failure(...)
return Result::success(ResultCode::OK);
}, 'invoicer.pdfVariantMatrix');
It opens its own transaction only when none is running (isTransactionActive() on the DBAL
connection). Called inside another transaction it only returns the result and leaves commit
and rollback to the caller.
A nested rollback does not undo just the inner part
TransactionManager uses no savepoints. A rollback on a nested level only marks the
whole transaction rollback-only, and the caller's last commit then undoes everything and
throws RollbackOnlyException. That is why inAtomicTransaction() in a nested run rolls
nothing back itself and only passes the Result on.
A closed EntityManager after a failed flush#
Doctrine ORM 3 has no reopen(). When a flush fails, UnitOfWork::commit() closes the
EntityManager and every later persist()/flush() in the same process throws "The
EntityManager is closed." — the next cron or import item, an order save after a customer
race. App\Core\Doctrine\EntityManagerReopener::reopenIfClosed() reopens it and drops the
UnitOfWork persisters Doctrine keeps after closing. Outside TransactionManager neither the
reopener nor clear()/detach() is called directly (UnitOfWorkResetBoundaryTest checks
this); go through App\Core\Doctrine\Operations\UnitOfWorkReset instead:
| Who | What it calls |
|---|---|
TransactionManager |
by itself after the outermost rollback — inside a manager transaction you write nothing |
| places that handle a flush failure themselves (the product and bundle import) | UnitOfWorkReset::recoverAfterFailure($reason, $logChannel) — clears an open EntityManager, reopens a closed one |
a place that only has to reopen a closed EntityManager (OrderCustomerManager) |
UnitOfWorkReset::reopenIfClosed($reason, $logChannel) |
a phase that starts with an empty map after DBAL writes (FeedManager, StockDocumentManager) |
UnitOfWorkReset::resetBatch($reason) |
Procedure: a loop that must carry on after one item fails
- Catch and log the item's error.
- Call
UnitOfWorkReset::recoverAfterFailure(): it clears an open EntityManager and reopens a closed one. A directclear()ordetach()failsUnitOfWorkResetBoundaryTest. - Carry on with the next item — and check that it really is saved after the error.
Reopen only after a rollback and with an empty UnitOfWork
The persister of an entity whose INSERT failed keeps it in $queuedInserts. Without
dropping it, the next flush would replay it with an empty changeset — an INSERT of all
nulls. That is why the reopener drops the persisters and runs only after the transaction
is rolled back.
The reopener reaches into Doctrine internals by reflection
The closed, persisters and collectionPersisters properties are covered by
tests/Unit/Core/TransactionSequencesTest.php; after an ORM update that renames them,
rewrite the helper. It cannot reopen a decorated EntityManager — it returns false and
logs it, and later writes in the process then fail.
Comment, rating or conversation thread on insert#
An entity that can be commented on or rated right after it is created gets its thread in
afterSave() on INSERT. The manager uses one of three traits; each saves the thread and
writes its id into the entity column through updateColumn():
| Trait | Method | Thread | Default column |
|---|---|---|---|
CommentThreadCreatorTrait |
createCommentThread($id, ThreadType) |
empty comment thread | commentThreadId |
RatingThreadCreatorTrait |
createRatingThread($id, ThreadType) |
empty rating thread | ratingThreadId |
MessageThreadCreatorTrait |
openMessageThreadFor($id, MessageThreadType, …) |
private conversation with two participants and a first message | messageThreadId |
- Inject the thread manager through the constructor (
commentThreadManager,ratingThreadManager,messageThreadManager); the trait only reads it. - Pass the thread type yourself (the section's
ThreadType) — it is kept on the thread for moderation in the administration and per-type configuration. - Return a failure from
afterSave(): the trait opens no transaction of its own, the rollback is done bysave().
A conversation without a participant with an account is not created
openMessageThreadFor() returns null and messageThreadId stays empty when neither side
has an account (a guest writing to an anonymous advert). The column is nullable and this is
not an error: nobody would ever open such a thread. Pass the participants without
threadId and role; MessageThreadManager::openThread() fills them in.
updateColumns() with a collection replaces it#
A collection (ToMany) in updateColumns() is written as a full replace: all links of the
parent are deleted and the given ones inserted. An empty array therefore deletes everything.
The values must be relation entity objects; a scalar ends with INVALID_ARGUMENT before any
write. Columns and collections go in one mapper transaction, so a failure halfway through the
inserts does not leave the parent with half of its rows (product set price snapshots).
Inside a foreign transaction the mapper rolls back only its own level
The mapper rolls back only the level it opened itself and only logs a failed rollback. A
blind rollback would throw NoActiveTransaction after a failed commit and hide the
original error, or it would roll back the caller's level too.
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.
A bulk delete (delete([…]) without hooks, deleteAll()) runs as one transaction: on a
technical failure the whole batch is rolled back and the Result carries deletedCount = 0
and rolledBack = true. Missing ids are not an error; the rest of the batch is deleted and
committed.
Image cleanup of a deleted entity#
After the commit DeleteTrait calls completedAssetCleanup() from AssetProcessingTrait
next to completedDelete(). It runs only for a manager that defines
ownedAssetIds(object $entity): array (which images the entity owns). The procedure and the
rules for shared images: Image variants.
A class method overrides a trait method without a warning
Many Api managers define completedDelete() themselves. If the shared cleanup lived in
the trait under the same name, it would silently not run in those managers. That is why
it has its own name, and for the same reason AdvertHardDeleteTrait has its post-commit
step completedAdvertHardDelete() called explicitly from the manager's
completedDelete(). When you add a hook in a trait that some manager already defines,
the manager must call it by hand.
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.
User payment items (UserPaymentManager) show both variants and a third case — credits, which
are never recalculated:
| Value | Gateway (updateStatusForPayment()) |
Save from the admin | Deleting an item |
|---|---|---|---|
member_until (tariffs with duration > 0) |
extends monotonically | the form (UserPaymentFormTrait) sends meta.recalcMemberUntil → absolute recalculation; any other save of an active item only extends |
absolute recalculation; NULL without an active membership item |
credits (tariffs with credits > 0) |
granted once per payment | granted only on a fresh activation: an insert with active = 1 or an active 0 → 1 transition |
not taken back |
The 0 → 1 transition is detected in beforeSave() by reading the stored active straight from the
DB with a FOR UPDATE lock (the managed entity already carries the new value) and passed to
afterSave() through the Result data. Two admins activating the same item at once are thus
serialised and the credits are granted once.
Credits are irreversible
A spent balance cannot be recalculated backwards, so neither deleting nor deactivating an item
takes credits away; a reversal is a separate operation. Adding a deduction to afterDelete()
could push the user's balance below zero for credits already spent.
Deleting a middle membership item shortens validity only approximately
The absolute recalculation takes the maximum of the stored validity_datetime values and does
not recompute the validity of the remaining chained items. Deleting the last item is exact.
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.
Notifications: receiver and probes#
An administrator notification carries receiverUserId (the receiver; NULL = shared by all
administrators) and itemId of a foreign record the bell leads to. The field used to be
called userId; the old name no longer exists (the DB column is the same). The producer
(NotificationProducerTrait) sets setReceiverUserId(null) for shared and
setReceiverUserId($userId) for targeted ones (a product import goes to whoever started it).
When the target record is deleted, the notification is not deleted, only detached: its
target reference (id) is nulled and it stays in the bell and the overview as text without a
link instead of leading to a 404. A delete through the ORM detaches it right away
(NotificationTargetDetachSubscriber); a delete
outside the ORM (raw SQL, a DB cascade) is caught by NotificationOrphanCleaner. Both learn where
a notification type points from the probes:
| Part | Job |
|---|---|
NotificationTargetProbeInterface::existingIds(string $typeKey, array $ids): ?array |
a module itself answers which of the IDs still exist; null = this probe does not own the type |
ManagerNotificationTargetProbe |
the base: a map type key → admin manager, one query id IN (…) |
| module probes | Models/Helpers/NotificationTargetProbe in the Eshop, System, Base, Blog, Company modules (an autowired array NotificationTargetProbeInterface[]) |
NotificationOrphanCleaner |
detachOrphans() (the newest 500) — only from the "Detach orphans" button in the overview (POST, Api clean-orphans); detachOrphansResuming() (the next 500 older ones, position kept in the cache, after the oldest it starts again from the newest) — from the cron /cron/system/notification/clean-orphans; never on page render, because a GET that changes data is triggered by crawlers too |
final class NotificationTargetProbe extends ManagerNotificationTargetProbe
{
protected function managers(): array
{
return ['ORDER_NEW' => $this->orderManager, 'COMPLAINT_NEW' => $this->complaintManager];
}
}
A new notification type = add a probe
A type to which no probe claims is never detached — after the record is deleted a link to a 404 stays in the bell. A type whose check fails behaves the same (fail-safe: better a dead link than a valid notification detached).
Where a notification links to is in the data, not in the code. The type
(cms_system_notification_types) points with the column cms_admin_system_pages_page_id to an
admin page; NotificationTargetResolver builds presenter:action from it and the link is always
destination + ['id' => itemId]. The icon column holds a Font Awesome class (empty = placeholder
icon). The resolver is shared by the bell and the notification list.
Procedure: adding a notification type
- The type row in a migration:
key,module_id,iconandcms_admin_system_pages_page_idof the edit page of the target record (an action that takesid). Without a page the item is plain text. - A probe that recognises the type (see above).
- A producer in the Api presenter (
shouldProduceNotifications(), Calling the API). - Check the click-through in the bell with a role that has the right to the target page and with one that does not.
The type's page must have an existing presenter
The resolver checks it with class_exists() and returns null otherwise; without that check
link() in the header would break every admin page. A typo in the page's presenter therefore
breaks nothing, the notification just stays without a link.
The bell (NotificationBell) filters receiverUserId IS NULL OR receiverUserId = <signed in>
(one group OrFilter, so read = 0 applies to both branches); without an identity it is
empty. A link to the target is offered only with the right to the target page. Operator
description: Notifications.
Operations return a Result, callers read isSuccess()#
An operation that changes something (a write, a relation sync, a delete, a recount, a
clean-up) returns a Result — not a bool and not an array whose key or string encodes
success. false discards the reason for the failure and is easy for a caller to overlook; a
Result carries a code and a message.
| What | Return type |
|---|---|
an operation (syncRoles, deletePrivilege, createFolder, recountAdverts, …) |
Result |
| an operation's statistics (how many were recounted, deleted) | a Result with a flat summary in data: ['updated' => 12]; an entity does not belong in data |
a question (isOwnedBy, exists, hasVoted, canRespondTo, …) |
bool — a Result would be noise |
a lock (acquire, claimExecution) |
bool — "somebody else holds the lock" is not an error |
a read for a component (availability(), summary()) |
an array — the consumers' contract does not change |
These operations were converted on 2 October 2026: assigning roles and groups to
a user (UserRelationManager::syncRoles, syncGroups, removeExternalLogin), the permission
matrix operations (UserRolePrivilegeMatrixManager::setRelation, deletePrivilege,
setPrivilegeGroup, saveGroupPrivileges), the file manager (FileSystemManager::createFolder,
writeFile, sanitizeAll), the recounts and clean-ups of classifieds and the directory
(CategoryManager::recountAdverts, AdvertManager::deleteLongExpired) and the e-shop price
lists (ShipperTariffManager::saveBranch, ProductSetManager::savePriceSnapshot,
deletePriceSnapshot).
A Result object is always truthy
if ($manager->syncRoles($id, $roles)) { … } passes even on failure — and nobody notices.
Callers always read ->isSuccess(). The result of syncRoles used to be discarded
altogether, and the administrator got "Saved" although the roles in the database stayed old.
Procedure: convert a bool operation to a Result#
- Find all callers (
grepoverapp/,tests/,bin/) and write them down — each one will change. - Change the return type through the whole chain of layers (repository → mapper → service
→ manager); the reasons for failure are
ResultCodevalues (INVALID_VALUE,ITEM_NOT_FOUND,FILE_WRITE_FAILED, …). - Rewrite each caller to
->isSuccess(). InafterSavepass the failure on (return $result;) — the base rolls back and the administrator sees a red message. - Leave the wire shape of the Api presenter alone (
{success},{success, code}…): the presenter builds the same response from theResultas before (the*Wire()helpers). Api clients are unaffected. - Add the method to the
CONVERTEDlist intests/Unit/System/ResultReturnSweepTest.php. A static guard test walksapp/and fails the build when it finds a call of a converted method used as a bare condition (if (,!,&&,||,(bool)) without->isSuccess().
The guard test watches only the methods on the list
The CONVERTED list is maintained by hand and today it does not contain setRelation,
createFolder, writeFile and saveBranch. A newly converted method that you do not add
to it may have a caller with a bare condition and the test will not know.
The Admin layers of the same operations stay on the wire shape
Administration managers are Api clients through ApiBridge and return the wire shape
($result['success']). They read explicit keys, not truthiness, so the trap above does not
threaten them. They can be converted only together with the Api contract.
A cron reports an error on failure
A cron presenter whose operation returns a failed Result sends an error (sendError)
instead of "ok". A bulk delete of expired adverts that fails therefore does not start
reindexing over unchanged data; an unconfigured upload root is a failure, not a silent
zero.
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 |
| reopening the EntityManager after a failed flush | app/Core/Doctrine/EntityManagerReopener.php, app/Core/Doctrine/Transactions/TransactionManager.php |
| the entity preload before deleting | app/Core/Traits/Api/Models/Managers/DeleteTrait.php |
| an atomic operation over several rows | app/Core/Traits/Api/Models/Managers/AtomicTransactionTrait.php |
| a thread on insert | app/Core/Traits/Api/Models/Managers/{CommentThreadCreatorTrait,RatingThreadCreatorTrait,MessageThreadCreatorTrait}.php |
updateColumns() and collection replace |
app/Core/Traits/Api/Models/Mappers/SaveTrait.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