0

Queued mail — ShouldQueue on a Mailable

Intermediate5 min read·lv-22-003

Concept

Sending email synchronously during a web request is a user experience antipattern. A single SMTP send can take 300–800ms depending on the provider and network conditions. API-based transports like Mailgun are faster but still add latency. The fix is obvious: push the send to a queue worker and return the HTTP response immediately.

Implementing ShouldQueue on a Mailable is the entire API change required. Laravel's Mailer checks whether the mailable implements ShouldQueue and, if so, dispatches it as a SendQueuedMailable job rather than sending immediately. The mailable is serialized (respecting SerializesModels) and stored in your configured queue driver.

The Queueable trait (conventionally included alongside ShouldQueue) adds methods for customizing queue behavior: ->onQueue('emails'), ->onConnection('redis'), ->delay(now()->addMinutes(5)). These can be called when constructing the mailable before passing it to Mail::to()->send(), or they can be set as defaults inside the mailable class by overriding __construct.

Queued mailables participate in all standard queue features: retries, backoff, timeouts, and failed job handling. Set $tries and $backoff on the mailable class itself. If the mail send ultimately fails, it lands in the failed_jobs table and can be retried with php artisan queue:retry.

One subtle trap: the mail queue connection used by your workers must be the same connection configured for your queue system. If your app uses QUEUE_CONNECTION=redis but a worker is only watching the default queue, queued mailables dispatched to the emails queue won't be processed until a worker listens to that queue explicitly.

Markdown mailables using ShouldQueue are fully supported — the markdown rendering happens inside the worker process, not at dispatch time, so the full template rendering overhead is moved off the web request as well.

Code Example

php
<?php

namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class WelcomeMail extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    // Queue configuration
    public int $tries = 3;
    public int $backoff = 60; // seconds between retries

    public function __construct(
        public readonly User $user,
    ) {
        // Default to the dedicated mail queue
        $this->onQueue('emails');
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Welcome to the platform, {$this->user->name}!",
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.welcome',
        );
    }
}

// Dispatching — returns immediately, send happens in worker
Mail::to($user->email)->send(new WelcomeMail($user));

// Delay the send — useful for onboarding drip emails
Mail::to($user->email)->send(
    (new WelcomeMail($user))->delay(now()->addHours(1))
);

// When you want to queue explicitly even if the class doesn't implement ShouldQueue
Mail::to($user->email)->queue(new WelcomeMail($user));

// Later sends — schedule a follow-up
Mail::to($user->email)->send(
    (new FollowUpMail($user))
        ->onQueue('emails')
        ->delay(now()->addDays(3))
);

Interview Q&A

Q: What exactly happens under the hood when a Mailable implementing ShouldQueue is passed to Mail::to()->send()?

Laravel's Mailer::send() checks whether the mailable implements ShouldQueue. If it does, instead of calling the transport directly, it creates a Illuminate\Mail\SendQueuedMailable job, serializes the mailable (using PHP serialization, with SerializesModels replacing any Eloquent models with identifiers), and dispatches that job to the queue. The job's handle() method, when executed by a worker, reconstructs the mailable (re-fetching models from the database), builds the Symfony Mailer message, and calls the transport. This means the web process finishes its response immediately and the actual SMTP or API call happens entirely in the worker process.


Q: How do retries work for queued mailables, and what happens when all retries are exhausted?

Queued mailables respect the $tries and $backoff properties defined on the class. When a send fails (transport throws an exception), the job is released back to the queue with a delay defined by $backoff (fixed seconds or an array for exponential backoff). When the attempts counter reaches $tries, the job is marked as failed and recorded in the failed_jobs table with the exception message. You can replay it with php artisan queue:retry {id} or queue:retry all. For critical transactional mail, implement the failed(Throwable $exception): void method on the mailable to trigger a fallback (e.g., notify ops via Slack).


Q: Is there a risk of sending duplicate emails when using queued mailables with retries?

Yes, this is a real risk. If the transport successfully delivers the email but then throws an exception before the job acknowledges completion (e.g., a timeout or a network error on the response), the queue system will retry the job, potentially sending the email again. The safest mitigation is idempotency tracking: before sending, check a cache key or database flag like email_sent_at on the model, set it atomically before dispatch using a cache lock, and have the job bail out if the flag is already set. For most transactional emails the risk is low, but for financial notifications or one-time verification links, build explicit idempotency.