This article is part of the CakeDC Advent Calendar 2025 (December 8th 2025)
Building a modern application without notifications is like running a restaurant without telling customers their food is ready. Users need to know what's happening. An order shipped. A payment went through. Someone mentioned them in a comment. These moments matter, and how you communicate them matters even more.
I've built notification systems before. They always started simple. Send an email when something happens. Easy enough. Then someone wants in-app notifications. Then someone needs Slack alerts. Then the mobile team wants push notifications. Before you know it, you're maintaining five different notification implementations, each with its own bugs and quirks.
That's exactly why the CakePHP Notification plugin exists. It brings order to the chaos by giving you one consistent way to send notifications, regardless of where they're going or how they're being delivered.
The core notification system (crustum/notification) provides the foundation with database and email support built in.
Two Worlds of Notifications
Notifications naturally fall into two categories, and understanding this split helps you architect your system correctly.
The first category is what I call presence notifications. These are for users actively using your application. They're sitting there, browser open, working away. You want to tell them something right now. A new message arrived. Someone approved their request. The background job finished. These notifications need to appear instantly in the UI, update the notification bell, and maybe play a sound. They live in your database and get pushed to the browser through WebSockets.
The second category is reach-out notifications. These go find users wherever they are. Email reaches them in their inbox. SMS hits their phone. Slack pings them in their workspace. Telegram messages appear on every device they own. These notifications cross boundaries, reaching into other platforms and services to deliver your message.
Understanding this distinction is crucial because these two types of notifications serve different purposes and require different technical approaches. Presence notifications need a database to store history and WebSocket connections for real-time delivery. Reach-out notifications need API integrations and reliable delivery mechanisms.
The Beautiful Part: One Interface
Here's where it gets good. Despite these two worlds being completely different, you write the same code to send both types. Your application doesn't care whether a notification goes to the database, WebSocket, email, or Slack. You just say "notify this user" and the system handles the rest.
$user = $this->Users->get($userId);
$user->notify(new OrderShipped($order));
That's it. The OrderShipped notification might go to the database for the in-app notification bell, get broadcast via WebSocket for instant delivery, and send an email with tracking information. All from that one line of code.
Web interface for notifications
Let's talk about the in-app notification experience first. This is what most users interact with daily. That little bell icon in the corner of your application. Click it, see your notifications. It's so common now that users expect it.
The NotificationUI plugin (crustum/notification-ui) provides a complete notification interface out of the box. There's a bell widget that you drop into your layout, and it just works. It shows the unread count, displays notifications in a clean interface, marks them as read when clicked, and supports actions like buttons in the notification.
You have two display modes to choose from. Dropdown mode gives you the traditional experience where clicking the bell opens a menu below it. Panel mode creates a sticky side panel that slides in from the edge of your screen, similar to what you see in modern admin panels.
Setting it up takes just a few lines in your layout template.
<?= $this->element('Crustum/NotificationUI.notifications/bell_icon', [
'mode' => 'panel',
'pollInterval' => 30000,
]) ?>
The widget automatically polls the server for new notifications every 30 seconds by default. This works perfectly fine for most applications. Users see new notifications within a reasonable time, and your server isn't overwhelmed with requests.
But sometimes 30 seconds feels like forever. When someone sends you a direct message, you want to see it immediately. That's where real-time broadcasting comes in.
Real-Time Broadcasting for Instant Delivery
Adding real-time broadcasting transforms the notification experience. Instead of polling every 30 seconds, new notifications appear instantly through WebSocket connections. The moment someone triggers a notification for you, it pops up in your interface.
The beautiful thing is you can combine both approaches. Keep database polling as a fallback, add real-time broadcasting for instant delivery. If the WebSocket connection drops, polling keeps working. When the connection comes back, broadcasting takes over again. Users get reliability and instant feedback.
<?php $authUser = $this->request->getAttribute('identity'); ?>
<?= $this->element('Crustum/NotificationUI.notifications/bell_icon', [
'mode' => 'panel',
'enablePolling' => true,
'broadcasting' => [
'userId' => $authUser->getIdentifier(),
'userName' => $authUser->username,
'pusherKey' => 'app-key',
'pusherHost' => '127.0.0.1',
'pusherPort' => 8080,
],
]) ?>
This hybrid approach gives you the best of both worlds. Real-time when possible, reliable fallback always available.
Behind the scenes, this uses the Broadcasting (crustum/broadcasting) and BroadcastingNotification (crustum/notification-broadcasting) plugins working together. When you broadcast a notification, it goes through the same WebSocket infrastructure. The NotificationUI plugin handles subscribing to the right channels and updating the interface when broadcasts arrive.
Creating Your Notification Classes
Notifications in CakePHP are just classes. Each notification type gets its own class that defines where it goes and what it contains. This keeps everything organized and makes notifications easy to test.
namespace App\Notification;
use Crustum\Notification\Notification;
use Crustum\Notification\Message\DatabaseMessage;
use Crustum\Notification\Message\MailMessage;
use Crustum\BroadcastingNotification\Message\BroadcastMessage;
use Crustum\BroadcastingNotification\Trait\BroadcastableNotificationTrait;
class OrderShipped extends Notification
{
use BroadcastableNotificationTrait;
public function __construct(
private $order
) {}
public function via($notifiable): array
{
return ['database', 'broadcast', 'mail'];
}
public function toDatabase($notifiable): DatabaseMessage
{
return DatabaseMessage::new()
->title('Order Shipped')
->message("Your order #{$this->order->id} has shipped!")
->actionUrl(Router::url(['controller' => 'Orders', 'action' => 'view', $this->order->id], true))
->icon('check');
}
public function toMail($notifiable): MailMessage
{
return MailMessage::create()
->subject('Your Order Has Shipped')
->greeting("Hello {$notifiable->name}!")
->line("Great news! Your order #{$this->order->id} has shipped.")
->line("Tracking: {$this->order->tracking_number}")
->action('Track Your Order', ['controller' => 'Orders', 'action' => 'track', $this->order->id]);
}
public function toBroadcast(EntityInterface|AnonymousNotifiable $notifiable): BroadcastMessage|array
{
return new BroadcastMessage([
'title' => 'Order Shipped',
'message' => "Your order #{$this->order->id} has shipped!",
'order_id' => $this->order->id,
'order_title' => $this->order->title,
'tracking_number' => $this->order->tracking_number,
'action_url' => Router::url(['controller' => 'Orders', 'action' => 'view', $this->order->id], true),
]);
}
public function broadcastOn(): array
{
return [new PrivateChannel('users.' . $notifiable->id)];
}
}
The via method tells the system which channels to use. The toDatabase method formats the notification for display in your app. The toMail method creates an email. The toBroadcast method formats the notification for broadcast. The broadcastOn method specifies which WebSocket channels to broadcast to.
One notification class, three different formats, all sent automatically when you call notify. That's the power of this approach.
Reach-Out Notifications
Now let's talk about reaching users outside your application. This is where the plugin really shines because there are so many channels available.
Email is the classic. Everyone has email. The base notification plugin gives you a fluent API for building beautiful transactional emails. You describe what you want to say using simple methods, and it generates a responsive HTML email with a plain text version automatically.
Slack integration (crustum/notification-slack) lets you send notifications to team channels. Perfect for internal alerts, deployment notifications, or monitoring events. You get full support for Slack's Block Kit, so you can create rich, interactive messages with buttons, images, and formatted sections.
Telegram (crustum/notification-telegram) reaches users on their phones. Since Telegram has a bot API, you can send notifications directly to users who've connected their Telegram account. The messages support formatting, buttons, and even images.
SMS through Seven.io (crustum/notification-seven) gets messages to phones as text messages. This is great for critical alerts, verification codes, or appointment reminders. Things that need immediate attention and work even without internet access.
RocketChat (crustum/notification-rocketchat) is perfect if you're using RocketChat for team communication. Send notifications to channels or direct messages, complete with attachments and formatting.
The plugin system allows you to add new notification channels easily. You can create a new plugin for a new channel and install it like any other plugin.
The brilliant part is that adding any of these channels to a notification is just adding a string to the via array and implementing one method. Want to add Slack to that OrderShipped notification? Add 'slack' to the array and implement toSlack. Done.
public function via($notifiable): array
{
return ['database', 'broadcast', 'mail', 'slack'];
}
public function toSlack($notifiable): BlockKitMessage
{
return (new BlockKitMessage())
->text('Order Shipped')
->headerBlock('Order Shipped')
->sectionBlock(function ($block) {
$block->text("Order #{$this->order->id} has shipped!");
$block->field("*Customer:*\n{$notifiable->name}");
$block->field("*Tracking:*\n{$this->order->tracking_number}");
});
}
Now when someone's order ships, they get an in-app notification with real-time delivery, an email with full details, and your team gets a Slack message in the orders channel. All automatic.
The Database as Your Notification Store
Every notification sent through the database channel gets stored in a notifications table. This gives you a complete history of what users were notified about and when. The NotifiableBehavior adds methods to your tables for working with notifications.
$user = $usersTable->get($userId);
$unreadNotifications = $usersTable->unreadNotifications($user)->all();
$readNotifications = $usersTable->readNotifications($user)->all();
$usersTable->markNotificationAsRead($user, $notificationId);
$usersTable->markAllNotificationsAsRead($user);
The UI widget uses these methods to display notifications and mark them as read. But you can use them anywhere in your application. Maybe you want to show recent notifications on a user's dashboard. Maybe you want to delete old notifications. The methods are there.
Queuing for Performance
Sending notifications, especially external ones, takes time. Making API calls to Slack, Seven.io, or Pusher adds latency to your request. If you're sending to multiple channels, that latency multiplies.
The solution is queuing. Implement the ShouldQueueInterface on your notification class, and the system automatically queues notification sending as background jobs.
use Crustum\Notification\ShouldQueueInterface;
class OrderShipped extends Notification implements ShouldQueueInterface
{
protected ?string $queue = 'notifications';
}
Now when you call notify, it returns immediately. The actual notification sending happens in a background worker. Your application stays fast, users don't wait, and notifications still get delivered reliably.
Testing Your Notifications
Testing notification systems used to be painful. You'd either send test notifications to real services (annoying) or mock everything (fragile). The NotificationTrait makes testing clean and simple.
use Crustum\Notification\TestSuite\NotificationTrait;
class OrderTest extends TestCase
{
use NotificationTrait;
public function testOrderShippedNotification()
{
$user = $this->Users->get(1);
$order = $this->Orders->get(1);
$user->notify(new OrderShipped($order));
$this->assertNotificationSentTo($user, OrderShipped::class);
$this->assertNotificationSentToChannel('mail', OrderShipped::class);
$this->assertNotificationSentToChannel('database', OrderShipped::class);
}
}
The trait captures all notifications instead of sending them. You can assert that the right notifications were sent to the right users through the right channels. You can even inspect the notification data to verify it contains the correct information.
There are many diferent assertions you can use to test your notifications. You can assert that the right notifications were sent to the right users through the right channels. You can even inspect the notification data to verify it contains the correct information.
Localization
Applications serve users in different languages, and your notifications should respect that. The notification system integrates with CakePHP's localization system.
$user->notify((new OrderShipped($order))->locale('es'));
Even better, users can have a preferred locale stored on their entity. Implement a preferredLocale method or property, and notifications automatically use it.
class User extends Entity
{
public function getPreferredLocale(): string
{
return $this->locale;
}
}
Now you don't even need to specify the locale. The system figures it out automatically and sends notifications in each user's preferred language.
Bringing It Together
What I like about this notification system is how it scales with your needs. Start simple. Just database notifications. Add real-time broadcasting when you want instant delivery. Add email when you need to reach users outside your app. Add Slack when your team wants internal alerts. Add SMS for critical notifications.
Each addition is incremental. You're not rewriting your notification system each time. You're adding channels to the via array and implementing format methods. The core logic stays the same.
The separation between presence notifications and reach-out notifications makes architectural sense. They serve different purposes, use different infrastructure, but share the same interface. This makes your code clean, your system maintainable, and your notifications reliable.
Whether you're building a small application with basic email notifications or a complex system with real-time updates, database history, email, SMS, and team chat integration, you're using the same patterns. The same notification classes. The same notify method.
That consistency is what makes the system powerful. You're not context switching between different notification implementations. You're just describing what should be notified, who should receive it, and how it should be formatted. The system handles the rest.
This article is part of the CakeDC Advent Calendar 2025 (December 8th 2025)