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
- Dual Architecture
- Database Schema
- Multilingual Content
- Recipient Segmentation
- Admin CRUD & Workflow
- System Notifications
- Delivery & Read Statistics
- Frontend UX
- Testing
- 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();
});
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']);
});
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'; }
}
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']
?? '';
}
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"]
}
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
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']);
});
}
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
Workflow
- Create - Admin fills form, notification saved as draft
- Edit - Modify translations, filters, scheduling while in draft
- Send - Apply filters, attach matching users, set status to 'sent'
- Resend - Re-run filters on already-sent notification, attach new matches
- 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());
}
}
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(...)
);
}
}
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 |
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,
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',
},
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);
}
};
Real-Time Unread Count
Polling every 30 seconds updates the notification bell in the header:
GET /notifications/unread → { unread: true, count: 4 }
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()]);
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
Admin Creation Form
Three sections:
- Main info: notification type (info/warning), visible_from, visible_until
- Translations: language accordion with auto-translate button
- 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');
});
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);
});
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();
});
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);
});
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)