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
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
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
}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.