Background Work as Architecture: Laravel Jobs and Eventual Consistency

8 min read

A queue does more than speed up an HTTP response. It introduces an asynchronous boundary: the request can say that an invoice is being prepared while the PDF, the accounting export, and the email do not yet exist. A worker can die after calling a provider but before recording the response. A deploy can replace the code while a reserved payload still refers to the old class. Those are ordinary operating states, so a job needs an explicit business contract.

That contract answers five questions: which durable input identifies the work; what success means; which effects can safely repeat; how long retry is useful; and how an operator recovers a permanent failure. We will use invoice creation to make each decision concrete.

At least once is the useful default

Queue drivers aim for at-least-once delivery. If a worker loses a reserved job, the driver may make it available again. There is no switch for exactly-once execution because a network failure can always leave an unknown outcome. Build for repetition with a stable business key, a unique database constraint, and an idempotency key for any external API.

Pass scalar identifiers through the queue, then load current state in handle(). Do not serialize a request, the authenticated user, or a large Eloquent graph. The request is gone when the worker runs, relations can be stale, and hidden serialization makes the job's database dependency unclear.

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Order;
use App\Services\InvoiceGenerator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class GenerateInvoice implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $timeout = 120;
    public int $tries = 3;

    /** @var array<int, int> */
    public array $backoff = [10, 60, 300];

    public function __construct(public readonly int $orderId)
    {
    }

    public function handle(InvoiceGenerator $invoiceGenerator): void
    {
        $order = Order::query()->findOrFail($this->orderId);

        if (! $order->is_paid || $order->invoice_path !== null) {
            return;
        }

        $invoiceGenerator->generateFor($order);
    }
}

The early return is a useful first line, not the final guarantee. Two workers can both observe invoice_path as null. Put the actual invariant where the write occurs: create an invoice row with a unique order_id, allocate a unique invoice number, or make an atomic conditional update. A job reduces duplicate opportunities; the database decides whether two records can exist.

Commit the cause before dispatching its consequence

A worker can otherwise win the race against the transaction that created its input. It looks up an uncommitted order, throws ModelNotFoundException, and wastes a retry. Dispatch after commit when the job depends on a row written in the current transaction.

php
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Jobs\GenerateInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class MarkOrderPaid
{
    public function execute(Order $order): void
    {
        DB::transaction(function () use ($order): void {
            $order->update(['paid_at' => now()]);

            GenerateInvoice::dispatch($order->getKey())->afterCommit();
        });
    }
}

afterCommit() orders dispatch after a successful commit; it does not replace the transaction, nor does it freeze business state. The job must still handle a later cancellation. This is eventual consistency: the system advances through visible states, rather than pretending every subsystem changed atomically.

Idempotency belongs beyond the database

Your database can reject a duplicate key. An accounting API is outside that transaction. Give it an idempotency key derived from the business operation (not the attempt number), so attempt one and attempt three mean the same thing.

php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Order;
use Illuminate\Http\Client\Factory as HttpFactory;

final class AccountingExporter
{
    public function __construct(private readonly HttpFactory $http)
    {
    }

    public function export(Order $order): void
    {
        $this->http->acceptJson()
            ->timeout(15)
            ->connectTimeout(5)
            ->withHeaders([
                'Idempotency-Key' => "order:{$order->getKey()}:accounting-export",
            ])
            ->post(config('services.accounting.url').'/invoices', [
                'order_id' => $order->getKey(),
                'total_cents' => $order->total_cents,
            ])
            ->throw();
    }
}

When a provider cannot deduplicate, persist an outbox record with a unique business key before dispatching and mark it sent only after a confirmed reply. If the reply is lost after a successful request, the unknown outcome has a durable record for reconciliation instead of becoming an invisible duplicate.

Retry for the business deadline

Retries must not hammer an outage and must stop when continuing is no longer useful. Keep the queue connection's retry_after comfortably above a job's timeout; otherwise a second worker can receive the same job while the first still runs. For a payment reconciliation that may reasonably wait until close of business, use a deadline instead of an arbitrary number of attempts.

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use DateTimeInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

final class ReconcilePayment implements ShouldQueue
{
    use Dispatchable;
    use Queueable;

    public int $tries = 0;

    /** @var array<int, int> */
    public array $backoff = [60, 300, 900, 1800];

    public function __construct(public readonly string $providerPaymentId)
    {
    }

    public function retryUntil(): DateTimeInterface
    {
        return now()->endOfDay();
    }
}

Validation errors, deleted orders, and an API's “invalid account” response are not transient. Retrying them only delays a decision. Record an actionable state in failed() so the operator sees the business object, not just a failed queue payload.

php
public function failed(\Throwable $exception): void
{
    Order::query()->whereKey($this->orderId)->update([
        'invoice_status' => 'failed',
    ]);

    report($exception);
}

Unique dispatches are not a data invariant

ShouldBeUnique prevents duplicate dispatch for one uniqueId() while its lock exists. It suits one CRM sync per customer. ShouldBeUniqueUntilProcessing releases the lock when handling begins, which is useful only when a new sync may wait behind the current one.

php
final class SyncCustomerFromCrm implements ShouldBeUnique, ShouldQueue
{
    use Dispatchable;
    use Queueable;

    public int $uniqueFor = 900;

    public function __construct(public readonly int $customerId)
    {
    }

    public function uniqueId(): string
    {
        return (string) $this->customerId;
    }
}

It cannot protect the same operation invoked by a command, webhook, and old job. For a short critical section take an explicit Cache::lock(), but retain the database invariant because a lock may expire during slow work. Locks serialize work, so they are a poor default for broad imports or an unhealthy upstream; use them for scarce, per-entity coordination.

php
Cache::lock("invoice:{$order->getKey()}", 180)->block(5, function () use ($order): void {
    app(InvoiceGenerator::class)->generateFor($order);
});

Batch independent work and chain dependencies

CSV chunks can run independently, so model them with a batch and expose batch progress. Invoice generation, accounting export, and notification have a strict order, so model them as a chain.

php
Bus::batch($chunkPaths->map(fn (string $path) => new ImportCustomersChunk($path))->all())
    ->name('September customer import')
    ->then(fn () => FinalizeCustomerImport::dispatch($importId))
    ->catch(fn ($batch, \Throwable $exception) => MarkImportFailed::dispatch($importId))
    ->dispatch();

Bus::chain([
    new GenerateInvoice($orderId),
    new ExportInvoiceToAccounting($orderId),
    new SendInvoiceReadyNotification($orderId),
])->dispatch();

Neither is a distributed transaction. A chain can export successfully and stop before notification. Persist observable milestones such as exported_at and notification_sent_at, then let a repair command safely resume the missing step instead of rerunning everything blindly.

Workers are deployed application code

Workers are long-running processes and will not load new PHP automatically. After compatible code and schema are available, use php artisan queue:restart so standard workers exit after their current job; terminate Horizon during a Horizon deployment so its manager replaces workers. Keep reserved old payloads compatible with the release: adding an optional field is safer than removing a job class or renaming a serialized property.

Horizon is excellent for separating high, default, and low queues, observing wait time, failures, memory, and process balance. It cannot determine whether an external call succeeded immediately before a crash.

Test observable safety

Test that the action dispatches the job, that completed work is a no-op, and that an external request receives the stable key. Bus::fake() is appropriate at the dispatch boundary; a direct job test with a mocked generator verifies the idempotent branch. Add an integration test with a real queue driver when serialization itself matters.

php
it('does not generate an invoice twice', function (): void {
    $order = Order::factory()->create([
        'paid_at' => now(),
        'invoice_path' => 'invoices/123.pdf',
    ]);
    $generator = Mockery::mock(\App\Services\InvoiceGenerator::class);
    $generator->shouldNotReceive('generateFor');

    (new GenerateInvoice($order->getKey()))->handle($generator);
});

Do not queue a small, user-critical write simply because queues look architectural: checkout should reserve stock synchronously. Do not queue work that must be visible in the same HTTP response. For short, non-critical post-response work on one server, defer() may be clearer. A queue is the right boundary when work is slow, independently retryable, and its delay is safe and visible to the user.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.