DEV Community

Cover image for "Notifications without WebSockets: an in-app centre and broadcasts on shared hosting
Dmitry Isaenko
Dmitry Isaenko

Posted on

"Notifications without WebSockets: an in-app centre and broadcasts on shared hosting

Every "add notifications to your Laravel app" tutorial seems to start the same way: install Pusher or Reverb, wire up Echo, maybe spin up Redis. That is a fine setup once you actually need a live feed. But for an in-app notification centre, a bell with an unread badge and an inbox, it is a lot of moving infrastructure for something you can do with a queue and a cron line.

LaraFoundry, the reusable SaaS core I am extracting in public from a CRM that already runs in production, has a hard rule: it must run on plain shared hosting. No mandatory Redis, no Horizon, no long-running daemons. So when I built the notifications module (phase 4.1, tag v0.14.x) I had to deliver a real notification centre and super-admin broadcasts under that constraint. Here is how it went together.

The bell does not need a socket

The unread badge is a poll, not a push. The bell hits a tiny unread-count endpoint on a light interval, and only while the tab is actually visible, so a backgrounded tab goes quiet. The recent list is fetched when you open the dropdown, not on a timer. The full inbox is a normal paginated page.

That is genuinely enough for an in-app centre. A user who has the app open sees their count refresh within a minute, and sees everything the moment they open the bell. Realtime delivery is a nice upgrade to layer on later through the channels seam, but it is not a dependency you need to start with. Shipping the 90 percent that works on any host beats blocking on infrastructure most small SaaS never provision.

Broadcasts that do not block the request

The interesting half is super-admin broadcasts. An operator drafts a message with per-locale text, picks an audience, and sends. The audience filters are the domain-independent ones the core can own: verified users, recently active users, or users holding a given RBAC role. Demographic targeting (country, age) stays in the host, where that data lives.

The naive version of "send to everyone" attaches every matched user in one synchronous insert inside the request. On a large user base that blocks the request and balloons memory. So the send queues a job, resolves the audience from the stored filters, walks it in id-ordered chunks, and inserts each chunk with insertOrIgnore.

// A broadcast fans out to its audience, queued and chunked. insertOrIgnore keeps a retried job idempotent, no giant insert.
$this->recipientQuery($notification)
    ->select('id')
    ->chunkById(1000, function ($users) use ($notification) {
        DB::table('larafoundry_notification_user')->insertOrIgnore(
            $users->map(fn ($user) => [
                'notification_id' => $notification->id,
                'user_id' => $user->id,
                'created_at' => now(),
            ])->all()
        );
    });
Enter fullscreen mode Exit fullscreen mode

Two details matter here. insertOrIgnore against a unique (notification_id, user_id) index means a retried job can never double-deliver. And the job only acts while the broadcast is in a sending state: once it finishes and flips to sent, a retry returns early instead of re-firing the audited "broadcast sent" event. The fan-out is idempotent, and so is the audit trail. It all runs on the database queue, so a single queue:work under cron is the only moving part.

A notification is content someone else wrote

This is the part I cared about most. A broadcast body and a system notification both end up as stored text that gets rendered into a user's browser. That is attacker-adjacent input, so I treated it that way.

Titles and bodies render as text, never v-html. A notification can carry actions, but each action is reduced to an internal, same-origin GET link before it ever reaches the frontend: a single leading slash, never a protocol-relative //host, never a backslash that a browser would normalise back off-site. The HTTP method is dropped, so a stored action can never drive a POST or a DELETE or bounce you to another origin.

And the inbox itself is scoped. Every read and every mutation goes through the user's own relation, so asking for another user's notification id simply finds nothing and returns a 404. No "mark as read" on a row you do not own.

The host pushes notifications through one seam

Your application should not write notification rows by hand. It calls one service, with translation keys instead of baked strings, so the message localises per recipient at read time.

// Send an in-app notification from your own domain event. Wording is translation keys, localised per recipient.
app(NotificationService::class)->system(
    users: $company->users,
    code: 'success',
    titleKey: 'Welcome to :company',
    params: ['company' => $company->name],
);
Enter fullscreen mode Exit fullscreen mode

In the host I wired this to a domain event: when a company is created, its owner gets a welcome notification. The whole integration on the host side is small. Add a trait to the user model for the inbox relation, run the migration, publish the pages, and the bell shows up in the header because it ships in the core layout. Sending from your own events is the six lines above.

Proof, not vibes

None of this ships on faith. The module is covered with Pest on the backend and Vitest on the frontend, both green before the tag went out, plus the inbox scoping, the IDOR check, the broadcast fan-out and the super-admin exclusion are all asserted end to end against a real tenancy in the host app. The XSS-escaping of titles and bodies has its own frontend tests. Test count went up by a few dozen on this phase alone.

Building in public means the green suite is part of the story, not a footnote.

Follow along

Top comments (0)