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.