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.

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.

protected function completedDelete(array $entities, Result $result, array $meta): void
{
    foreach ($entities as $entity) {
        $id = $this->getDeletedId($meta, $entity);   // not $entity->getId()
        …
    }
}

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#

  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
}

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

  1. Catch and log the item's error.
  2. Call UnitOfWorkReset::recoverAfterFailure(): it clears an open EntityManager and reopens a closed one. A direct clear() or detach() fails UnitOfWorkResetBoundaryTest.
  3. 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
  1. Inject the thread manager through the constructor (commentThreadManager, ratingThreadManager, messageThreadManager); the trait only reads it.
  2. Pass the thread type yourself (the section's ThreadType) — it is kept on the thread for moderation in the administration and per-type configuration.
  3. Return a failure from afterSave(): the trait opens no transaction of its own, the rollback is done by save().

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#

  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.

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

  1. The type row in a migration: key, module_id, icon and cms_admin_system_pages_page_id of the edit page of the target record (an action that takes id). Without a page the item is plain text.
  2. A probe that recognises the type (see above).
  3. A producer in the Api presenter (shouldProduceNotifications(), Calling the API).
  4. 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#

  1. Find all callers (grep over app/, tests/, bin/) and write them down — each one will change.
  2. Change the return type through the whole chain of layers (repository → mapper → service → manager); the reasons for failure are ResultCode values (INVALID_VALUE, ITEM_NOT_FOUND, FILE_WRITE_FAILED, …).
  3. Rewrite each caller to ->isSuccess(). In afterSave pass the failure on (return $result;) — the base rolls back and the administrator sees a red message.
  4. Leave the wire shape of the Api presenter alone ({success}, {success, code} …): the presenter builds the same response from the Result as before (the *Wire() helpers). Api clients are unaffected.
  5. Add the method to the CONVERTED list in tests/Unit/System/ResultReturnSweepTest.php. A static guard test walks app/ 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

Cookie settings