# Laravel Queue Best Practices: Write Faster, Safer Background Jobs

> Author: [Oussama Ghaieb](https://oussama.ghaieb.com/about-me)
> Canonical: https://oussama.ghaieb.com/posts/laravel-queue-best-practices-write-faster-safer-background-jobs

# 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:

```php
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:

```php
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()`:

```php
public function __construct(Podcast $podcast)
{
    $this->podcast = $podcast->withoutRelations();
}
```

Or, using the `#[WithoutRelations]` attribute (Laravel 11+):

```php
use Illuminate\Queue\Attributes\WithoutRelations;

public function __construct(
    #[WithoutRelations]
    public Podcast $podcast,
) {}
```

---

### When Passing Models Is Fine

```php
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

```php
SendWelcomeEmail::dispatch($user->id);
```

```php
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

```php
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

```php
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

```php
ProcessReports::dispatch($reports);
```

### ✅ Good

```php
ProcessReports::dispatch($reports->pluck('id')->toArray());
```

---

### Handling Large Datasets

```php
$key = 'job:' . Str::uuid();
Cache::put($key, $largeDataSet, now()->addHour());

ProcessLargeDataSet::dispatch($key);
```

Inside the job:

```php
$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:

```php
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:
> ```php
> 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:

```php
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

```php
$user = User::findOrFail($this->userId);
```

### Option 2: Silently Discard

```php
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

```php
SendPasswordResetEmail::dispatch($userId)->onQueue('critical');
GenerateReport::dispatch($userId)->onQueue('default');
ReindexAllProducts::dispatch()->onQueue('bulk');
```

Run workers accordingly:

```bash
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:

```php
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

```php
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

```php
Queue::fake();

$this->post('/orders');

Queue::assertPushed(ChargeCustomer::class);
```

### Test Job Logic

```php
$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.
