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:
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:
| Effect | SDK registration |
|---|---|
| System-owned projection, valid independently of a user | AppEventListeners::listen() |
| Protected action delegated by the event actor | ActorDelegatedAppEventListeners::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.
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
| Mode | While the App cannot run | On recovery |
|---|---|---|
Replay | Store paused Inbox messages | Replay in consumer order; explicitly retry dead rows |
Reconcile | Record dirty generations | Run the registered idempotent reconciler |
Ephemeral | Count drops without retaining payload | Accept 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:
task dev:up:runtime
task artisan -- nexia-runtime:generate-app-map
task artisan -- nexia-apps:validate-app-descriptors workshopTest 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.