Copied!
Programming
Laravel
PHP

Queued Notifications & Mailables in Laravel – The Complete Guide

Queued Notifications & Mailables in Laravel – The Complete Guide
Shahroz Javed
Mar 21, 2026 . 32 views

Why Queue Notifications & Mailables?

Every notification you send synchronously delays your HTTP response:

  • Sending an email via SMTP takes 500ms–2s per send

  • Sending an SMS via Twilio takes 300ms–1s

  • Sending a Slack message takes 200ms–500ms

  • Sending a push notification to 10,000 devices blocks your thread entirely

When a user places an order and your controller sends an email confirmation, an SMS, and a Slack alert synchronously, you're adding 2–4 seconds to the response. Queue all of it.

Queuing a Notification

Adding queuing to a notification is a single line change — implement ShouldQueue:

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;

class OrderShipped extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(protected Order $order) {}

    public function via(object $notifiable): array
    {
        // Send via both email and Slack
        return ['mail', 'slack'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Your order #{$this->order->number} has shipped!")
            ->line("Your order is on its way.")
            ->action('Track Your Order', route('orders.show', $this->order))
            ->line('Thank you for shopping with us!');
    }

    public function toSlack(object $notifiable): SlackMessage
    {
        return (new SlackMessage)
            ->content("Order #{$this->order->number} shipped to {$notifiable->name}");
    }
}

How It Works

When a notification implements ShouldQueue, Laravel creates one queued job per channel per notification. If your notification uses mail and slack, two separate jobs are dispatched. If mail fails and retries, Slack isn't affected.

Send the Notification

// From a model — goes to queue automatically because of ShouldQueue
$user->notify(new OrderShipped($order));

// Via the Notification facade
Notification::send($users, new OrderShipped($order));

// Both are now instant — they just push to the queue

Per-Channel Queue Routing

This is one of the most underused features. You can send each notification channel to a different queue, allowing different priorities and throughput for each channel type.

class OrderShipped extends Notification implements ShouldQueue
{
    use Queueable;

    // Route each channel to its own queue
    public function viaQueues(): array
    {
        return [
            'mail'     => 'notifications-email',   // high priority
            'slack'    => 'notifications-slack',   // medium priority
            'database' => 'notifications-db',      // low priority (just DB insert)
            'vonage'   => 'notifications-sms',     // separate SMS queue
        ];
    }

    // Route each channel to its own connection too
    public function viaConnections(): array
    {
        return [
            'mail'  => 'redis',   // Redis for speed
            'slack' => 'database', // Database queue is fine for Slack
        ];
    }
}

Run separate workers for each queue with the appropriate concurrency:

# High-concurrency worker for emails (most volume)
php artisan queue:work --queue=notifications-email --sleep=1 # numprocs=4 in Supervisor

# Lower-concurrency worker for Slack
php artisan queue:work --queue=notifications-slack --sleep=3 # numprocs=1

afterCommit – Dispatch After Transaction

The same transaction dispatch problem from the anti-patterns post applies to notifications too. If you send a notification inside a database transaction, the notification job is dispatched before the transaction commits. If the transaction rolls back, the notification went out for data that doesn't exist.

// ❌ Notification dispatched before transaction commits
DB::transaction(function () use ($order, $user) {
    $order->update(['status' => 'shipped']);
    $user->notify(new OrderShipped($order));  // sent before commit!
});

// ✅ Option 1: Dispatch after transaction
DB::transaction(function () use ($order) {
    $order->update(['status' => 'shipped']);
});
$order->user->notify(new OrderShipped($order));  // safe

// ✅ Option 2: Use $afterCommit on the notification
class OrderShipped extends Notification implements ShouldQueue
{
    use Queueable;

    // Wait for the active transaction to commit before queuing
    public bool $afterCommit = true;
}
⚠️ $afterCommit = true is the cleanest solution — set it on any notification that sends user-facing messages (email, SMS, push). It prevents the "notification for rolled-back data" bug without changing how you call the notification.

Queuing a Mailable

Mailables can be queued independently of the Notification system. Use this when you want more control over the mailing process.

<?php

namespace App\Mail;

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 InvoicePdfReady extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    // Retry up to 3 times with backoff
    public int $tries = 3;
    public array $backoff = [30, 60, 120];

    // Route to a specific queue
    public string $queue = 'emails';

    public function __construct(protected Invoice $invoice) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Invoice #{$this->invoice->number} is ready to download",
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.invoice-ready',
            with: ['invoice' => $this->invoice],
        );
    }

    public function attachments(): array
    {
        return [
            Attachment::fromStorageDisk('s3', $this->invoice->pdf_path)
                ->as("invoice-{$this->invoice->number}.pdf")
                ->withMime('application/pdf'),
        ];
    }
}

Sending a Queued Mailable

// All of these queue the email automatically because of ShouldQueue
Mail::to($user)->send(new InvoicePdfReady($invoice));

// Send to multiple recipients
Mail::to($user)
    ->cc($accountant)
    ->bcc('archive@company.com')
    ->send(new InvoicePdfReady($invoice));

// Explicit delay
Mail::to($user)
    ->later(now()->addMinutes(30), new InvoicePdfReady($invoice));

On-Demand Queued Notifications

On-demand notifications let you send to non-model recipients — external email addresses, Slack channels, phone numbers — without needing a User model or the Notifiable trait.

use Illuminate\Support\Facades\Notification;

// Send to an arbitrary email
Notification::route('mail', 'support@external.com')
    ->notify(new SupportTicketCreated($ticket));

// Send to multiple channels at once
Notification::route('mail', 'admin@company.com')
    ->route('slack', '#general')
    ->route('vonage', '+15551234567')
    ->notify(new SystemAlert('Server CPU above 90%'));

// Still goes to queue if the notification implements ShouldQueue
class SupportTicketCreated extends Notification implements ShouldQueue
{
    use Queueable;
    // ...
}

Production Patterns & Architecture

Pattern 1: Notification Preference System

Real-world apps let users control which notifications they receive and on which channels. Build this into the via() method:

class NewCommentNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function via(object $notifiable): array
    {
        $channels = [];

        // Check user's preferences
        if ($notifiable->notification_preferences['email']['new_comment'] ?? true) {
            $channels[] = 'mail';
        }
        if ($notifiable->notification_preferences['push']['new_comment'] ?? false) {
            $channels[] = 'fcm';  // Firebase push
        }
        if ($notifiable->notification_preferences['database'] ?? true) {
            $channels[] = 'database';  // Always store in DB for the bell icon
        }

        return $channels;
    }
}

Pattern 2: Deduplicate Notifications

Prevent notification spam when the same event fires rapidly (e.g. 20 comments in 5 minutes). Combine ShouldBeUnique with time-windowed uniqueness:

use Illuminate\Contracts\Queue\ShouldBeUnique;

class NewCommentNotification extends Notification implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    // Only one notification per user per post per 10 minutes
    public int $uniqueFor = 600;

    public function uniqueId(): string
    {
        return "new-comment-{$this->notifiable->id}-{$this->post->id}";
    }
}

Pattern 3: Notification Digests

Instead of sending a notification for every event, batch events into a single digest. Use the scheduler to dispatch a digest job at regular intervals:

// Store events, not notifications
class NotificationEvent extends Model
{
    // user_id, type, data (JSON), created_at
}

// Queue one notification immediately (for real-time) but store the event
public function notifyUser(User $user, array $data): void
{
    NotificationEvent::create([
        'user_id' => $user->id,
        'type'    => 'new_comment',
        'data'    => $data,
    ]);
}

// Scheduler: send digest every hour to users who have unread events
// app/Console/Kernel.php
$schedule->job(new SendNotificationDigests)->hourly();

// App\Jobs\SendNotificationDigests.php
class SendNotificationDigests implements ShouldQueue
{
    public function handle(): void
    {
        User::whereHas('notificationEvents', function ($q) {
            $q->where('notified', false);
        })->each(function (User $user) {
            $events = $user->notificationEvents()->where('notified', false)->get();

            if ($events->isEmpty()) return;

            $user->notify(new DigestNotification($events));

            $events->each->update(['notified' => true]);
        });
    }
}

Testing Queued Notifications

use Illuminate\Support\Facades\Notification;
use App\Notifications\OrderShipped;

class OrderShippedTest extends TestCase
{
    public function test_user_receives_queued_notification_when_order_ships(): void
    {
        Notification::fake();

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create(['status' => 'processing']);

        // Trigger the action
        $order->markAsShipped();

        // Assert notification was queued
        Notification::assertSentTo($user, OrderShipped::class);

        // With data verification
        Notification::assertSentTo(
            $user,
            OrderShipped::class,
            function (OrderShipped $notification) use ($order) {
                return $notification->order->id === $order->id;
            }
        );
    }

    public function test_notification_sent_to_correct_channels(): void
    {
        Notification::fake();

        $user  = User::factory()->create(['sms_notifications' => true]);
        $order = Order::factory()->for($user)->create();

        $user->notify(new OrderShipped($order));

        Notification::assertSentTo($user, OrderShipped::class, function ($notification, $channels) {
            return in_array('mail', $channels) && in_array('vonage', $channels);
        });
    }

    public function test_notification_not_sent_when_user_opted_out(): void
    {
        Notification::fake();

        $user = User::factory()->create([
            'notification_preferences' => ['email' => ['new_comment' => false]],
        ]);

        $user->notify(new NewCommentNotification($comment));

        // Should only go to database, not email
        Notification::assertSentTo($user, NewCommentNotification::class, function ($notification, $channels) {
            return ! in_array('mail', $channels) && in_array('database', $channels);
        });
    }
}

Conclusion

Queuing notifications and mailables is one of the highest-impact performance improvements you can make to a Laravel app. Here's the complete checklist:

  • Add implements ShouldQueue and use Queueable to every notification that calls external services

  • Use viaQueues() to route different channels to different queues with different priorities

  • Always set $afterCommit = true on notifications sent inside or near transactions

  • Set $tries and $backoff on mailables — email providers go down

  • Use on-demand notifications for non-model recipients

  • Respect user preferences in the via() method — only send what the user asked for

  • Use ShouldBeUnique on notifications to prevent spam when events fire rapidly

  • Use Notification::fake() for tests — never send real emails or SMS in tests

📑 On This Page