Laravel 5.3 shipped a notifications layer that finally gave us a single, consistent way to alert users across channels instead of hand-rolling mail classes and ad hoc database inserts for every event in an application.
Why we switched
On a recent booking platform we had three different code paths sending emails: one from a service class, one from a queued job, and one straight out of a controller. None of them logged anything for the in-app notification bell, which meant the product team had no reliable way to answer a simple question like "did this user get told their booking was confirmed?" without grepping mail server logs by hand. Worse, two of the three paths formatted the confirmation email slightly differently, a discrepancy nobody noticed until a client pointed it out during a demo. Laravel's `Notification` classes let us define the event once and choose which channels it goes out on, with the actual delivery logic living in exactly one place regardless of how many parts of the app trigger it.
What the setup looked like
- A `Notification` class per event type (`BookingConfirmed`, `PaymentFailed`, `ReminderDue`), each one representing a single meaningful thing that happened in the application rather than a specific delivery mechanism
- The `via()` method returning `['mail', 'database']` depending on user preference, read from a simple notification-settings table rather than hardcoded per notification class
- Queued notifications for anything that hits an external mail provider, so a slow SMTP response never blocks a request — this mattered more than we expected once we noticed how often our previous synchronous mail sending was adding two or three hundred milliseconds to otherwise fast endpoints
- A simple `notifications` relationship on the `User` model for the notification bell in the dashboard, backed by the `database` channel's own migration that ships with the framework
- A shared base template for the mail channel so every transactional email has the same header, footer, and unsubscribe language, rather than each notification class reinventing its own markup
How the database channel actually works
The part of this release that impressed us most was how little scaffolding the database channel needs. Laravel ships a migration that creates a `notifications` table with a polymorphic `notifiable` relationship, a `type` column recording which notification class produced the row, and a `data` JSON column holding whatever payload the notification's `toDatabase()` method returns. Because the relationship is polymorphic, the same table can back notifications for users, admin accounts, or any other model in the application without a second table or a second set of queries — something we had previously solved with a bespoke `activity_log` table that only half-fit the notification bell's actual requirements.
Rendering the bell itself became a short Blade partial that eager-loads `unreadNotifications`, loops over the polymorphic `data` payload, and marks items read on click through a small AJAX call to a controller action Laravel provides a sensible default for. None of this is complicated, but it replaced code we had previously written slightly differently on three separate projects, each with its own quiet bugs around marking things read or paginating the list correctly.
Results
The database channel alone removed close to two hundred lines of bespoke logging code that existed purely to power the in-app notification bell, and it removed an entire category of bug where the bell and the actual email fell out of sync because they were two independent code paths reacting to the same event.
Because everything funnels through the same `Notification` classes, adding SMS later (we are eyeing Nexmo) should be a matter of adding a new channel method rather than rewiring the whole flow. We prototyped this on one notification class as a proof of concept — implementing a `toNexmo()` method that returns a simple message object — and had it sending real text messages within an afternoon, which is a good sign for how cleanly the abstraction generalizes to channels the framework does not ship out of the box.
Handling failures and retries
One area that needed more care than the basic tutorial examples suggest is failure handling. A queued notification that fails — an SMTP timeout, a malformed recipient address — goes into Laravel's `failed_jobs` table by default, but that alone does not tell anyone that a customer never got their booking confirmation. We added a small listener on the `NotificationFailed` event that logs a structured error and, for anything tagged as business-critical (payment and booking notifications specifically), pages an on-call developer through our existing alerting setup rather than letting it sit quietly in a database table nobody checks daily.
We also had to think carefully about idempotency. A queued job that fails partway through and gets retried should not send the same email twice, so any notification with an external side effect now checks a `sent_at` timestamp before dispatching, rather than trusting the queue's own retry semantics to behave exactly once.
A couple of things we would tell a team adopting this for the first time
- Keep the notification classes themselves free of business logic — they should describe what to send, not decide whether to send it. That decision belongs upstream, in the code that dispatches the notification, which keeps the notification classes simple enough to unit test in isolation
- Use `shouldSend()` or an equivalent guard if a notification might fire for a user who has opted out of that channel entirely, rather than relying on `via()` alone to filter channels, since `via()` runs after the decision to notify has already been made
- Test notifications with Laravel's built-in fake notification assertions rather than mocking mail transport by hand — it is far less brittle and reads more clearly in a test suite, and it caught a regression for us within the first week where a refactor had accidentally dropped the database channel from one notification's `via()` array
- Rate-limit anything that could plausibly fire in a tight loop, like a reminder notification tied to a scheduled job — we learned this the hard way when a bug in a date calculation briefly queued the same reminder notification for the same booking dozens of times before we caught it in a staging environment
If you are still hand-rolling notification logic in a Laravel app, this is one of the more immediately useful upgrades in the 5.3 release, and it is the kind of change that quietly prevents a whole category of "why didn't the customer get an email" support ticket before it ever happens. We have since rolled the same pattern out to two other client projects on Laravel, and both migrations took under a day each, which says something about how well-scoped this feature is.
Notification preferences, and where that logic actually lives
One question that comes up on every project adopting this pattern is where user notification preferences should be read from, and we settled on keeping that logic entirely outside the notification classes themselves. A `NotificationPreferences` service resolves, for a given user and event type, which channels are actually enabled, and the code that dispatches a notification consults that service before calling `Notification::send()` at all, rather than sending unconditionally and hoping `via()` filters correctly. This keeps the notification classes themselves dumb and easily testable, and it means preference logic — which tends to accumulate business rules over time, like "always send payment failures via SMS regardless of user preference" — lives in exactly one place rather than being scattered across `via()` methods on a dozen different notification classes.
Migrating existing mail classes without a big-bang rewrite
We did not rewrite every existing mail-sending code path on day one. Instead we picked the notifications with the clearest business value — anything touching money or a booking's status — and migrated those first, leaving lower-priority transactional emails on the old ad hoc approach until we had a chance to get to them. This staged migration meant the team could build confidence with the new pattern on the notifications that mattered most, and it meant a bug in the new approach would show up on a well-monitored code path rather than surfacing quietly on an obscure email nobody was watching closely. Roughly two months in, we have migrated all but a handful of low-traffic administrative emails, and we expect to finish the migration within the quarter.
What this means for future projects
Because the pattern generalizes so cleanly across channels, we have started treating "does this event need to notify someone" as a design question we ask explicitly during a project's initial planning, rather than something that gets bolted on ad hoc as features ship. Sketching out the notification classes a new feature will need, before writing any of the feature's core logic, has already caught a couple of cases early where a client assumed a notification would go out automatically that nobody had actually planned to build, which is a much cheaper conversation to have during planning than after launch.