Skip to content

Build a queue workflow

The Internal ConfirmOrderJob carries IDs and an application idempotency key, never an Eloquent Model. Each delivery reconstructs actor and tenant context, authorizes, and enters ConfirmPendingOrder once.

The boundary uses a Job and Model; Domain Event and Listener work have separate ownership.

Before you begin

Use the reference app's fake queue and inbound adapter rules. Laravel queue delivery is at-least-once, so a retry may reach the Job more than once.

Keep the Job durable and thin

The Job declares its retry/backoff policy and passes the same key on every attempt. The UseCase owns the transaction and inserts the key before it confirms the Order.

examples/reference-app/app/Pulsar/Services/Internal/Modules/Orders/Jobs/ConfirmOrderJob.php — Durable payload, context reconstruction, and one UseCase call.

php
<?php

namespace App\Pulsar\Services\Internal\Modules\Orders\Jobs;

use App\Models\User;
use App\Pulsar\Domain\Orders\Models\Order;
use App\Pulsar\Services\Internal\Modules\Orders\UseCases\ConfirmPendingOrder;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Gate;

final class ConfirmOrderJob implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function backoff(): array
    {
        return [10, 30, 60];
    }

    public function __construct(
        public readonly int $orderId,
        public readonly int $actorId,
        public readonly string $tenantId,
        public readonly string $idempotencyKey,
    ) {}

    // #region confirm-order-job
    public function handle(ConfirmPendingOrder $useCase): void
    {
        $actor = User::query()->findOrFail($this->actorId);
        $order = Order::query()->findOrFail($this->orderId);
        abort_unless($actor->tenant_id === $this->tenantId && $order->tenant_id === $this->tenantId, 403);
        Gate::forUser($actor)->authorize('confirm', $order);

        $useCase->execute($order->id, $this->idempotencyKey);
    }
    // #endregion
}

examples/reference-app/app/Pulsar/Services/Internal/Modules/Orders/UseCases/ConfirmPendingOrder.php — Transactional idempotency guard and confirmation.

php
<?php

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

use App\Pulsar\Domain\Orders\Actions\ConfirmOrder;
use App\Pulsar\Domain\Orders\Models\Order;
use Illuminate\Support\Facades\DB;

final class ConfirmPendingOrder
{
    public function __construct(private readonly ConfirmOrder $confirmOrder) {}

    // #region confirm-pending-order-workflow
    public function execute(int $orderId, string $idempotencyKey): Order
    {
        return DB::transaction(function () use ($orderId, $idempotencyKey): Order {
            $existing = DB::table('processed_workflows')->where('idempotency_key', $idempotencyKey)->exists();
            $order = Order::query()->lockForUpdate()->findOrFail($orderId);

            if ($existing) {
                return $order;
            }

            DB::table('processed_workflows')->insert(['idempotency_key' => $idempotencyKey, 'created_at' => now(), 'updated_at' => now()]);

            return $this->confirmOrder->execute($order);
        });
    }
    // #endregion
}
A worker receives durable identifiers or values, restores context, authorizes, calls one UseCase with runtime data, and retries through the same idempotency guard.
Enter one UseCase from a queued Job. A queued Job carries durable runtime data and delegates the workflow without owning a transaction or business branch.

Interpretation. The worker restores durable context before authorization and retries through the identical application-owned key.

Verify repeated delivery

Run composer run reference:test:queue. It proves dispatch payload fields, invokes the handler twice, confirms one Order, and finds one processed workflow record. This is idempotent business handling, not an exactly-once broker guarantee.

Troubleshoot

If a Job serializes a Model, replace it with an ID and reload it. Use a queued Listener when a Domain Event reaction owns the work.

Continue with the Artisan Command guide or read Command.