Skip to content

Build events and listeners

The reference app emits immutable Domain OrderPlaced after its transaction commits. Use a synchronous Listener only for safe in-process work; use a queued Listener for retryable communication.

Before you begin

Run composer run reference:test:events in examples/reference-app. Read the event delivery model before choosing a tier.

Dispatch a committed fact

examples/reference-app/app/Pulsar/Services/Client/Modules/Orders/UseCases/PlaceOrder.php — The UseCase owns the transaction, outbox intent, and [Event](/concepts/event) dispatch.

php
<?php

namespace App\Pulsar\Services\Client\Modules\Orders\UseCases;

use App\Pulsar\Domain\Orders\Actions\CreateOrder;
use App\Pulsar\Domain\Orders\DTOs\CreateOrderData;
use App\Pulsar\Domain\Orders\Events\OrderPlaced;
use App\Pulsar\Domain\Orders\Models\Order;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(private readonly CreateOrder $createOrder) {}

    // #region place-order-workflow
    public function execute(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $order = $this->createOrder->execute($data);
            DB::table('outbox_messages')->insert([
                'event_type' => 'orders.placed.v1',
                'aggregate_id' => (string) $order->id,
                'payload' => json_encode(['order_id' => $order->id, 'version' => OrderPlaced::VERSION], JSON_THROW_ON_ERROR),
                'idempotency_key' => 'order-placed-'.$order->id,
                'created_at' => now(),
                'updated_at' => now(),
            ]);
            event(new OrderPlaced($order->id));

            return $order;
        });
    }
    // #endregion
}

examples/reference-app/app/Pulsar/Domain/Orders/Listeners/SendOrderConfirmation.php — Queued after-commit communication accepts the event only after the committed fact is delivered.

php
<?php

namespace App\Pulsar\Domain\Orders\Listeners;

use App\Models\User;
use App\Pulsar\Domain\Orders\Events\OrderPlaced;
use App\Pulsar\Domain\Orders\Mail\OrderConfirmationMail;
use App\Pulsar\Domain\Orders\Models\Order;
use App\Pulsar\Domain\Orders\Notifications\OrderConfirmationNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;

final class SendOrderConfirmation implements ShouldQueue
{
    public bool $afterCommit = true;

    // #region queued-order-confirmation
    public function handle(OrderPlaced $event): void
    {
        $order = Order::query()->findOrFail($event->orderId);
        $recipient = User::query()->where('tenant_id', $order->tenant_id)->firstOrFail();
        $key = 'order-confirmation-'.$order->id;
        if (DB::table('communication_deliveries')->insertOrIgnore([
            'idempotency_key' => $key,
            'created_at' => now(),
            'updated_at' => now(),
        ]) === 0) {
            return;
        }
        Notification::send($recipient, new OrderConfirmationNotification($order->id, $key));
        Mail::to($recipient)->send(new OrderConfirmationMail($order->id, $key));
    }
    // #endregion
}
A UseCase transaction can dispatch synchronously before commit, discard an after-commit callback on rollback, lose an in-memory callback in a crash gap after commit, enqueue a queued after-commit reaction that retries with idempotency, or persist durable outbox intent for a relay.
Choose a Domain Event delivery guarantee. Choose visibility or durable intent deliberately; neither is an exactly-once effect guarantee.

Interpretation. After-commit prevents rollback reactions, but the in-memory dispatch still has a crash gap.

Verify and troubleshoot

The event group proves commit reaction and rollback suppression. If a reaction needs durable external delivery, use the transactional outbox; no tier here is exactly-once.