Introduction
Sending notifications across multiple channels—email, SMS, Slack, and in-app database alerts—typically requires gluing together different libraries and managing their quirks separately. Laravel notifications: a practical guide for developers shows you how to unify this chaos into one clean API that handles routing, formatting, and delivery consistently. After working through numerous e-commerce and SaaS projects, we've learned that getting notifications right early saves hours of refactoring later.
Notification classes define channels
The foundation of Laravel's notification system is the notification class. Each notification you create is a single class that knows how to format its content for different delivery channels. This separation of concerns means you write the notification logic once, then Laravel handles the rest.
Let's say we're building an order management system. When an order ships, we need to notify the customer via email and SMS. We create a notification class that encapsulates this responsibility:
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use NotificationChannels\Twilio\TwilioSmsMessage;
class OrderShipped extends Notification
{
public $order;
public function __construct($order)
{
$this->order = $order;
}
public function via($notifiable)
{
return ['mail', 'twilio'];
}
public function toMail($notifiable)
{
return (new MailMessage)
->subject('Your order has shipped')
->line('Order #' . $this->order->id)
->action('Track Order', route('orders.track', $this->order))
->line('Thank you for your business.');
}
public function toTwilio($notifiable)
{
return (new TwilioSmsMessage)
->content('Your order #' . $this->order->id . ' has shipped. Track it here: ' . route('orders.track', $this->order));
}
}Generate this class with php artisan make:notification OrderShipped. Each to* method corresponds to a channel. The framework calls the appropriate method based on what's returned from via(). For email, we get a fluent interface that handles attachments, view rendering, and headers. For SMS, we use Twilio's channel package.
The beauty here is that the order logic lives in one place. If we later decide to add Slack notifications, we add a toSlack() method—no need to touch email or SMS code.
via() picks channels per user
The via() method determines which channels receive the notification. But here's the power: it's called with the notifiable instance, so you can make routing decisions at runtime based on user preferences, subscription tier, or any other factor.
Consider a SaaS platform where premium users get SMS alerts but free users don't:
public function via($notifiable)
{
$channels = ['mail'];
if ($notifiable->isPremium()) {
$channels[] = 'twilio';
}
if ($notifiable->hasSlackIntegration()) {
$channels[] = 'slack';
}
return $channels;
}You can also store channel preferences in the database. Imagine a user table with a notification_channels JSON column:
public function via($notifiable)
{
return $notifiable->notification_channels ?? ['mail'];
}This lets users control how they receive notifications without code changes. When a user logs in and adjusts their notification settings, the next notification they receive will respect those choices automatically.
Another pattern we've used in ride-hailing applications: escalating urgency changes the channel. A low-priority system health alert goes to email, but a critical incident notification attempts SMS first, then Slack, then email as a fallback:
public function via($notifiable)
{
return match($this->alert->severity) {
'critical' => ['twilio', 'slack', 'mail'],
'high' => ['slack', 'mail'],
'medium' => ['mail'],
'low' => ['mail'],
};
}The framework sends the notification through each channel in the order listed, so you can implement intelligent retry logic.
Database notifications for in-app alerts
Not every notification needs to leave your application. Database notifications store alerts in your notifications table, appearing as an in-app bell icon or notification center. This channel is built into Laravel and requires just one migration:
php artisan notifications:table
php artisan migrateThis creates a notifications table with columns for id, notifiable_type, notifiable_id, type, data (JSON), read_at, and timestamps. Now add the database channel to your notification:
public function via($notifiable)
{
return ['mail', 'database'];
}
public function toDatabase($notifiable)
{
return [
'order_id' => $this->order->id,
'status' => 'shipped',
'tracking_url' => route('orders.track', $this->order),
];
}The data you return is stored as JSON in the notification record. On your frontend, fetch unread notifications:
// In a controller or API endpoint
$notifications = auth()->user()->unreadNotifications;
foreach ($notifications as $notification) {
echo $notification->data['tracking_url'];
$notification->markAsRead();
}We've used this pattern extensively in CRM applications where teams need to see activity feeds—new leads assigned, deals won, comments on records—without being overwhelmed by email. The database channel lets users see what happened, then decide if they need external alerts.
You can also broadcast database notifications to your frontend in real-time using WebSockets. Add the broadcast channel and use Laravel Echo in your JavaScript:
public function via($notifiable)
{
return ['database', 'broadcast'];
}Now when you send a notification, it hits both the database and a broadcast channel. Connected clients receive it instantly without polling.
Queue them
Sending notifications synchronously can slow down your request. Email especially—if the mail server is slow, your user waits. Always queue notifications:
// In your model or controller
$user->notify(new OrderShipped($order));
By default, this runs synchronously. To queue it, implement ShouldQueue on your notification:
namespace App\Notifications;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class OrderShipped extends Notification implements ShouldQueue
{
// ...
}Now the notification is pushed to your queue and processed by a worker. This is essential in production. Your request completes in milliseconds while the queue worker handles email delivery in the background.
You can also control delay and retries:
public function __construct($order)
{
$this->order = $order;
$this->delay(now()->addMinutes(5)); // Send after 5 minutes
}
public function retryUntil()
{
return now()->addHours(24); // Keep trying for 24 hours
}In e-commerce, we've used delays to batch notifications—wait a few seconds to see if more orders come in, then send a summary instead of 10 individual emails. We've also set aggressive retries for payment notifications where delivery is critical.
Monitor your queue to catch issues early. In production, use Laravel Horizon or similar tools to watch for backed-up jobs:
// Check queue depth
$count = \Illuminate\Support\Facades\Queue::connection('redis')->size();
Common mistakes
Not handling missing user channels gracefully. If you call via() and the user has no email address, the toMail() method will still be called and fail. Always validate that data exists before attempting to use it:
public function via($notifiable)
{
$channels = [];
if ($notifiable->email) {
$channels[] = 'mail';
}
if ($notifiable->phone_number) {
$channels[] = 'twilio';
}
return $channels ?: ['database'];
}Putting business logic in notification methods. Your notification classes should format content, not fetch data. Load everything in the constructor or through the model relationship:
// Bad
public function toMail($notifiable)
{
$invoice = Invoice::where('order_id', $this->order->id)->first();
}
// Good
public function __construct($order)
{
$this->order = $order->load('invoice');
}Forgetting to implement ShouldQueue. We've seen production systems grind to a halt because notifications were running synchronously, blocking the request. Make it a habit to always queue.
Not testing all channels. It's easy to test email rendering but forget that your Slack message needs the right emoji or URL format. Test each channel separately:
public function test_slack_notification()
{
Notification::fake();
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class, function ($notification) {
return $notification->toSlack($user)->content === 'expected text';
});
}Key takeaways
- Notification classes unify email, SMS, Slack, and database alerts under one API, reducing code duplication.
- The
via()method enables per-user channel routing, so you can respect preferences or escalate based on severity. - Database notifications create in-app alerts that can be broadcast to connected clients for real-time updates.
- Always implement
ShouldQueuein production to keep notifications out of your request cycle. - Validate that user data (email, phone number, Slack workspace ID) exists before including channels in the notification.
- Load related data in the constructor to keep notification methods focused on formatting, not database queries.






Comments
Loading comments…