gupalo/browser-notifier
gupalo/browser-notifier is a Laravel package that simplifies sending browser notifications from your application, letting you alert users with native-style popups. It provides a straightforward API for triggering notifications and integrating them into your existing Laravel workflows.
gupalo/browser-notifier is a thin Laravel bridge over Symfony Notifier's Browser transport, which pushes messages to open browser tabs through a Mercure hub. You are not sending Web Push / VAPID notifications to closed tabs — a live EventSource connection is required. Set expectations accordingly.
Minimal setup:
composer require gupalo/browser-notifier
php artisan vendor:publish --tag=browser-notifier-config # if the provider ships a config
You need a Mercure hub. Locally, the Docker image is the fastest path:
docker run -d --name mercure -p 3000:80 \
-e MERCURE_PUBLISHER_JWT_KEY='!ChangeMe!' \
-e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeMe!' \
dunglas/mercure
Then wire the env vars the package expects (names vary by version — confirm against config/browser-notifier.php):
MERCURE_URL=http://localhost:3000/.well-known/mercure
MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure
MERCURE_JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... # publisher JWT
First use case — tell a user in an already-open tab that a queued export finished:
use Illuminate\Notifications\Notification;
class ExportReady extends Notification
{
public function via($notifiable): array
{
return ['browser'];
}
public function toBrowser($notifiable)
{
return \Gupalo\BrowserNotifier\BrowserMessage::create('Export ready')
->topic("users/{$notifiable->id}/exports");
}
}
$user->notify(new ExportReady($export));
Frontend side, subscribe to the topic and hand the payload to the Notification API:
const es = new EventSource(
`${mercureUrl}?topic=${encodeURIComponent(`users/${userId}/exports`)}`,
{ withCredentials: true }
);
es.onmessage = (e) => new Notification('Export ready', JSON.parse(e.data));
Where to look first in the repo: the service provider (how the Symfony Notifier / BrowserTransport is bound and how config maps to it), the channel class registered with Laravel's Notification::extend or a via() alias, and the BrowserMessage-style DTO if one exists. If the package is a straight passthrough, Symfony's BrowserTransport / BrowserOptions docs are your real reference.
Custom notification channel. The idiomatic integration is a Laravel channel aliased to 'browser' (or the fully-qualified class). Once registered you get the normal notification ergonomics — queueing, via() switching, per-user settings — for free:
public function via($notifiable): array
{
return $notifiable->wants_browser_alerts ? ['browser', 'mail'] : ['mail'];
}
Topic strategy is the whole design. Mercure delivers by topic, so pick a scheme early and treat it as an API:
users/{id}/notifications — per-user, private (requires a subscriber JWT or signed cookie)orders/{id} — per-entity, good for collaborative UIsbroadcast — public announcements, no authPrefer per-user private topics over a global topic filtered in JS; filtering in the browser leaks other users' payloads.
Queue it. Almost every real send happens after a slow operation (export, webhook, payment). Implement ShouldQueue on the notification or dispatch from a job listener. Symfony's transport is a synchronous HTTP call to the hub, so an unresponsive hub will stall the request otherwise.
Compose with other channels. Browser notifications should be additive, not replacing email/database. A common pattern: via() returns ['database', 'browser'] so the in-app bell and the live toast stay consistent, both built from the same notification class.
Direct service use. For non-user events (system alerts, ops dashboards), bypass the notification system and resolve the transport/service from the container. This is worth having a small wrapper for, so the rest of your app never imports Symfony\Component\Notifier types.
Testing. Laravel's Notification::fake() records the notification object regardless of channel, so you can assert assertSentTo without hitting Mercure. For end-to-end confidence, bind a fake transport in the container in your test case and assert on the published topic/payload. Do not let CI talk to a real hub.
try/catch or a queue with retries; a dead hub should never fail a business transaction.jwt option only covers the publisher side. If nothing arrives, check the browser network tab for 401 on the /.well-known/mercure request before touching PHP.Notification API requires a secure context. localhost is exempt; any non-HTTPS staging host silently no-ops. Permission must also be requested from a user gesture — requesting on page load gets you a permanently denied prompt in Chrome.JsonSerializable, expect either a throw or a nested data blob on the client. Send scalars and primitive arrays; hydrate on the client if needed.SerializesModels; avoid closures and resources. Classic Serialization of 'Closure' is not allowed if you slip one in.{env}/users/{id}/...) or run separate hubs per environment.Notification::fake() does not assert channel-internal behaviour. It swallows the channel; it won't tell you the payload was malformed. Add a thin integration test against a fake transport if the hub contract matters to you.composer.json and keep the integration surface small — ideally one class in your app that calls this package, so replacing it with a hand-rolled BrowserTransport wrapper (Symfony Notifier is already a first-class Composer package) is a one-file change.BrowserOptions (topic, level, etc.) is where most customisation lives. If the package doesn't expose an option you need, subclass the channel rather than forking; the transport configuration is just DI.EventSource reconnects automatically but drops messages sent while disconnected. For anything you can't afford to miss, pair the live toast with a durable database notification and a poll-on-reconnect.How can I help you explore Laravel packages today?