Tools
Tools: Laravel Notifications in Practice: Mail, Database, Queues, and Clean Testing
2026-02-20
0 views
admin
Why use Notifications instead of sending mail directly ? ## A real example: order shipped ## Step 1: Generate a notification ## Step 2: Build the notification class ## Why this is clean ## Step 3: Send the notification ## Step 4: Enable database notifications ## Step 5: Display notifications in your app ## Step 6: Mark notifications as read ## Step 7: Keep your app fast with queues ## Why queueing matters ## Step 8: Test notifications cleanly ## A practical pattern I like ## Common mistakes to avoid ## 1) Sending notifications directly in controllers everywhere ## 2) Forgetting to queue notifications ## 3) Overloading notifications with business logic ## 4) Mixing database and broadcast payloads without thinking ## Bonus ideas for production projects ## Final thoughts Notifications look simple at first—until your app grows. You start by sending a quick email from a controller, but soon you need more: That is exactly where Laravel Notifications shine. They provide a structured way to send short, event-driven messages across multiple channels using a single class. Laravel gives you a clean, structured way to send short, event-driven messages (like Order shipped, Invoice paid, New comment, Password changed) across multiple channels with one notification class. In this article, I’ll show a practical setup you can reuse in real projects. While you could call Mail::to()->send() everywhere, notifications offer a better architecture: This makes your code easier to maintain when your app evolves. Let’s say you have an e-commerce app and want to notify a user when an order is shipped. Laravel creates a class in app/Notifications/OrderShipped.php. Here is a practical version: If your User model uses Laravel’s default Notifiable trait (it usually does), sending is straightforward: You can also send to multiple users: This is super useful for admin alerts, moderation events, or internal ops notifications. If you want in-app notifications (bell icon, notification list, unread count), create the notifications table: Laravel stores notification payloads in a notifications table (including the type, JSON data, and read_at status). Because your model uses Notifiable, you get relationships out of the box: Example in a controller: This is enough to build a simple notification center in Vue / React / Blade. A common pattern: when a user opens the notification list, mark unread notifications as read. For large volumes, you can also use a direct query update (more efficient than looping in memory). Notifications often call external services (SMTP, Slack, SMS providers), so they should usually be queued. Now make sure your queue is configured and a worker is running (Redis is a great choice in production). This becomes a big win as soon as you have real traffic. One of Laravel’s best features here is Notification::fake(). It lets you test behavior without sending anything. This keeps tests fast, reliable, and focused on your business logic. In real projects, I usually trigger notifications after a state change in a service/action class. It works at first, but it spreads your logic across the app. Mail and third-party channels can slow down your requests a lot. Keep notifications focused on delivery + presentation.
Your business rules should live in services/actions. If your database payload and realtime frontend payload need to differ, define toDatabase() and toBroadcast() separately instead of relying on one toArray() for both. Once your base setup is working, Laravel Notifications scale really well: You can even make the via() method dynamic: That’s a very clean way to support user preferences. Laravel Notifications are simple to start with, but very powerful when your app grows. They give you a consistent structure for: If you’re building Laravel apps with user workflows (orders, bookings, payments, accounts, dashboards), Notifications are one of those features that quickly become foundational. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
php artisan make:notification OrderShipped Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
php artisan make:notification OrderShipped CODE_BLOCK:
php artisan make:notification OrderShipped COMMAND_BLOCK:
<?php namespace App\Notifications; use App\Models\Order;
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 Order $order ) {} /** * Decide which channels to use. */ public function via(object $notifiable): array { return ['mail', 'database']; } /** * Email version. */ public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject('Your order has been shipped') ->greeting('Hello ' . $notifiable->name . ' 👋') ->line("Good news! Your order #{$this->order->id} has been shipped.") ->action('Track my order', url("/orders/{$this->order->id}")) ->line('Thank you for your purchase.'); } /** * Database version (stored as JSON in notifications table). */ public function toArray(object $notifiable): array { return [ 'order_id' => $this->order->id, 'status' => 'shipped', 'message' => "Your order #{$this->order->id} has been shipped.", 'url' => "/orders/{$this->order->id}", ]; }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
<?php namespace App\Notifications; use App\Models\Order;
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 Order $order ) {} /** * Decide which channels to use. */ public function via(object $notifiable): array { return ['mail', 'database']; } /** * Email version. */ public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject('Your order has been shipped') ->greeting('Hello ' . $notifiable->name . ' 👋') ->line("Good news! Your order #{$this->order->id} has been shipped.") ->action('Track my order', url("/orders/{$this->order->id}")) ->line('Thank you for your purchase.'); } /** * Database version (stored as JSON in notifications table). */ public function toArray(object $notifiable): array { return [ 'order_id' => $this->order->id, 'status' => 'shipped', 'message' => "Your order #{$this->order->id} has been shipped.", 'url' => "/orders/{$this->order->id}", ]; }
} COMMAND_BLOCK:
<?php namespace App\Notifications; use App\Models\Order;
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 Order $order ) {} /** * Decide which channels to use. */ public function via(object $notifiable): array { return ['mail', 'database']; } /** * Email version. */ public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject('Your order has been shipped') ->greeting('Hello ' . $notifiable->name . ' 👋') ->line("Good news! Your order #{$this->order->id} has been shipped.") ->action('Track my order', url("/orders/{$this->order->id}")) ->line('Thank you for your purchase.'); } /** * Database version (stored as JSON in notifications table). */ public function toArray(object $notifiable): array { return [ 'order_id' => $this->order->id, 'status' => 'shipped', 'message' => "Your order #{$this->order->id} has been shipped.", 'url' => "/orders/{$this->order->id}", ]; }
} CODE_BLOCK:
$user->notify(new OrderShipped($order)); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
$user->notify(new OrderShipped($order)); CODE_BLOCK:
$user->notify(new OrderShipped($order)); CODE_BLOCK:
use Illuminate\Support\Facades\Notification; Notification::send($admins, new OrderShipped($order)); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
use Illuminate\Support\Facades\Notification; Notification::send($admins, new OrderShipped($order)); CODE_BLOCK:
use Illuminate\Support\Facades\Notification; Notification::send($admins, new OrderShipped($order)); CODE_BLOCK:
php artisan make:notifications-table
php artisan migrate Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
php artisan make:notifications-table
php artisan migrate CODE_BLOCK:
php artisan make:notifications-table
php artisan migrate COMMAND_BLOCK:
public function index(Request $request)
{ $user = $request->user(); return response()->json([ 'unread_count' => $user->unreadNotifications()->count(), 'items' => $user->notifications()->latest()->limit(20)->get(), ]);
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
public function index(Request $request)
{ $user = $request->user(); return response()->json([ 'unread_count' => $user->unreadNotifications()->count(), 'items' => $user->notifications()->latest()->limit(20)->get(), ]);
} COMMAND_BLOCK:
public function index(Request $request)
{ $user = $request->user(); return response()->json([ 'unread_count' => $user->unreadNotifications()->count(), 'items' => $user->notifications()->latest()->limit(20)->get(), ]);
} COMMAND_BLOCK:
public function markAllAsRead(Request $request)
{ $request->user()->unreadNotifications->markAsRead(); return response()->json(['message' => 'Notifications marked as read']);
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
public function markAllAsRead(Request $request)
{ $request->user()->unreadNotifications->markAsRead(); return response()->json(['message' => 'Notifications marked as read']);
} COMMAND_BLOCK:
public function markAllAsRead(Request $request)
{ $request->user()->unreadNotifications->markAsRead(); return response()->json(['message' => 'Notifications marked as read']);
} CODE_BLOCK:
QUEUE_CONNECTION=database Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
QUEUE_CONNECTION=database CODE_BLOCK:
QUEUE_CONNECTION=database CODE_BLOCK:
php artisan queue:work Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
php artisan queue:work CODE_BLOCK:
php artisan queue:work COMMAND_BLOCK:
use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderShipped;
use Illuminate\Support\Facades\Notification; it('notifies the user when an order is shipped', function () { Notification::fake(); $user = User::factory()->create(); $order = Order::factory()->create(['user_id' => $user->id]); // Your application logic... $user->notify(new OrderShipped($order)); Notification::assertSentTo($user, OrderShipped::class);
}); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderShipped;
use Illuminate\Support\Facades\Notification; it('notifies the user when an order is shipped', function () { Notification::fake(); $user = User::factory()->create(); $order = Order::factory()->create(['user_id' => $user->id]); // Your application logic... $user->notify(new OrderShipped($order)); Notification::assertSentTo($user, OrderShipped::class);
}); COMMAND_BLOCK:
use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderShipped;
use Illuminate\Support\Facades\Notification; it('notifies the user when an order is shipped', function () { Notification::fake(); $user = User::factory()->create(); $order = Order::factory()->create(['user_id' => $user->id]); // Your application logic... $user->notify(new OrderShipped($order)); Notification::assertSentTo($user, OrderShipped::class);
}); COMMAND_BLOCK:
class ShipOrderAction
{ public function handle(Order $order): void { $order->update(['status' => 'shipped']); $order->user->notify(new OrderShipped($order)); }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
class ShipOrderAction
{ public function handle(Order $order): void { $order->update(['status' => 'shipped']); $order->user->notify(new OrderShipped($order)); }
} COMMAND_BLOCK:
class ShipOrderAction
{ public function handle(Order $order): void { $order->update(['status' => 'shipped']); $order->user->notify(new OrderShipped($order)); }
} CODE_BLOCK:
public function via(object $notifiable): array
{ return $notifiable->wants_email_notifications ? ['mail', 'database'] : ['database'];
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
public function via(object $notifiable): array
{ return $notifiable->wants_email_notifications ? ['mail', 'database'] : ['database'];
} CODE_BLOCK:
public function via(object $notifiable): array
{ return $notifiable->wants_email_notifications ? ['mail', 'database'] : ['database'];
} - Email and in-app notifications.
- Different channels depending on user preferences.
- Queued delivery so the UI stays fast.
- Clean tests that don't actually hit your SMTP server. - Centralized logic: One place to define delivery channels via the via() method.
- Multi-channel support: Easily switch between or combine mail, database, Slack, and SMS.
- Built-in Queues: Native support for background processing.
- Observability: Built-in support for testing with Notification::fake(). - an email notification
- an in-app notification stored in the database - via() defines the channels in one place
- toMail() handles only the email message
- toArray() formats the in-app payload
- ShouldQueue + Queueable keeps delivery async and your request fast - $user->notifications
- $user->unreadNotifications
- $user->readNotifications - implements ShouldQueue
- use Queueable - user clicks a button
- request waits for email/SMS API
- response feels slower - notification job is pushed to the queue
- request returns quickly
- worker sends the notification in the background - controller stays thin
- notification logic is tied to the domain action
- easy to test
- easy to reuse from HTTP, CLI, or jobs - Admin alerts (new signup, failed payment, suspicious login)
- User activity (comment replies, mentions, reminders)
- Subscription events (trial ending, invoice paid, renewal failed)
- Realtime UI notifications with broadcasting
- Channel preferences (email only, app only, both) - deciding where a message should be sent
- formatting it for each channel
- queueing it for performance
- testing it safely
how-totutorialguidedev.toaiserverswitchdatabase