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