Skip to content

The request lifecycle and events

Every JSON:API request flows through one invokable controller: negotiate → parse → Server::dispatch() → render. The Illuminate request is bridged to PSR-7 to drive core, then core's response value object is rendered back to an Illuminate response. Around the operation, the package dispatches real Laravel events (PLAN decision 10) — so Event::listen, Event::fake, queued listeners, and event auto-discovery all work.

The events

Eighteen event classes are dispatched via Laravel's dispatcher, before and after each operation:

Phase Events
serving ServingEvent (once per request, after server resolution)
save BeforeSaveEvent, AfterSaveEvent
create BeforeCreateEvent, AfterCreateEvent
update BeforeUpdateEvent, AfterUpdateEvent
delete BeforeDeleteEvent, AfterDeleteEvent
relationship mutation BeforeRelationshipMutateEvent, AfterRelationshipMutateEvent
reads AfterFetchOneEvent, AfterFetchCollectionEvent, BeforeFetchRelatedEvent, BeforeFetchRelationshipEvent
action BeforeActionEvent, AfterActionEvent

Each event carries the operation context — the JSON:API type, the resolved server name, the JsonApiRequestInterface, and the entity (or collection) in play:

final class BeforeCreateEvent
{
    public function __construct(
        public readonly string $type,
        public readonly JsonApiRequestInterface $request,
        public readonly object $entity,
        public readonly string $serverName,
    ) {}
}

Listening

Register a listener the ordinary Laravel way:

use haddowg\JsonApiLaravel\Event\AfterCreateEvent;
use Illuminate\Support\Facades\Event;

Event::listen(function (AfterCreateEvent $event): void {
    Log::info("created {$event->type}", ['server' => $event->serverName]);
});

Queued listeners work (implement ShouldQueue), and Event::fake() lets a test assert an event fired. A cross-cutting subscriber is just a plain listener set — the worked example is the workbench's AuditLogSubscriber (the Laravel port of the Symfony bundle example's subscriber): registered once via Event::subscribe() in the example's wiring providers, it gates every write on an X-Read-Only: on header via ServingEvent (a 403 before the operation runs) and appends an audit entry on AfterSaveEvent/AfterDeleteEvent — one concern spanning every type, no resource touched. tests/Feature/MusicCatalog/AuditListenerTestCase drives it end to end on both provider arms: a committed write appends exactly one entry, a failed write (a 409 hook guard, a denied ability, the read-only gate) appends none.

Before vs after semantics

  • Before events run before persistence. Throwing from a before listener aborts the operation; the thrown exception renders through the error pipeline (throw a 409/422 to veto a write).
  • After events run after the operation. An after handler may replace the response by returning a response value object (this is how the hook trait shapes a custom response).

Two event systems, one write

The Eloquent persister calls $model->save()/$model->delete(), so Eloquent model events fire untouched on any write path. The package's JSON:API events are distinct: they carry API operation context and defer After* work post-commit inside an atomic batch. Use model events for persistence concerns, JSON:API events for API concerns — see eloquent.

Hooks: the per-resource sugar

Prefer to keep the logic on the resource? The hook trait routes the same events to overridable methods (beforeCreate(), afterSave(), …) on the resource itself — no listener wiring.