DEV Community

Cover image for Building a Dual Notification System for a Multi-Tenant Laravel SaaS
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Dual Notification System for a Multi-Tenant Laravel SaaS

Your SaaS needs to talk to users. Not just email - in-app notifications that persist, track reads, support multiple languages, and let admins target specific user segments.

I built Kohana.io - a production CRM/ERP for small businesses. The notification module evolved into a dual-architecture system: admin broadcasts and automated system notifications sharing the same table, the same UI, and the same read-tracking mechanism.

Now I'm extracting it into LaraFoundry, an open-source SaaS framework for Laravel. This post covers the full implementation.

Table of Contents

  1. Dual Architecture
  2. Database Schema
  3. Multilingual Content
  4. Recipient Segmentation
  5. Admin CRUD & Workflow
  6. System Notifications
  7. Delivery & Read Statistics
  8. Frontend UX
  9. Testing
  10. Design Decisions

Dual Architecture

LaraFoundry has two notification types that coexist in one system:

Admin notifications - created manually in the admin panel:

  • Multilingual titles and bodies stored as JSON in the database
  • Recipient filters: country, sex, age range, registration date, activity level, verification status
  • Draft → Sent workflow with visibility scheduling
  • Delivery and read statistics

System notifications - created programmatically by queued jobs:

  • Laravel translation keys with dynamic parameters
  • Auto-sent when events occur (company created, invitation accepted, payment failed)
  • Auto-expire after 30 days
  • Rich metadata for UI actions

Both types appear in the same user notification panel. The user doesn't know or care where a notification came from.


Database Schema

Notifications Table

Schema::create('notifications', function (Blueprint $table) {
    $table->id();
    $table->string('code')->index();
    $table->enum('notification_type', ['admin', 'system']);
    $table->enum('status', ['draft', 'sent']);

    // system notifications - translation keys
    $table->string('title_key')->nullable();
    $table->string('body_key')->nullable();
    $table->json('params')->nullable();

    // admin notifications - stored translations
    $table->json('title_translations')->nullable();
    $table->json('body_translations')->nullable();

    // admin notifications - targeting
    $table->json('recipient_filters')->nullable();
    $table->json('data')->nullable();

    // scheduling
    $table->timestamp('visible_from')->nullable();
    $table->timestamp('visible_until')->nullable();
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

One table with two sets of nullable columns. The notification_type enum determines which set is used. This is simpler than two tables because the user-facing side doesn't care about the source.

Notification-User Pivot

Schema::create('notification_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('notification_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->timestamp('read_at')->nullable()->index();
    $table->timestamps();
    $table->unique(['notification_id', 'user_id']);
});
Enter fullscreen mode Exit fullscreen mode

read_at is nullable. Null = unread. Timestamp = read (and you know exactly when). The unique constraint prevents duplicate attachments.

Model

class Notification extends Model
{
    use HasFactory;

    protected function casts(): array
    {
        return [
            'params' => 'array',
            'recipient_filters' => 'array',
            'title_translations' => 'array',
            'body_translations' => 'array',
            'data' => 'array',
            'visible_from' => 'datetime',
            'visible_until' => 'datetime',
        ];
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withPivot('read_at')
            ->withTimestamps();
    }

    // Scopes
    public function scopeDraft($q) { $q->where('status', 'draft'); }
    public function scopeSent($q) { $q->where('status', 'sent'); }
    public function scopeAdmin($q) { $q->where('notification_type', 'admin'); }
    public function scopeSystem($q) { $q->where('notification_type', 'system'); }

    // Helpers
    public function isDraft(): bool { return $this->status === 'draft'; }
    public function isSent(): bool { return $this->status === 'sent'; }
    public function isAdmin(): bool { return $this->notification_type === 'admin'; }
    public function isSystem(): bool { return $this->notification_type === 'system'; }
}
Enter fullscreen mode Exit fullscreen mode

Multilingual Content

The same getLocalizedTitle() method handles both notification types:

public function getLocalizedTitle(string $locale = 'en'): string
{
    if ($this->isSystem()) {
        return __($this->title_key, $this->params ?? [], $locale);
    }

    return $this->title_translations[$locale]
        ?? $this->title_translations['en']
        ?? '';
}
Enter fullscreen mode Exit fullscreen mode

System notifications use Laravel's __() helper. Translation files + parameters. Standard.

Admin notifications use JSON lookup with fallback: requested locale → English → empty string.

Auto-Translate in Admin Panel

The admin creation form has a language accordion. English is required. Other languages are optional. Each language tab has a "Translate" button:

POST /admin/translate
{
    "source_locale": "en",
    "text": "System maintenance on Friday",
    "target_locales": ["uk", "de"]
}
Enter fullscreen mode Exit fullscreen mode

One click fills all empty language fields. The admin reviews and adjusts. Only empty fields are populated - already-filled translations are preserved.

The body field supports up to 10,000 characters per language.


Recipient Segmentation

When creating an admin notification, the admin configures who receives it:

Filter Options
Country From available_countries config
Sex Male / Female
Age range 16 - 100
Registration date All / Today / This month / This year
Recent activity All / More active / Less active
Email verified All / Verified / Unverified
Phone verified All / Verified / Unverified

The form shows a live user count that updates as filters change:

Recipients matching filters: 847 users
Enter fullscreen mode Exit fullscreen mode

Debounced at 500ms. The submit button is disabled if count is 0.

Filter Implementation

Filters are stored as JSON in recipient_filters and applied via AdminUsersFilter:

public function send(
    Notification $notification,
    AdminUsersFilter $filter
): RedirectResponse
{
    DB::transaction(function () use ($notification, $filter) {
        $users = User::query()
            ->filter($filter)
            ->where('email', '!=', config('own.admin_email'))
            ->pluck('id');

        $notification->users()->attach(
            $users->mapWithKeys(fn($id) => [
                $id => ['created_at' => now(), 'updated_at' => now()]
            ])
        );

        $notification->update(['status' => 'sent']);
    });
}
Enter fullscreen mode Exit fullscreen mode

The HasUserFilterRules trait provides shared validation rules, reusable across any feature that needs user targeting.


Admin CRUD and Workflow

Routes

GET    /admin/notifications               → index
GET    /admin/notifications/create         → create
POST   /admin/notifications                → store (draft)
GET    /admin/notifications/{id}/edit      → edit
PUT    /admin/notifications/{id}           → update
DELETE /admin/notifications/{id}           → destroy
POST   /admin/notifications/{id}/send      → send
Enter fullscreen mode Exit fullscreen mode

Workflow

  1. Create - Admin fills form, notification saved as draft
  2. Edit - Modify translations, filters, scheduling while in draft
  3. Send - Apply filters, attach matching users, set status to 'sent'
  4. Resend - Re-run filters on already-sent notification, attach new matches
  5. Delete - Detach all users, delete notification (transaction-wrapped)

The form request validates everything:

class AdminNotificationStoreRequest extends FormRequest
{
    use HasUserFilterRules;

    public function rules(): array
    {
        $rules = [
            'code' => ['required', 'string', Rule::in(
                array_keys(config('own.notification_types'))
            )],
            'title_translations.en' => 'required|string|max:255',
            'visible_from' => 'nullable|date',
            'visible_until' => 'nullable|date',
        ];

        foreach (config('app.available_languages') as $locale => $name) {
            if ($locale !== 'en') {
                $rules["title_translations.{$locale}"] = 'nullable|string|max:255';
            }
            $rules["body_translations.{$locale}"] = 'nullable|string|max:10000';
        }

        return array_merge($rules, $this->userFilterRules());
    }
}
Enter fullscreen mode Exit fullscreen mode

English title required. Everything else optional. Filter rules mixed in from the trait.


System Notifications

System notifications are created by queued jobs when events happen:

class NotifyEmployeeAboutRejection implements ShouldQueue
{
    public function handle(): void
    {
        $notification = Notification::create([
            'code' => 'invitation_rejected_employee_' . $this->id,
            'notification_type' => 'system',
            'status' => 'sent',
            'title_key' => 'Invitation Rejected',
            'body_key' => 'You declined the invitation to join :company.',
            'params' => ['company' => $this->company->name],
            'visible_from' => now(),
            'visible_until' => now()->addDays(
                config('own.system_notification_lifetime_days')
            ),
        ]);

        $notification->users()->attach($this->user->id);

        $this->user->notify(
            new InvitationRejectedEmployeeNotification(...)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern: create → attach → optionally email. Unique code prevents duplicates.

System Notification Triggers

Event Notification
Company created Owner gets in-app + email
Company deleted Owner gets in-app + email
Invitation sent Invitee gets email (+ in-app if registered)
Invitation accepted Owner notified
Invitation rejected Both sides notified differently
Employee removed Employee notified
Payment success Owner notified
Payment failed Owner notified
Admin login attempt Admin gets email + Telegram

Each notification auto-expires after 30 days (system_notification_lifetime_days config). Old notifications disappear naturally without cleanup jobs.


Delivery and Read Statistics

The admin panel shows per-notification statistics:

| Type    | Title                      | Received | Read         | Status    |
|---------|----------------------------|----------|--------------|-----------|
| info    | System maintenance Friday  | 847      | 312 (36.8%)  | Active    |
| warning | New pricing March 1        | 1,204    | 891 (74.0%)  | Scheduled |
| info    | Welcome to v2.0            | 2,100    | 2,034 (96.9%)| Expired   |
Enter fullscreen mode Exit fullscreen mode

All computed from the pivot table:

// AdminNotificationResource
'total_recipients' => $this->users->count(),
'read_count' => $this->users->whereNotNull('pivot.read_at')->count(),
'read_percentage' => $total > 0
    ? round(($read / $total) * 100, 1)
    : 0,
Enter fullscreen mode Exit fullscreen mode

Visibility Status

Computed from timestamps, displayed as a colored badge:

'visibility_status' => match(true) {
    $this->visible_until?->isPast() => 'expired',
    $this->visible_from?->isFuture() => 'scheduled',
    default => 'active',
},
Enter fullscreen mode Exit fullscreen mode

Drafts show "Not sent yet" instead of statistics. The can_send flag controls whether the send button appears.


Frontend UX

User Notification Panel

The notification page shows both admin and system notifications in a unified list:

  • Header: "Notifications (3 unread)" + "Mark all as read" button
  • List: Paginated (25 per page), each item expandable
  • Item: Type badge, title, timestamp, read indicator
  • Expand: Body text appears, auto-marks as read
// NotificationItem.vue
const toggle = () => {
    expanded.value = !expanded.value;
    if (expanded.value && !notification.read_at) {
        markAsRead(notification.id);
    }
};
Enter fullscreen mode Exit fullscreen mode

Real-Time Unread Count

Polling every 30 seconds updates the notification bell in the header:

GET /notifications/unread  { unread: true, count: 4 }
Enter fullscreen mode Exit fullscreen mode

Synced to a global store. No WebSockets needed - polling is simple and reliable for non-chat notifications.

Bulk Operations

"Mark all as read" uses a direct DB update:

$count = $request->user()
    ->notifications()
    ->wherePivot('read_at', null)
    ->update(['notification_user.read_at' => now()]);
Enter fullscreen mode Exit fullscreen mode

One query regardless of notification count.

User API Endpoints

GET  /notifications              → paginated list
GET  /notifications/unread       → unread count
GET  /notifications/unread-recent → last 5 unread (for dropdown)
GET  /notifications/recent       → last 5 all (for widget)
POST /notifications/{id}/read    → mark single
POST /notifications/mark-all-read → mark all
Enter fullscreen mode Exit fullscreen mode

Admin Creation Form

Three sections:

  1. Main info: notification type (info/warning), visible_from, visible_until
  2. Translations: language accordion with auto-translate button
  3. Recipients: filter form with live user count

The form is disabled for sent notifications except resend action.


Testing

Notifications get tested end-to-end with Pest:

CRUD

test('admin can create a draft notification', function () {
    actingAs($admin)
        ->post(route('admin.notifications.store'), [
            'code' => 'info',
            'title_translations' => ['en' => 'Test notification'],
            'body_translations' => ['en' => 'Test body'],
            // ... filter defaults
        ])
        ->assertRedirect(route('admin.notifications.index'));

    expect(Notification::first()->status)->toBe('draft');
});
Enter fullscreen mode Exit fullscreen mode

Segmentation

test('only matching users receive notification', function () {
    User::factory()->create(['country' => 'DE']);
    User::factory()->create(['country' => 'DE']);
    User::factory()->create(['country' => 'US']);

    $notification = Notification::factory()->create([
        'recipient_filters' => ['country' => 'DE'],
    ]);

    actingAs($admin)->post(route('admin.notifications.send', $notification));

    expect($notification->users)->toHaveCount(2);
});
Enter fullscreen mode Exit fullscreen mode

Read Tracking

test('expanding notification marks it as read', function () {
    actingAs($user)->post(route('notifications.read', $notification));

    expect($user->notifications()->first()->pivot->read_at)
        ->not->toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

System Notifications

test('rejection job creates notification and sends email', function () {
    NotifyEmployeeAboutRejection::dispatch($user, $company);

    expect(Notification::system()->count())->toBe(1);
    Mail::assertSent(InvitationRejectedEmployeeNotification::class);
});
Enter fullscreen mode Exit fullscreen mode

Real requests. Real database. No mocking of notification creation.


Design Decisions

Why One Table Instead of Two?

Admin and system notifications have different creation flows but identical consumption. The user sees a list of notifications - they don't care about the source. One table means one query, one resource, one component. The notification_type enum + nullable columns is simpler than polymorphism for this use case.

Why JSON Translations Instead of a Translations Table?

Admin notifications are write-once. Draft, translate, send, done. No one edits translations after sending. A flat JSON column avoids the complexity of a polymorphic translations table for something that's essentially immutable after creation.

Why Polling Instead of WebSockets?

Notifications aren't chat messages. A 30-second delay is acceptable. Polling is simpler to implement, deploy, and debug. No WebSocket server to maintain. No connection management. If the latency requirements change, switching to WebSockets is straightforward - the API endpoints stay the same.

Why Auto-Mark-as-Read on Expand?

Forcing users to click a separate "mark as read" button adds friction without adding value. If you expanded a notification to read it, you read it. The timestamp records when, which is useful for analytics.

Why Exclude Admin From Recipients?

The admin who creates and sends notifications doesn't need to receive their own broadcasts. Excluding config('own.admin_email') prevents self-notification without adding a "skip creator" flag to the schema.


What's Next

This module is part of LaraFoundry - an open-source SaaS framework for Laravel, extracted from a production CRM/ERP.

GitHub: github.com/dmitryisaenko/larafoundry
Previous modules: Registration, Authentication, Multi-Tenancy, Logging, Multilanguage, Navigation, Vue Frontend, Traits & Middlewares, Admin Users, Admin Companies
Follow for the next module deep-dive.
LaraFoundry: larafoundry.com

Top comments (0)