CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

Notifications That Actually Work

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)

Latest articles

Goodbye to 2025!

Well bakers… another advent calendar is coming to an end. I hope you enjoyed all of the topics covered each day. We are also closing the year with so much gratitude.    2025 was the 20th year of CakePHP, can you believe it? We had an amazing year with our team, the community and the CakePHP core. It was great connecting with those who attended CakeFest in Madrid, and we hope to have the opportunity to see more of you in 2026.    I cannot let the year end without getting a little sentimental. There is no better way to say it… THANK YOU. Thank you to the team who worked so hard, the core team that keeps pumping out releases, and most of all … thank you to our clients that trust us with their projects. CakeDC is successful because of the strong relationships we build with our network, and we hope to continue working with all of you for many years.    There are a lot of great things still to come in year 21! Could 2026 will be bringing us CakePHP 6?! Considering 21 is the legal drinking age in the US, maybe CakePHP 6 should be beer cake? Delicious. Stay tuned to find out.    Before I go, I am leaving you with something special. A note from Larry!   As we close out this year, I just want to say thank you from the bottom of my heart. Twenty years ago, CakePHP started as a simple idea shared by a few of us who wanted to make building on the web easier and more enjoyable. Seeing how far it has come, and more importantly, seeing how many lives and careers it has impacted, is something I never take for granted. I am deeply grateful for our team, the core contributors, the community, and our clients who continue to believe in what we do. You are the reason CakePHP and CakeDC are still here, still growing, and still relevant after two decades. Here is to what we have built together, and to what is still ahead. Thank you for being part of this journey. Larry

Pagination of multiple queries in CakePHP

Pagination of multiple queries in CakePHP

A less typical use case for pagination in an appication is the need to paginate multiples queries. In CakePHP you can achieve this with pagination scopes.

Users list

Lest use as an example a simple users list. // src/Controller/UsersController.php class UsersController extends AppController { protected array $paginate = [ 'limit' => 25, ]; public function index() { // Default model pagination $this->set('users', $this->paginate($this->Users)); } } // templates/Users/index.php <h2><?= __('Users list') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= h($user->name) ?></td> <td><?= h($user->email) ?></td> <td><?= $user->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?>

Pagination of multiple queries

Now, we want to display two paginated tables, one with the active users and the other with the inactive ones. // src/Controller/UsersController.php class UsersController extends AppController { protected array $paginate = [ 'Users' => [ 'scope' => 'active_users', 'limit' => 25, ], 'InactiveUsers' => [ 'scope' => 'inactive_users', 'limit' => 10, ], ]; public function index() { $activeUsers = $this->paginate( $this->Users->find()->where(['active' => true]), [scope: 'active_users'] ); // Load an additional table object with the custom alias set in the paginate property $inactiveUsersTable = $this->fetchTable('InactiveUsers', [ 'className' => \App\Model\Table\UsersTable::class, 'table' => 'users', 'entityClass' => 'App\Model\Entity\User', ]); $inactiveUsers = $this->paginate( $inactiveUsersTable->find()->where(['active' => false]), [scope: 'inactive_users'] ); $this->set(compact('users', 'inactiveUsers')); } } // templates/Users/index.php <?php // call `setPaginated` first with the results to be displayed next, so the paginator use the correct scope for the links $this->Paginator->setPaginated($users); ?> <h2><?= __('Active Users') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= h($user->name) ?></td> <td><?= h($user->email) ?></td> <td><?= $user->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?> <?php // call `setPaginated` first with the results to be displayed next, so the paginator use the correct scope for the links $this->Paginator->setPaginated($inactiveUsers); ?> <h2><?= __('Inactive Users') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($inactiveUsers as $inactiveUser): ?> <tr> <td><?= h($inactiveUser->name) ?></td> <td><?= h($inactiveUser->email) ?></td> <td><?= $inactiveUser->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?> And with this you have two paginated tables in the same request.

Clean DI in CakePHP 5.3: Say Goodbye to fetchTable()

This article is part of the CakeDC Advent Calendar 2025 (December 23rd, 2025)

Introduction: The Death of the "Hidden" Dependency

For years, accessing data in CakePHP meant "grabbing" it from the global state. Whether using TableRegistry::getTableLocator()->get() or the LocatorAwareTrait’s $this->fetchTable(), your classes reached out to a locator to find what they needed. While convenient, this created hidden dependencies. A class constructor might look empty, despite the class being secretly reliant on multiple database tables. This made unit testing cumbersome, forcing you to stub the global TableLocator just to inject a mock. CakePHP 5.3 changes the game with Inversion of Control. With the framework currently in its Release Candidate (RC) stage and a stable release expected soon, now is the perfect time to explore these architectural improvements. By using the new TableContainer as a delegate for your PSR-11 container, tables can now be automatically injected directly into your constructors. This shift to explicit dependencies makes your code cleaner, fully type-hinted, and ready for modern testing standards. The Old Way (Hidden Dependency): public function execute() { $users = $this->fetchTable('Users'); // Where did this come from? } The 5.3 Way (Explicit Dependency): public function __construct(protected UsersTable $users) {} public function execute() { $this->users->find(); // Explicit and testable. }

Enabling the Delegate

Open src/Application.php and update the services() method by delegating table resolution to the TableContainer. // src/Application.php use Cake\ORM\TableContainer; public function services(ContainerInterface $container): void { // Register the TableContainer as a delegate $container->delegate(new TableContainer()); }

How it works under the hood

When you type-hint a class ending in Table (e.g., UsersTable), the main PSR-11 container doesn't initially know how to instantiate it. Because you've registered a delegate, it passes the request to the TableContainer, which then:
  1. Validates: It verifies the class name and ensures it is a subclass of \Cake\ORM\Table.
  2. Locates: It uses the TableLocator to fetch the correct instance (handling all the usual CakePHP ORM configuration behind the scenes).
  3. Resolves: It returns the fully configured Table object back to the main container to be injected.
Note: The naming convention is strict. The TableContainer specifically looks for the Table suffix. If you have a custom class that extends the base Table class but is named UsersRepository, the delegate will skip it, and the container will fail to resolve the dependency.

Practical Example: Cleaner Services

Now, your domain services no longer need to know about the LocatorAwareTrait. They simply ask for what they need. namespace App\Service; use App\Model\Table\UsersTable; class UserManagerService { // No more TableRegistry::get() or $this->fetchTable() public function __construct( protected UsersTable $users ) {} public function activateUser(int $id): void { $user = $this->users->get($id); // ... logic } } Next, open src/Application.php and update the services() method by delegating table resolution to the TableContainer. // src/Application.php use App\Model\Table\UsersTable; use App\Service\UserManagerService; use Cake\ORM\TableContainer; public function services(ContainerInterface $container): void { // Register the TableContainer as a delegate $container->delegate(new TableContainer()); // Register your service with the table as constructor argument $container ->add(UserManagerService::class) ->addArgument(UsersTable::class); }

Why this is a game changer for Testing

Because the table is injected via the constructor, you can now swap it for a mock effortlessly in your test suite without touching the global state of the application. $mockUsers = $this->createMock(UsersTable::class); $service = new UserManagerService($mockUsers); // Pure injection!

Conclusion: Small Change, Big Impact

At first glance, adding a single line to your Application::services() method might seem like a minor update. However, TableContainer represents a significant shift in how we approach CakePHP architecture. By delegating table resolution to the container, we gain:
  • True Type-Safety: Your IDE and static analysis tools now recognize the exact Table class being used. This is a massive win for PHPStan users—no more "Call to an undefined method" errors or messy @var docblock workarounds just to prove to your CI that a method exists.
  • Zero-Effort Mocking: Testing a service no longer requires manipulating the global TableRegistry state. Simply pass a mock object into the constructor and move on.
  • Standardization: Your CakePHP code now aligns with modern PHP practices found in any PSR-compliant ecosystem, making your application more maintainable and easier for new developers to understand.
If you plan to upgrade to CakePHP 5.3 upon its release, this is one of the easiest wins for your codebase. It’s time to stop fetching your tables and start receiving them. This article is part of the CakeDC Advent Calendar 2025 (December 23rd, 2025)

We Bake with CakePHP