Skip to content
Guide

Handle events and jobs

Publish a transactional fact and choose an authorized, recoverable consumer.

Handle events and jobs

Publish a Note update in the same transaction as its write. In an already authorized NoteController::update() flow, after validation, replace the standalone update with this code. $note is the resolved record and $validated is the existing validated input:

Code example
PHP
use Illuminate\Support\Facades\DB;
use Nexia\Events\Contracts\EventPublisher;
use Nexia\Events\EventDraft;
use Nexia\Events\LegalEntityScope;

DB::transaction(function () use ($note, $validated): void {
    $note->update($validated);
    app(EventPublisher::class)->publish(new EventDraft(
        eventName: 'workshop.note.updated',
        payload: ['note_public_id' => (string) $note->public_id],
        legalEntityScope: LegalEntityScope::explicit((int) $note->legal_entity_id),
        aggregateType: 'workshop.note',
        aggregateId: (string) $note->public_id,
        schemaVersion: 1,
    ));
});

This adds an owner-internal fact; the scaffold does not declare this event. To expose it to another App or Process, first add its public lifecycle contract to NoteModule with the exact payload and schema version. Standalone public integration events use EventDescriptor; do not declare the same event both ways. See SDK contracts for the full Event contract.

Core supplies tenant, producer, time and transport identity. For delegated work, supply a verified actor reference from the already authorized context and reauthorize at execution time; an actor ID alone is not permission. Public payloads carry public IDs and declared fields, not models, protected values or numeric primary keys.

Register the consumer through the host

Choose the effect before choosing a registration:

EffectSDK registration
System-owned projection, valid independently of a userAppEventListeners::listen()
Protected action delegated by the event actorActorDelegatedAppEventListeners::listenDelegated() plus EventActorAuthorizer

Both declarations name the owner App. Register at provider boot without reading tenant state. The host restores tenant context, checks that the owner and its prerequisites are operational, and for delegated work restores and reauthorizes the actor immediately before execution. Do not register raw Event::listen('outbox:...') listeners.

Use EventConsumerRegistration to declare a stable consumer key, exact supported schema version and recovery mode. Exact cross-App subscriptions require active cataloged event names. AllPublicEvents takes an empty event list and delivers only compatible active public events, not arbitrary outbox traffic.

Read a complete listener declaration

This declaration is taken from the People App's src/PeopleCoreServiceProvider.php; its existing handler lives at src/Actions/ConsumeRecruitingAcceptedOffer.php in that same App. It is a system-owned onboarding draft, not a protected action delegated by a user. Do not import that class into Workshop; use your own consumer class when implementing a Workshop effect.

Code example
PHP
use Amuzcorp\Nexia\PeopleCore\Actions\ConsumeRecruitingAcceptedOffer;
use Nexia\Events\Contracts\AppEventListeners;
use Nexia\Events\EventConsumerRegistration;
use Nexia\Events\EventRecoveryMode;

// Inside PeopleCoreServiceProvider::boot(), in the People App repository.
$this->app->make(AppEventListeners::class)->listen(
    'people',
    'outbox:recruiting.job_offer.accepted',
    ConsumeRecruitingAcceptedOffer::class,
    new EventConsumerRegistration(
        consumerKey: ConsumeRecruitingAcceptedOffer::CONSUMER_KEY,
        recoveryMode: EventRecoveryMode::Replay,
        supportedSchemaVersion: 1,
    ),
);

The handler calls InboxConsumer::run($envelope, self::CONSUMER_KEY, ...), validates the producer and v1 payload, and applies an idempotent domain change. Keep the consumer key identical in registration and execution. Use a registered reconciler instead when selecting Reconcile.

Choose recovery and idempotency

ModeWhile the App cannot runOn recovery
ReplayStore paused Inbox messagesReplay in consumer order; explicitly retry dead rows
ReconcileRecord dirty generationsRun the registered idempotent reconciler
EphemeralCount drops without retaining payloadAccept the declared loss

Use InboxConsumer for the atomic claim, effect and processed marker. Keep a domain idempotency key or locked upsert where the business operation can repeat through another path. A separate “already handled?” query followed by a write is racy.

Before applying payloads, check event name, producer, schema, tenant, required Legal Entity scope, references and correlation. For handoffs, publish a separate correlated result and model accepted, rejected, failed and unknown outcomes; a timeout is not proof of failure. External calls need their own idempotency and retry behavior because a database transaction cannot roll them back.

Verify the async path

From the Core root, start queue/runtime services and refresh changed declarations:

Code example
Shell
task dev:up:runtime
task artisan -- nexia-runtime:generate-app-map
task artisan -- nexia-apps:validate-app-descriptors workshop

Test rollback, duplicate delivery, non-operational owner, absent tenant and revoked delegated authority. Test an App provides the focused test workflow. For requests that actually need current data rather than a committed fact, use Choose a cross-App integration.

Source of truth: docs/developers/content/en/building-apps/handle-events-and-async-work.md