Skip to content
Guide

Handle events and asynchronous work

Commit an event with a business write and consume it without duplicate effects.

Handle events and asynchronous work

This recipe asynchronously creates a receipt after updating a legal-entity-owned Note. Start with Workshop Note from Create a Resource and an active sandbox. EventReceipt is a synthetic system record containing no personal data; it does not replace authorization design for real business projections.

Declare the event and result Resource

Generate a tenant-owned Resource for observing delivery:

Code example
Shell
nexia make resource EventReceipt --record-owner tenant --label-ko '이벤트 수신 기록'

Add src/Descriptors/WorkshopEvents.php. The generated Manifest discovers src/Descriptors. Do not declare one event both as a Resource lifecycle event and a standalone descriptor.

Code example
PHP
<?php

declare(strict_types=1);

namespace Nexia\Apps\Acme\Workshop\Descriptors;

use Nexia\AppDescriptors\AppDescriptorSet;
use Nexia\AppDescriptors\Contracts\AppDescriptorContribution;
use Nexia\AppDescriptors\EventDescriptor;

final class WorkshopEvents implements AppDescriptorContribution
{
    public static function appDescriptors(): AppDescriptorSet
    {
        return AppDescriptorSet::of(new EventDescriptor(
            key: 'workshop.note.updated',
            schemaVersion: 1,
            aggregateType: 'workshop.note',
            payloadSchema: [
                'note_public_id' => ['type' => 'string', 'format' => 'uuid', 'required' => true],
            ],
        ));
    }
}

This publishes the event name, producer ownership and payload contract. Discovery in the Process editor is a separate setting and is not enabled here.

Commit the write and publication together

Inside NoteController's existing update(), after authorization and input validation, replace its standalone $note->update($validated) with this block. Do not append another write after the original one. Keep the existing response handling.

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 example requires a legal-entity-owned Note. publish() records publication intent with the business write; it does not mean consumption finished. The platform restores tenant and execution provenance. Do not impersonate authenticated requests from provider boot, arbitrary CLI execution or fixtures.

Implement and register the consumer

Add src/Events/RecordNoteUpdate.php. This App-owned system receipt is not an actor-delegated business action.

Code example
PHP
<?php

declare(strict_types=1);

namespace Nexia\Apps\Acme\Workshop\Events;

use Illuminate\Support\Str;
use InvalidArgumentException;
use Nexia\Apps\Acme\Workshop\Models\EventReceipt;
use Nexia\Events\Contracts\InboxConsumer;
use Nexia\Events\EventEnvelope;

final class RecordNoteUpdate
{
    public const KEY = 'workshop.note.update_receipt';

    public function handle(EventEnvelope $event): void
    {
        if ($event->producerAppKey !== 'workshop'
            || $event->eventName !== 'workshop.note.updated'
            || $event->schemaVersion !== 1
            || ! is_string($event->payload['note_public_id'] ?? null)
            || ! Str::isUuid($event->payload['note_public_id'])) {
            throw new InvalidArgumentException('Unexpected Note event.');
        }
        $result = app(InboxConsumer::class)->run($event, self::KEY, function () use ($event): void {
            EventReceipt::firstOrCreate(
                ['public_id' => $event->identityUuid()],
                ['name' => 'Note updated'],
            );
        });
        if ($result->error !== null) {
            throw $result->error;
        }
    }
}

Add the following registration inside the existing boot() of src/WorkshopServiceProvider.php, with imports at the top. Preserve its other registrations. Do not query or write tenant data while booting.

Code example
PHP
use Nexia\Apps\Acme\Workshop\Events\RecordNoteUpdate;
use Nexia\Events\Contracts\AppEventListeners;
use Nexia\Events\EventConsumerRegistration;
use Nexia\Events\EventRecoveryMode;

$this->app->make(AppEventListeners::class)->listen(
    'workshop',
    'outbox:workshop.note.updated',
    RecordNoteUpdate::class,
    new EventConsumerRegistration(
        consumerKey: RecordNoteUpdate::KEY,
        recoveryMode: EventRecoveryMode::Replay,
        supportedSchemaVersion: 1,
    ),
);

Inbox completion and App writes share the App transaction. Effects reachable through other business paths also need a domain idempotency key. Here the event identity additionally makes the receipt unique on replay. External API calls cannot be undone by a database rollback.

Verify actual delivery

  1. Wait for source, migration and event-registration readiness in nexia dev.
  2. Assign Note update and EventReceipt read permission to your user.
  3. Update one Note and refresh the receipt list. Consumption is not complete until its result exists.
  4. In an isolated test, pass the same EventEnvelope to the consumer twice and assert one row. Separate Note updates are separate events.
  5. A forced failure inside the publishing transaction must cancel both the write and publication. Recovery after consumer failure or App disablement must not duplicate the effect.

Separate local syntax/mock checks from successful sandbox delivery. If no receipt appears, inspect selected source revision, event name, consumer key and runtime readiness, then provide the operation identifier to support. Do not edit database or Inbox state directly.

Choose authority and recovery

SituationContract
System effect independent of user delegationAppEventListeners, as above
Protected work on behalf of a userActorDelegatedAppEventListeners with EventActorAuthorizer to reauthorize at execution
Replay facts missed during interruptionReplay
Reconcile against current stateReconcile with a separately registered idempotent reconciler
Deliberately accept missed deliveryEphemeral

An actor ID is not authorization. Another App's events require an active public contract and the exact supported schema version; do not import its models. General queue Jobs and scheduled work have separate registration and execution contracts. Choose cross-App integration distinguishes current reads from committed facts.

Run background and scheduled work for explicit queued work and cron declarations.

See the public CLI documentation for command and option details.

Business timezone during event handling

Event handlers and reconciliation callbacks read the tenant business timezone through app(\Nexia\Tenancy\Contracts\TenantSettings::class)->businessTimezone(). The platform supplies the execution context; Apps must not invent an actor or fallback timezone. Apply Core 0.8.162 first, then Manager 0.1.0-alpha.88 and its rebuilt App runtime images; updating Manager alone does not update existing App containers. No SDK change is required. After adoption, retry failed events through the existing Inbox recovery flow instead of submitting duplicate documents or editing database state.

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