Laravel Queue Best Practices: Write Faster, Safer Background Jobs

Reading time: ~9 minutes


What Are Laravel Queues?

Laravel queues allow you to defer time-consuming tasks—like sending emails, processing uploads, or calling external APIs—to background workers. This improves response times and overall user experience.

But queues introduce a key constraint: jobs run later, in a separate process, with no shared state. If you ignore that, you'll run into subtle production bugs.

In this guide to Laravel queue best practices, you'll learn how to write jobs that are lean, predictable, and production-safe.


How Laravel Queue Jobs Work

When you dispatch a job:

MyJob::dispatch($data);

Laravel will:

  1. Serialize the job into a storable payload (typically JSON containing a serialized object)
  2. Push it onto a queue (database, Redis, SQS, etc.)
  3. A worker picks it up later
  4. The job is deserialized and handle() is executed

That "later" is critical.

By the time your job runs:

  • Database records may have changed or been deleted
  • Request/session data is gone
  • You're in a completely fresh PHP process

👉 Key idea: A queued job is a message, not a method call.


1. Passing Models vs IDs in Jobs

Laravel allows you to pass Eloquent models directly into queued jobs:

SendWelcomeEmail::dispatch($user);

Because queued jobs use the Queueable trait, Laravel will:

  • Serialize only the model's identifier onto the queue (not the full object)
  • Automatically re-fetch the full model instance and its loaded relationships from the database when the job runs
  • Keep job payloads small as a result

This is efficient and convenient — but there are important caveats.


Important: Relationship Constraints Are Not Re-applied

When the job deserializes and re-fetches your model, any relationship constraints you applied before dispatching (e.g., filtered eager loads) will not be restored. Relationships are re-retrieved in their entirety.

If you need to work with a specific subset of a relationship, re-constrain it inside the job's handle() method.

You can also strip relationships before serialization using withoutRelations():

public function __construct(Podcast $podcast)
{
    $this->podcast = $podcast->withoutRelations();
}

Or, using the #[WithoutRelations] attribute (Laravel 11+):

use Illuminate\Queue\Attributes\WithoutRelations;

public function __construct(
    #[WithoutRelations]
    public Podcast $podcast,
) {}

When Passing Models Is Fine

SendWelcomeEmail::dispatch($user);

✔ Simple jobs
✔ Short delay between dispatch and execution
✔ You're okay with failure if the model is missing


When Passing IDs Is Better

SendWelcomeEmail::dispatch($user->id);
class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(private int $userId) {}

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);

        Mail::to($user->email)->send(new WelcomeMail($user));
    }
}

✔ More explicit control
✔ Easier error handling
✔ Better for long-running or critical jobs


Rule of Thumb

Use models for convenience, but prefer IDs when you need control, clarity, or long-term reliability.


2. Inject Services in handle(), Not the Constructor

❌ Avoid This

public function __construct(
    private int $userId,
    private PaymentGateway $gateway
) {}

This forces Laravel to attempt serializing complex service objects—which often contain:

  • Connections
  • State
  • Non-serializable dependencies

✅ Prefer This

public function handle(PaymentGateway $gateway): void
{
    $user = User::findOrFail($this->userId);
    $gateway->charge($user);
}

✔ Clean serialization
✔ Fresh service instances resolved by the container
✔ Full dependency injection support


3. Keep Job Payloads Small

Large payloads slow down your queue and may hit driver limits (e.g., Amazon SQS: 256 KB maximum).

❌ Bad

ProcessReports::dispatch($reports);

✅ Good

ProcessReports::dispatch($reports->pluck('id')->toArray());

Handling Large Datasets

$key = 'job:' . Str::uuid();
Cache::put($key, $largeDataSet, now()->addHour());

ProcessLargeDataSet::dispatch($key);

Inside the job:

$data = Cache::get($key);

👉 Aim to keep payloads under ~10 KB when possible.


4. Use the Queueable Trait (Laravel 11+)

In Laravel 11 and later, the recommended approach is a single unified trait:

use Illuminate\Foundation\Queue\Queueable;

class MyJob implements ShouldQueue
{
    use Queueable;
    // ...
}

This Queueable trait from Illuminate\Foundation\Queue already bundles Dispatchable, InteractsWithQueue, Queueable, and SerializesModels — so you no longer need to list them individually.

Note: If you're on Laravel 10 or earlier, you'll still use the four separate traits:

use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

Running php artisan make:job will always generate the correct trait usage for your Laravel version.


5. Set Timeouts and Retry Limits

Prevent jobs from hanging forever:

class SyncWithExternalAPI implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 60;
    public int $backoff = 30;
}

✔ Avoids worker exhaustion
✔ Controls retry behavior
$backoff defines the number of seconds to wait between retry attempts


6. Handle Missing Data Explicitly

Data may disappear between dispatch and execution.

Option 1: Fail Explicitly

$user = User::findOrFail($this->userId);

Option 2: Silently Discard

public bool $deleteWhenMissingModels = true;

Choose based on your business logic — failing loudly is usually better for auditing critical jobs, while silent discard suits cleanup or notification jobs where staleness is expected.


7. Use Separate Queues by Priority

SendPasswordResetEmail::dispatch($userId)->onQueue('critical');
GenerateReport::dispatch($userId)->onQueue('default');
ReindexAllProducts::dispatch()->onQueue('bulk');

Run workers accordingly:

php artisan queue:work --queue=critical
php artisan queue:work --queue=default,bulk

✔ Prevents slow jobs from blocking critical ones
✔ Lets you scale workers independently per priority tier


8. Make Jobs Idempotent

Jobs may run more than once due to retries or worker restarts. Always guard against duplicate side effects:

if ($order->isProcessed()) {
    return;
}

✔ Prevents duplicate charges, emails, or database writes
✔ Makes your queue system resilient to at-least-once delivery semantics


9. Use Job Middleware

public function middleware(): array
{
    return [new RateLimited('api')];
}

Built-in middleware options include:

  • RateLimited — throttle job execution against a named limiter
  • WithoutOverlapping — prevent concurrent execution of the same job
  • ThrottlesExceptions — back off automatically when exceptions occur
  • Skip — conditionally skip a job based on a closure

10. Test Jobs in Isolation

Test Dispatching

Queue::fake();

$this->post('/orders');

Queue::assertPushed(ChargeCustomer::class);

Test Job Logic

$job = new ChargeCustomer($customer->id);
$job->handle($gatewayMock, $invoiceMock);

✔ Fast and reliable testing
✔ Keeps queue tests decoupled from infrastructure


Common Laravel Queue Mistakes

  • Passing models without understanding serialization behavior
  • Forgetting that relationship constraints are not restored on deserialization
  • Injecting services into constructors instead of handle()
  • Ignoring retries and timeouts
  • Not handling deleted records
  • Using a single queue for everything
  • Writing non-idempotent jobs

Quick Reference

Practice Benefit
Pass IDs or models appropriately Balance convenience and control
Strip unneeded relationships before dispatch Smaller payloads, no stale constraints
Inject services in handle() Avoid serialization issues
Keep payloads small Improve performance
Set retries/timeouts/backoff Prevent worker exhaustion
Use multiple queues Prioritize jobs
Handle missing data Avoid unexpected failures
Write idempotent jobs Safe retries
Use job middleware Rate limiting, concurrency control

Conclusion

By following these Laravel queue best practices, you can build background jobs that are:

  • Fast
  • Reliable
  • Easy to debug
  • Safe in production

The key mindset shift is simple:

A queued job is a message describing work — not the work itself.

Design your jobs with that in mind — keep them small, explicit, and resilient — and your queue system will scale cleanly with your application.

Tags: #Laravel #PHP #Queues #Background Jobs #Performance #Best Practices
Oussama GHAIEB - Laravel Certified Developer in Paris

Oussama GHAIEB

Laravel Certified Developer | Full-Stack Web Developer in Paris

14+ years experience 20+ projects
Read more about me →

Comments (0)

No comments yet. Be the first to comment!


Leave a Comment