Lifecycle hooks: author seams around every operation¶
The bundle drives every JSON:API request through one generic
CrudOperationHandler (see
lifecycle). Lifecycle hooks are the author seam into that
flow: fixed points before and after each operation where your code runs — to
authorize a request, guard a delete, stamp an audit field, run imperative
validation the declarative bridge can't express, or shape a custom response —
without writing or decorating a handler.
For authorization specifically, the bundle ships a declarative layer built on these hooks: declare a Symfony Security expression on
#[AsJsonApiResource(security: …)]and the bundle gates the operation for you. See authorization; reach for a hook directly only when an expression can't express the rule.
There are two equivalent ways to hook in, and you can mix them freely:
- A Symfony event subscriber — listen to the hook event classes. Best for a cross-cutting concern that spans types (an audit log, an authorization gate).
- A resource hook method — implement
ResourceLifecycleHooksInterfaceon a resource and override the hooks you want. Best for a per-type concern that belongs with the resource (a delete-guard for this type, a default value on create). The methods are sugar over the events: a built-in subscriber routes each event to the matching method.
Hooks require
symfony/event-dispatcher(it ships withsymfony/framework-bundle, so it is present in every Symfony app). Absent a dispatcher the seam is simply inert.
The hook set and firing order¶
The full set, in the order each fires:
serving (once per request, before any operation)
create: serving → BeforeSave → BeforeCreate → [persist] → AfterCreate → AfterSave
update: serving → BeforeSave → BeforeUpdate → [commit] → AfterUpdate → AfterSave
delete: serving → BeforeDelete → [delete] → AfterDelete
PATCH/POST/DELETE …/relationships/{rel}:
serving → BeforeRelationshipMutate → [apply] → AfterRelationshipMutate
GET /{type}/{id}: serving → AfterFetchOne
GET /{type}: serving → AfterFetchCollection
serving is a server-level gate fired once per request inside
Server::dispatch(), before the operation runs — the natural place for a
request-wide authorization gate. The aggregate BeforeSave / AfterSave pair
wraps both create and update (a creating flag distinguishes them), so a
concern that applies to every write lives in one place; the more specific
BeforeCreate/BeforeUpdate (and their After twins) fire inside that wrap.
beforeUpdate carries the pre-change snapshot¶
The update before hook is the one with an extra argument: its signature is
beforeUpdate(object $entity, object $original, HookContext $context). $entity is
the already-hydrated, mutable target the patch has been applied to; $original is a
pre-change snapshot taken before hydration, so a hook can diff old against new
— e.g. record exactly which fields changed, or reject a change to an immutable field:
public function beforeUpdate(object $entity, object $original, HookContext $context): void
{
\assert($entity instanceof Album && $original instanceof Album);
if ($entity->isrc !== $original->isrc) {
throw /* a 422/409 — the ISRC is immutable once set */;
}
}
The same snapshot rides the event: BeforeUpdateEvent exposes $original alongside
$entity (the other before events carry only the entity, since there is nothing yet
to diff against). All other hook methods take just (object $entity, HookContext
$context).
Two semantics: before aborts, after replaces¶
- A before hook (
serving,BeforeSave,BeforeCreate,BeforeUpdate,BeforeDelete,BeforeRelationshipMutate) receives the entity mutable and runs before the persister commits. Two things you can do: - Mutate the entity — a field you set is persisted by the ensuing flush.
- Abort —
throwa coreJsonApiExceptionInterface. The route-scopedExceptionListenerrenders it as the status it carries, and nothing commits. Use a403for an authorization/guard failure,422for an imperative-validation failure,409for a conflict. - An after hook (
AfterSave,AfterCreate,AfterUpdate,AfterDelete,AfterRelationshipMutate,AfterFetchOne,AfterFetchCollection) runs post-commit and may replace the response value object (custom-action shaping). On the event, callsetResponse(...); on a resource method,returnthe replacement (ornullto keep the handler's response).
serving is before-only — it carries no response. Request-wide response shaping
belongs to the per-operation after hooks.
Mechanism 1 — an event subscriber (cross-cutting)¶
Every hook is a plain event under
haddowg\JsonApiBundle\Event. Subscribe with an ordinary Symfony
EventSubscriberInterface (autoconfigured — no manual tag):
<?php
declare(strict_types=1);
namespace App\JsonApi;
use haddowg\JsonApiBundle\Event\AfterSaveEvent;
use haddowg\JsonApiBundle\Event\ServingEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class AuditSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly AuditLog $log) {}
public static function getSubscribedEvents(): array
{
return [
ServingEvent::class => 'onServing',
AfterSaveEvent::class => 'onAfterSave',
];
}
public function onServing(ServingEvent $event): void
{
// A request-wide gate: throw to abort before any operation runs.
// (The throw is rendered by the ExceptionListener as its status.)
}
public function onAfterSave(AfterSaveEvent $event): void
{
$verb = $event->creating ? 'created' : 'updated';
$this->log->record($event->type, $verb, $event->entity);
}
}
Each event carries the context for its point: the resource type, the live
JsonApiRequestInterface request, the entity (or parent + relation +
linkage + mode for the relationship pair, or items for the collection read),
the serverName, and creating on the save pair. BeforeUpdateEvent additionally
carries the pre-change $original snapshot (above) for diffing. The after events
expose setResponse(...) / response().
Mechanism 2 — resource hook methods (per-type)¶
Implement ResourceLifecycleHooksInterface on a resource and use
ResourceLifecycleHooksTrait for no-op defaults, then override only what you need:
<?php
declare(strict_types=1);
namespace App\Resource;
use haddowg\JsonApi\Exception\AbstractJsonApiException;
use haddowg\JsonApi\Resource\AbstractResource;
use haddowg\JsonApiBundle\Hook\HookContext;
use haddowg\JsonApiBundle\Hook\ResourceLifecycleHooksInterface;
use haddowg\JsonApiBundle\Hook\ResourceLifecycleHooksTrait;
use App\Entity\Album;
final class AlbumResource extends AbstractResource implements ResourceLifecycleHooksInterface
{
use ResourceLifecycleHooksTrait;
public static string $type = 'albums';
public function fields(): array { /* … */ }
// Stamp a server-owned field on create — the mutation is persisted.
public function beforeCreate(object $entity, HookContext $context): void
{
\assert($entity instanceof Album);
$entity->createdAt = new \DateTimeImmutable();
}
// Guard the delete: a 409 when the album is still referenced.
public function beforeDelete(object $entity, HookContext $context): void
{
\assert($entity instanceof Album);
if ($entity->tracks->count() > 0) {
throw new class extends AbstractJsonApiException {
public function __construct() { parent::__construct('Album still has tracks', 409); }
public function getErrors(): array { return []; }
};
}
}
}
The built-in ResourceHookSubscriber
listens to every lifecycle event, resolves the resource for the event's type, and
— when it implements the interface — calls the matching method, passing the entity
and a HookContext (the request, server name, type,
and the relation/linkage/mode for the relationship hooks). The one exception to the
(object $entity, HookContext $context) shape is beforeUpdate, which also receives
the pre-change $original snapshot (above). A before method throws to abort; an
after method returns a response to replace it. A resource that does not implement the
interface (or a bare serializer/hydrator pair with no resource) is untouched.
See it in the example app. The
PlaylistResourceimplementsResourceLifecycleHooksInterfacewith abeforeCreate(stamps a server-owned field, persisted by the ensuing flush) and abeforeDeleteguard (a409when the playlist is non-empty); the cross-cuttingAuditLogSubscribersubscribes toServingEvent/AfterSaveEvent/BeforeDeleteEvent/AfterDeleteEvent. Both are exercised end to end byLifecycleHooksTest.
Hooks vs. the other seams¶
| Seam | Reach | Use it for |
|---|---|---|
| Lifecycle hooks (this page) | One point in one operation | Authz, guards, audit, imperative validation, response shaping |
| The Validator bridge | Declarative 422 before hydration |
Field rules core can express (required, length, enum, cross-field) |
A custom DataProvider/DataPersister |
The whole fetch/persist for a type | Non-Doctrine storage, bespoke query scoping |
| Handler decoration | The entire dispatch | Wholesale replacement of the engine (rare) |
Reach for a hook before decorating the handler: a hook is scoped to exactly the point you care about and composes with every other hook, where a decorator owns the whole flow.
Next: Validation bridge →