The Problem With Naive Job Dispatch
Most Laravel queue tutorials stop at dispatch(new SendInvoice($order)). That works fine until a worker crashes mid-execution, a downstream API times out, or a deployment restarts Horizon at exactly the wrong moment. The job retries, the invoice is sent twice, and your customer is furious.
Production queues demand three things: custom job middleware for cross-cutting concerns, idempotency guards to prevent duplicate side-effects, and structured failure handling that distinguishes transient errors from permanent ones.
Custom Job Middleware
Job middleware in Laravel is underused. Unlike HTTP middleware, it wraps the handle() call directly and can short-circuit execution cleanly.
// app/Queue/Middleware/SkipIfAlreadyProcessed.php
class SkipIfAlreadyProcessed
{
public function handle(object $job, callable $next): void
{
$key = 'job_processed:' . $job->idempotencyKey();
if (Cache::has($key)) {
$job->delete();
return;
}
$next($job);
Cache::put($key, true, now()->addHours(24));
}
}
Attach it in the job itself:
class SendInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public readonly Order $order) {}
public function middleware(): array
{
return [new SkipIfAlreadyProcessed];
}
public function idempotencyKey(): string
{
return 'invoice:' . $this->order->id;
}
public function handle(InvoiceService $service): void
{
$service->send($this->order);
}
}
This pattern keeps idempotency logic out of handle() and makes it reusable across job classes.
Idempotency at the Database Level
Cache-based guards are fast but not durable across cache flushes. For financial or notification jobs, back the idempotency key with a database record.
Schema::create('processed_jobs', function (Blueprint $table) {
$table->string('idempotency_key')->primary();
$table->timestamp('processed_at');
});
class DatabaseIdempotency
{
public function handle(object $job, callable $next): void
{
$key = $job->idempotencyKey();
$inserted = DB::table('processed_jobs')->insertOrIgnore([
'idempotency_key' => $key,
'processed_at' => now(),
]);
if ($inserted === 0) {
$job->delete();
return;
}
try {
$next($job);
} catch (Throwable $e) {
DB::table('processed_jobs')->where('idempotency_key', $key)->delete();
throw $e;
}
}
}
insertOrIgnore is atomic on a primary key conflict. If the job throws, we delete the record so a retry can proceed — but only for transient failures.
Distinguishing Transient vs. Permanent Failures
Not every exception should trigger a retry. A PaymentGatewayUnavailableException is transient; a CardPermanentlyDeclinedException is not.
public function failed(Throwable $exception): void
{
if ($exception instanceof PermanentFailureException) {
// Notify, log, and do NOT re-queue
Log::error('Permanent job failure', [
'job' => static::class,
'order' => $this->order->id,
'error' => $exception->getMessage(),
]);
return;
}
// For transient failures, let Laravel's retry logic handle it
// but cap attempts to avoid infinite loops
}
public int $tries = 5;
public int $backoff = 60; // seconds between retries
Pair this with $this->fail($exception) inside handle() when you detect a permanent condition — it marks the job failed immediately without exhausting retry attempts.
Combining Everything With a Base Job
Avoid repeating middleware declarations across dozens of job classes:
abstract class ReliableJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $backoff = 30;
abstract public function idempotencyKey(): string;
public function middleware(): array
{
return [new DatabaseIdempotency];
}
}
class SendInvoice extends ReliableJob
{
public function __construct(public readonly Order $order) {}
public function idempotencyKey(): string
{
return 'invoice:' . $this->order->id;
}
public function handle(InvoiceService $service): void
{
$service->send($this->order);
}
}
Every subclass inherits idempotency and retry configuration automatically.
Key Takeaways
- Job middleware is the right place for cross-cutting concerns like idempotency, rate-limiting, and telemetry — keep
handle()focused on domain logic. insertOrIgnoreon a primary key gives you atomic, durable deduplication without race conditions.- Roll back the idempotency record on transient failure so retries can proceed; leave it in place on permanent failure.
$this->fail()short-circuits retry logic immediately — use it when you know a retry cannot succeed.- A shared base job class enforces reliability conventions across your entire application without repetition.