πŸ“ Volume II: Laravel Performance Tuning Kit

πŸ“¦ Topic 6: Queue Serialization Trap

Why sending full models to queues kills your Redis memory.

"You dispatch a job with $user.
Laravel serializes the ENTIRE model β€” all columns, all relations, all hidden attributes.
Your 2KB payload becomes 2MB. Your Redis fills up. Your queue stops.
And you have no idea why."
⚠️ THE SILENT KILLER

This is one of the most common production failures in Laravel. Developers dispatch jobs with full models, everything works in development (small data), then in production with 100,000 jobs, Redis memory explodes, workers hang, and the entire queue system collapses.

πŸ”΄ The Problem: Full Model Serialization

WHAT LARAVEL SERIALIZES

When you dispatch a job with a Model:

ProcessUserJob::dispatch($user);

Laravel serializes:

What does a serialized User model look like?

β”‚ β”‚ SERIALIZED USER MODEL (simplified representation) β”‚ ═══════════════════════════════════════════════════════════════════ β”‚ β”‚ O:15:"App\Models\User":20:{ β”‚ s:3:"id";i:12345; β”‚ s:8:"name";s:10:"John Doe"; β”‚ s:5:"email";s:19:"john@example.com"; β”‚ s:8:"password";s:60:"$2y$10$...hashedpassword..."; β”‚ s:13:"remember_token";s:100:"...token..."; β”‚ s:10:"created_at";s:19:"2024-01-01 12:00:00"; β”‚ s:11:"updated_at";s:19:"2024-01-01 12:00:00"; β”‚ s:14:"email_verified_at";s:19:"2024-01-01 12:00:00"; β”‚ s:15:"profile_photo";s:50:"photos/avatar_12345.jpg"; β”‚ s:12:"two_factor_secret";s:32:"...secret..."; β”‚ s:21:"two_factor_recovery_codes";s:200:"[...codes...]"; β”‚ s:9:"last_login";s:19:"2024-01-01 12:00:00"; β”‚ s:13:"ip_address";s:15:"192.168.1.100"; β”‚ s:12:"user_agent";s:120:"Mozilla/5.0..."; β”‚ s:5:"roles";a:3:{...}; // Relationship! β”‚ s:7:"permissions";a:15:{...}; // Another relationship! β”‚ ... more metadata ... β”‚ } β”‚ β”‚ TYPICAL SIZE: 2-10 KB per user (with no relations) β”‚ WITH RELATIONS: 50-500 KB per user β”‚
THE REAL KILLER: RELATIONSHIPS

If your model has loaded relationships (e.g., $user->load('posts', 'comments')), Laravel serializes every related model recursively. One user with 100 posts and 500 comments = megabytes of serialized data per job.

πŸ“Š Payload Size Comparison

πŸ‘Ž SENDING FULL MODEL

ProcessUserJob::dispatch($user);
ScenarioPayload Size
User only (no relations)2-10 KB
User + posts (100 posts)50-100 KB
User + posts + comments (500 comments)500 KB - 2 MB

πŸ‘ SENDING ID ONLY

ProcessUserJob::dispatch($user->id);
ScenarioPayload Size
Any user4-8 bytes (integer)
User + any relations4-8 bytes

Impact on Redis Memory (100,000 jobs in queue):

ApproachPayload per JobTotal for 100k JobsRedis Memory
Full User Model (no relations) 5 KB 500 MB ⚠️ Near limit
Full User + Posts + Comments 500 KB 50 GB πŸ’€ CRASH
ID Only 8 bytes 0.8 MB βœ… Fine (0.1% of memory)
THE BOTTOM LINE

Sending ID only is 99.99% smaller than sending full models with relations. In production, this difference is the line between a working queue and a crashed server.

βœ… The Fix: The ID-Only Pattern

THE GOLDEN RULE OF QUEUES

Never dispatch a model. Always dispatch the ID.

πŸ‘Ž BAD (Dispatch Full Model)

// Controller
$user = User::find(123);
ProcessUserJob::dispatch($user);  // ❌ Serializes everything

// Job
class ProcessUserJob implements ShouldQueue
{
    public function __construct(
        public User $user  // ❌ Receives full model
    ) {}
    
    public function handle()
    {
        // Use $this->user directly
        $this->user->update(['processed' => true]);
    }
}

πŸ‘ GOOD (Dispatch ID Only)

// Controller
$user = User::find(123);
ProcessUserJob::dispatch($user->id);  // βœ… Serializes integer

// Job
class ProcessUserJob implements ShouldQueue
{
    public function __construct(
        public int $userId  // βœ… Receives integer
    ) {}
    
    public function handle()
    {
        // Fetch fresh model inside job
        $user = User::find($this->userId);
        $user->update(['processed' => true]);
    }
}
BUT WHAT ABOUT STALE DATA?

Common objection: "What if the user changes between dispatch and job execution?"

Answer: That's exactly what you want! The job should use the current state of the user, not the state from when the job was dispatched. Fetching fresh inside handle() ensures you have the latest data.

If you NEED the state at dispatch time, explicitly serialize only the fields you need:

ProcessUserJob::dispatch($user->only(['id', 'name', 'email']));

🎨 What Happens in Redis (Visualized)

β”‚ β”‚ WITH FULL MODEL SERIALIZATION β”‚ ═══════════════════════════════════════════════════════════════════ β”‚ β”‚ Job 1: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 5 KB β”‚ Job 2: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 5 KB β”‚ Job 3: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 5 KB β”‚ ... (100,000 jobs) ... β”‚ Job N: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 5 KB β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ TOTAL: 500 MB in Redis β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β”‚ β”‚ β”‚ β”‚ β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β”‚ β”‚ β”‚ β”‚ β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ Redis near memory limit. New jobs rejected. Queue stops. β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ═══════════════════════════════════════════════════════════════════ β”‚ β”‚ WITH ID-ONLY SERIALIZATION β”‚ ═══════════════════════════════════════════════════════════════════ β”‚ β”‚ Job 1: [Β·] 8 bytes β”‚ Job 2: [Β·] 8 bytes β”‚ Job 3: [Β·] 8 bytes β”‚ ... (100,000 jobs) ... β”‚ Job N: [Β·] 8 bytes β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ TOTAL: 0.8 MB in Redis β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ β”‚ β”‚ β”‚ β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ β”‚ β”‚ β”‚ β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ Redis memory comfortable. Queue runs smoothly. β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚

πŸ•ΈοΈ The Relations Trap (Deep Serialization)

THE WORST CASE

This innocent-looking code will kill your queue:

// Controller
$user = User::with(['posts.comments', 'profile', 'settings'])->find(123);
SendNewsletterJob::dispatch($user);  // πŸ’€ Serializes EVERYTHING

// Job
class SendNewsletterJob implements ShouldQueue
{
    public function __construct(public User $user) {}
    
    public function handle()
    {
        foreach ($this->user->posts as $post) {
            foreach ($post->comments as $comment) {
                // Process comment
            }
        }
    }
}

This will serialize the user, all their posts, all comments on those posts, their profile, and their settings. One user could be 10MB+ of serialized data. 10,000 such jobs = 100GB of Redis memory = guaranteed crash.

THE FIX FOR RELATIONS
// Controller
$userId = 123;
SendNewsletterJob::dispatch($userId);  // βœ… Only the ID

// Job
class SendNewsletterJob implements ShouldQueue
{
    public function __construct(public int $userId) {}
    
    public function handle()
    {
        // Fetch WITH relations INSIDE the job
        $user = User::with(['posts.comments', 'profile', 'settings'])
            ->find($this->userId);
        
        // Now process
        foreach ($user->posts as $post) {
            foreach ($post->comments as $comment) {
                // Process comment
            }
        }
    }
}

Now the job fetches fresh data when it runs. No massive serialization in Redis.

⚑ Queue Connection Matters

Payload size limits by driver:

DriverDefault Max PayloadRisk with Full Models
sync (testing) No limit (in-process) Low risk (no serialization)
database (jobs table) ~65KB (TEXT column) ⚠️ HIGH RISK β€” models with relations exceed 65KB
redis ~512MB (configurable) Memory, not size limit
sqs (AWS) 256KB ⚠️ HIGH RISK β€” exceeds limit, jobs fail silently
SQS USERS: PAY ATTENTION

AWS SQS has a 256KB hard limit per message. A User model with 50 posts and 200 comments can easily exceed this. The job will fail with no clear error message. Always use ID-only with SQS.

πŸ€” When Do You Actually Need Full Model Serialization?

βœ… ACCEPTABLE CASES

  • The model is very small (only 2-3 attributes)
  • You're 100% sure the model will never have relations
  • You need a snapshot of the data as it was at dispatch time
  • Low-volume queues (< 100 jobs/day)

❌ ALWAYS USE ID WHEN

  • The model has any relations (posts, comments, etc.)
  • High-volume queues (thousands+ jobs)
  • Using database queue driver
  • Using SQS queue driver
  • Production environment (just to be safe)
πŸ”΄ THE SAFETY RULE:
When in doubt, dispatch the ID. One extra database query in the job is infinitely cheaper than a crashed queue system.

πŸ“ Topic 6 Summary: Queue Serialization Trap

ApproachPayload Size (100k jobs)Redis MemorySafety
Full User Model (no relations) 500 MB High ⚠️ Risk
Full User + Relations 50+ GB πŸ’€ CRASH πŸ’€ Never do this
ID Only 0.8 MB Minimal βœ… Always safe
πŸ“Œ THE RULE: Never dispatch a model. Always dispatch the ID. Fetch fresh inside the job.

This one pattern saves more production incidents than almost any other. Your queue will be faster, use less memory, and never hit payload limits.
NEXT TOPIC PREVIEW

Topic 7: Service Container & Reflection Cost β€” Why auto-wiring is expensive. The difference between singletons and transient services. How explicit binding saves CPU.