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:
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.
<?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.
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.
<?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.
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
- Wait for source, migration and event-registration readiness in
nexia dev. - Assign Note update and EventReceipt read permission to your user.
- Update one Note and refresh the receipt list. Consumption is not complete until its result exists.
- In an isolated test, pass the same EventEnvelope to the consumer twice and assert one row. Separate Note updates are separate events.
- 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
| Situation | Contract |
|---|---|
| System effect independent of user delegation | AppEventListeners, as above |
| Protected work on behalf of a user | ActorDelegatedAppEventListeners with EventActorAuthorizer to reauthorize at execution |
| Replay facts missed during interruption | Replay |
| Reconcile against current state | Reconcile with a separately registered idempotent reconciler |
| Deliberately accept missed delivery | Ephemeral |
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.