Default notification tutorials show you how to send a mail message when a user buys a product. That works fine for small side projects, but production applications break quickly under those assumptions. Users get spammed across four channels at once, queue workers choke on uncommitted database transactions, and custom SMS gateways fail without retry policies.
Managing Per-User Notification Preferences
Don't hardcode channels in your notification's via() method. Users want granular control over whether an alert arrives via email, database, or SMS. Storing these settings in a JSON column on your users table keeps queries simple and avoids extra table joins.
In Laravel 12 running on PHP 8.3, typed properties and dynamic channel resolution make evaluating these preferences clean. Here is how you can dynamically resolve channels inside a notification class based on a user's settings cast.
namespace App\Notifications;
use App\Channels\SmsChannel;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public readonly string $orderId) {}
public function via(User $notifiable): array
{
$prefs = $notifiable->notification_preferences ?? [];
$channels = [];
if ($prefs['email_order_shipped'] ?? true) {
$channels[] = 'mail';
}
if ($prefs['database_order_shipped'] ?? true) {
$channels[] = 'database';
}
if (($prefs['sms_order_shipped'] ?? false) && $notifiable->phone_number) {
$channels[] = SmsChannel::class;
}
if ($prefs['broadcast_order_shipped'] ?? true) {
$channels[] = 'broadcast';
}
return $channels;
}
public function toMail(User $notifiable): MailMessage
{
return (new MailMessage)
->subject("Order #{$this->orderId} Shipped")
->line("Your package is on its way.")
->action('View Order', url("/orders/{$this->orderId}"));
}
public function toArray(User $notifiable): array
{
return [
'order_id' => $this->orderId,
'message' => "Order #{$this->orderId} has been shipped.",
];
}
}Notice how via() accepts the $notifiable model instance. If a user disabled SMS notifications in their settings, the custom SmsChannel isn't even executed. That saves network calls to external APIs like Twilio or Vonage.
Building Custom Notification Channels
Laravel includes built-in drivers for mail, database, and broadcast, but custom business requirements almost always require custom outputs. Creating a custom channel class in Laravel 12 requires nothing more than a class containing a send() method.
Here is a complete custom SMS driver implementation that handles retry logic gracefully without crashing your job worker.
namespace App\Channels;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SmsChannel
{
public function send(object $notifiable, Notification $notification): void
{
if (! method_exists($notification, 'toSms')) {
throw new \RuntimeException('Notification is missing the toSms method.');
}
$message = $notification->toSms($notifiable);
$to = $notifiable->routeNotificationFor('sms', $notification) ?? $notifiable->phone_number;
if (! $to) {
return;
}
$response = Http::timeout(5)
->retry(3, 100)
->post('https://api.smsprovider.com/v1/send', [
'recipient' => $to,
'body' => $message,
]);
if ($response->failed()) {
Log::error('SMS delivery failed', [
'notifiable' => $notifiable->id,
'status' => $response->status(),
'body' => $response->body(),
]);
}
}
}By default, Laravel looks for a method named routeNotificationForSms() on your User model when using custom string aliases. When passing the channel class directly as SmsChannel::class, calling $notifiable->routeNotificationFor('sms') allows models to define specific routing logic per channel.
Queued Notifications and Uncommitted Database Transactions
The biggest production gotcha with queued notifications occurs when you dispatch a notification inside an active database transaction. If your queue worker picks up the job faster than your primary database node commits the transaction, the worker queries for a model record that doesn't exist yet in its snapshot.
The worker throws an Illuminate\Database\Eloquent\ModelNotFoundException and fails immediately. You'll see jobs failing in Horizon with missing record errors even though the record exists in your database two seconds later.
To fix this in Laravel 12, configure your notification class to wait for transaction completion before sending jobs to the queue. You can enable after_commit globally in your queue config, or add the $afterCommit property directly to the notification class.
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public bool $afterCommit = true;
}This single line drops transient job failures by roughly 95% in high-concurrency systems. If you are processing 500 orders a minute, you cannot afford jobs crashing because database replication lag added 15ms to your commit time.
Broadcasting Real-Time Alerts to Next.js 16 and React 19
When broadcasting notifications, Laravel serializes the output of toArray() or toBroadcast() into a WebSocket payload managed by Laravel Reverb or Pusher. On the frontend, Next.js 16 client components running React 19 can subscribe to private channels using Laravel Echo.
Here is how to set up a React 19 client hook to listen for real-time notifications over WebSockets.
'use client';
import { useEffect, useState } from 'react';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
declare global {
interface Window {
Pusher: typeof Pusher;
Echo: Echo;
}
}
export function useNotifications(userId: number) {
const [notifications, setNotifications] = useState<Array<any>>([]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.Pusher = Pusher;
const echo = new Echo({
broadcaster: 'reverb',
key: process.env.NEXT_PUBLIC_REVERB_APP_KEY,
wsHost: process.env.NEXT_PUBLIC_REVERB_HOST,
wsPort: process.env.NEXT_PUBLIC_REVERB_PORT ?? 80,
wssPort: process.env.NEXT_PUBLIC_REVERB_PORT ?? 443,
forceTLS: (process.env.NEXT_PUBLIC_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
echo.private(`App.Models.User.${userId}`)
.notification((notification: any) => {
setNotifications((prev) => [notification, ...prev]);
});
return () => {
echo.leave(`App.Models.User.${userId}`);
};
}, [userId]);
return notifications;
}Laravel broadcasts notifications on private channels formatted as App.Models.User.{id} by default. The React 19 client listens via .notification(), which handles the specific event wrapping Laravel uses behind the scenes. Don't use standard .listen() calls for notifications unless you manually override broadcast names in PHP.
Performance Benchmarks and Real World Bottlenecks
Sending emails synchronously inside an HTTP request lifecycle adds 250ms to 800ms of latency per request. Queueing notifications drops API response times from 450ms down to 35ms on standard 2 vCPU cloud instances running PHP 8.3 with FrankenPHP or Swoole.
However, queueing creates a different bottleneck: memory consumption under heavy batch dispatches. If you send a notification to 50,000 users at once using Notification::send($users, new SystemAlert()), Laravel loads the entire Eloquent collection into memory before dispatching individual queue jobs. Instantiating 50,000 model objects quickly hits your PHP memory limit.
Instead of passing large collections, use database cursors or dispatch dedicated chunking jobs to process users in batches of 1,000.
Common Pitfalls to Avoid
Missing Queue Backoffs: Custom HTTP channels hit rate limits if third-party APIs suffer degradation. Always set
public $backoff = [10, 30, 60];on queued notification classes.Database Channel Payload Size: The database channel stores payloads inside a
textorjsoncolumn. Storing full Eloquent models insidetoArray()inflates table size and causes slow reads on unindexed notification payloads. Store primitive IDs and brief text strings only.Reverb CORS and Authentication: In Next.js 16, private channel authentication requests land on your Laravel API at
/broadcasting/auth. If credentials or cookies aren't forwarded correctly, Echo fails silently without firing callbacks. Ensure your CORS configuration explicitly allows cross-origin credentials.













