Laravel Backend Setup
On this page
Table of Contents#
- Overview
- Prerequisites
- Database Setup
- Models
- Notification Class
- API Controllers
- API Routes
- OneSignal Push Integration
- Socket Delivery (Broadcast)
- Sending Notifications
- API Contract Reference
- Testing
Overview#
Magic Notifications works with any backend that provides REST API endpoints. This guide shows how to implement the notification backend using Laravel's built-in notification system — the recommended approach for Laravel-based projects.
Prerequisites#
- Laravel 10.x or higher
- PHP 8.1+
- Composer
Database Setup#
Notifications Table#
Laravel provides a built-in artisan command:
php artisan make:notifications-table
php artisan migrate
This creates the standard notifications table:
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| type | string | Notification class name |
| notifiable_type | string | Model type (e.g., App\Models\User) |
| notifiable_id | bigint | Model ID |
| data | json | Notification payload |
| read_at | timestamp | When marked as read (null = unread) |
| created_at | timestamp | Creation time |
| updated_at | timestamp | Last update time |
Notification Preferences Table#
php artisan make:migration create_notification_preferences_table
return new class extends Migration
{
public function up(): void
{
Schema::create('notification_preferences', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->boolean('push_enabled')->default(true);
$table->boolean('email_enabled')->default(true);
$table->boolean('in_app_enabled')->default(true);
$table->json('type_preferences')->nullable();
$table->timestamps();
$table->unique('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('notification_preferences');
}
};
php artisan migrate
Models#
User Model#
Ensure your User model uses the Notifiable trait:
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
public function notificationPreference()
{
return $this->hasOne(NotificationPreference::class);
}
}
NotificationPreference Model#
php artisan make:model NotificationPreference
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationPreference extends Model
{
protected $fillable = [
'user_id',
'push_enabled',
'email_enabled',
'in_app_enabled',
'type_preferences',
];
protected $casts = [
'push_enabled' => 'boolean',
'email_enabled' => 'boolean',
'in_app_enabled' => 'boolean',
'type_preferences' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Notification Class#
php artisan make:notification MonitorDownNotification
namespace App\Notifications;
use App\Models\Monitor;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class MonitorDownNotification extends Notification
{
use Queueable;
public function __construct(
public Monitor $monitor
) {}
public function via(object $notifiable): array
{
$channels = [];
$prefs = $notifiable->notificationPreference;
if (!$prefs || $prefs->in_app_enabled) {
$channels[] = 'database';
}
if (!$prefs || $prefs->email_enabled) {
$channels[] = 'mail';
}
return $channels;
}
public function toArray(object $notifiable): array
{
return [
'title' => 'Monitor Down',
'body' => "Your monitor '{$this->monitor->name}' is not responding.",
'action_url' => "/monitors/{$this->monitor->id}",
'monitor_id' => $this->monitor->id,
];
}
public function databaseType(object $notifiable): string
{
return 'monitor_down';
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Monitor Alert: ' . $this->monitor->name)
->greeting('Hello!')
->line("Your monitor '{$this->monitor->name}' is currently down.")
->action('View Monitor', url("/monitors/{$this->monitor->id}"))
->line('We will notify you when it recovers.');
}
}
API Controllers#
NotificationController#
php artisan make:controller Api/V1/NotificationController
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$notifications = $request->user()
->notifications()
->paginate($request->input('per_page', 15));
return response()->json([
'data' => $notifications->map(fn ($n) => [
'id' => $n->id,
'type' => $n->type,
'data' => $n->data,
'read_at' => $n->read_at?->toIso8601String(),
'created_at' => $n->created_at->toIso8601String(),
]),
'meta' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(),
'total' => $notifications->total(),
],
]);
}
public function unreadCount(Request $request): JsonResponse
{
$count = $request->user()->unreadNotifications()->count();
return response()->json(['count' => $count]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->findOrFail($id);
$notification->markAsRead();
return response()->json(['message' => 'Notification marked as read']);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['message' => 'All notifications marked as read']);
}
public function destroy(Request $request, string $id): JsonResponse
{
$request->user()
->notifications()
->findOrFail($id)
->delete();
return response()->json(['message' => 'Notification deleted']);
}
}
NotificationPreferenceController#
php artisan make:controller Api/V1/NotificationPreferenceController
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\NotificationPreference;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class NotificationPreferenceController extends Controller
{
public function show(Request $request): JsonResponse
{
$prefs = $request->user()->notificationPreference
?? NotificationPreference::create(['user_id' => $request->user()->id]);
return response()->json([
'data' => [
'push_enabled' => $prefs->push_enabled,
'email_enabled' => $prefs->email_enabled,
'in_app_enabled' => $prefs->in_app_enabled,
'type_preferences' => $prefs->type_preferences,
],
]);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'push_enabled' => 'sometimes|boolean',
'email_enabled' => 'sometimes|boolean',
'in_app_enabled' => 'sometimes|boolean',
'type_preferences' => 'sometimes|array',
]);
$prefs = $request->user()->notificationPreference
?? new NotificationPreference(['user_id' => $request->user()->id]);
$prefs->fill($validated);
$prefs->save();
return response()->json([
'message' => 'Preferences updated',
'data' => [
'push_enabled' => $prefs->push_enabled,
'email_enabled' => $prefs->email_enabled,
'in_app_enabled' => $prefs->in_app_enabled,
'type_preferences' => $prefs->type_preferences,
],
]);
}
}
API Routes#
In routes/api/v1.php:
use App\Http\Controllers\Api\V1\NotificationController;
use App\Http\Controllers\Api\V1\NotificationPreferenceController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::get('notifications', [NotificationController::class, 'index']);
Route::get('notifications/unread-count', [NotificationController::class, 'unreadCount']);
Route::post('notifications/{id}/read', [NotificationController::class, 'markAsRead']);
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead']);
Route::delete('notifications/{id}', [NotificationController::class, 'destroy']);
Route::get('notification-preferences', [NotificationPreferenceController::class, 'show']);
Route::put('notification-preferences', [NotificationPreferenceController::class, 'update']);
});
OneSignal Push Integration#
For push notifications via OneSignal, install the Laravel notification channel:
composer require laravel-notification-channels/onesignal
composer require berkayk/onesignal-laravel
php artisan vendor:publish --provider="Berkayk\OneSignal\OneSignalServiceProvider"
Add to .env:
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_REST_API_KEY=your-onesignal-rest-api-key
Update Notification Class#
Add the OneSignal channel to your notification:
use NotificationChannels\OneSignal\OneSignalChannel;
use NotificationChannels\OneSignal\OneSignalMessage;
public function via(object $notifiable): array
{
$channels = ['database'];
$prefs = $notifiable->notificationPreference;
if (!$prefs || $prefs->push_enabled) {
$channels[] = OneSignalChannel::class;
}
return $channels;
}
public function toOneSignal(object $notifiable): OneSignalMessage
{
return OneSignalMessage::create()
->setSubject('Monitor Down')
->setBody("Your monitor '{$this->monitor->name}' is not responding.")
// The destination your app routes from. setData writes
// additionalData, which is what arrives as `event.data` on a click,
// and `url` is the first key magic_deeplink's handler looks for.
// Without this line a tapped push carries no destination at all.
->setData('url', "/monitors/{$this->monitor->id}")
->setData('monitor_id', $this->monitor->id);
}
Route User to OneSignal#
Add to your User model:
/**
* Route notifications for OneSignal channel.
*
* IMPORTANT: The external_user_id must match what the Flutter app sets
* when calling Notify.initializePush('user_' + user.id).
*/
public function routeNotificationForOneSignal(): array
{
return ['include_external_user_ids' => ['user_' . $this->id]];
}
Device Reachability (Optional)#
OneSignal accepts a push for a subscription that cannot be woken, so only the
device knows whether a page will arrive. Notify.pushState reports that to
your backend and withdraws it on sign-out. It stays off until the client names
both endpoints (there is no default path):
'push_state': {
'report_path': '/devices/push-state',
'release_path': '/devices/push-state/release',
},
Both routes sit behind auth:sanctum. The user is always the session's; no
body field names a person beyond the device's own alias.
POST {report_path} carries PushDeliverySnapshot.toMap() unchanged:
| Key | Rule |
|---|---|
external_id |
present, nullable string; must equal the caller's own alias (user_{id}) when set |
subscription_id |
present, nullable string |
reachability |
one of on, off, blocked, unavailable (the PushReachability names) |
captured_at |
ISO-8601 UTC, the device's own clock |
Store it per (user, subscription_id) with updateOrCreate, stamp your own
reported_at, and answer 204. A null key is a fact ("this device holds no
subscription id"), so validate with present, not sometimes.
POST {release_path} carries {"subscription_id": "..."} only. Delete that
one row for the session's user and answer 204. The client calls it before
Auth.logout() drops the token, and only for the device being signed out of:
the person's other devices keep paging them.
The client reports on change (sign-in, permission, subscription swap), never on a timer, and re-posts only after a refusal. A refused release is logged and the sign-out proceeds, so size your freshness horizon for a stale row.
Socket Delivery (Broadcast)#
The client can take notification state off a socket instead of polling for it
(Notify.startRealtime). That needs three things on this side: the broadcast
channel on the notification, a payload the client's decoder can read, and channel
authorisation.
1. Add the channel and shape the frame#
use Illuminate\Notifications\Messages\BroadcastMessage;
class MonitorDownNotification extends Notification
{
public function via(object $notifiable): array
{
// Alongside `database`, never instead of it: the socket is delivery, the
// row is the record. A client that was offline reads the row over the API.
return ['database', 'broadcast'];
}
/**
* The wire event name.
*
* Laravel's default is the fully-qualified
* `Illuminate\Notifications\Events\BroadcastNotificationCreated`. Magic's
* Reverb channel matches a listener by EXACT string, so the client would have
* to hardcode a framework internal; this short name is the contract instead.
*/
public function broadcastAs(): string
{
return 'notification.created';
}
/**
* The payload, in the SAME shape `GET /notifications` returns a row.
*
* This matters more than it looks. Laravel's default broadcast payload
* FLATTENS the notification data to the top level and adds `id` and `type`,
* while `DatabaseNotification.fromMap` on the client reads `data.title`,
* `data.body` and `data.action_url` from a NESTED `data` key, so the default
* decodes to nothing useful. Building the frame from `toArray()` keeps one
* serializer behind both the API and the socket, so the two cannot drift.
*/
public function toBroadcast(object $notifiable): BroadcastMessage
{
return new BroadcastMessage([
'id' => $this->id,
'type' => static::class,
'data' => $this->toArray($notifiable),
'created_at' => now()->toIso8601String(),
'read_at' => null,
]);
}
}
2. Authorise the channel#
The default channel for a Notifiable is its class name with dots plus its key,
so App\Models\User id 42 publishes on App.Models.User.42. Authorise it in
routes/channels.php, scoped to the one user:
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{userId}', function (User $user, string $userId): bool {
// A notification is personal. Comparing as strings keeps a UUID key working.
return (string) $user->id === (string) $userId;
}, ['guards' => ['sanctum']]);
The guards option matters for an API client: the channel-auth request arrives
with a bearer token, not a session cookie, so the default web guard would deny
every subscription.
Override receivesBroadcastNotificationsOn() on the notifiable if you want a
different name; the client is told the channel by its caller, so any name works as
long as both sides agree.
3. Point the client at it#
await Notify.startRealtime(channel: 'App.Models.User.${user.id}');
Nothing else changes. Notify.startPolling() stays wired to auth state and
becomes a no-op while the socket is live, so a deployment with
BROADCAST_CONNECTION=null keeps polling and needs no client change at all.
The row is still the record. Keep
databaseinvia(). The socket delivers to whoever is connected; a client that was closed when the notification fired learns about it from the API on its next start, which is the fetchstartRealtime()does once.
Sending Notifications#
From Controller#
use App\Notifications\MonitorDownNotification;
public function checkMonitor(Monitor $monitor)
{
if ($monitor->isDown()) {
$monitor->user->notify(new MonitorDownNotification($monitor));
}
}
From Job/Queue#
use Illuminate\Support\Facades\Notification;
// Single user
$user->notify(new MonitorDownNotification($monitor));
// Multiple users
Notification::send($users, new MonitorDownNotification($monitor));
API Contract Reference#
These are the endpoints the Flutter plugin expects from your backend:
| Endpoint | Method | Description |
|---|---|---|
/notifications |
GET | List user notifications (paginated) |
/notifications/unread-count |
GET | Get unread notification count |
/notifications/{id}/read |
POST | Mark single notification as read |
/notifications/read-all |
POST | Mark all notifications as read |
/notifications/{id} |
DELETE | Delete a notification |
/notification-preferences |
GET | Get user notification preferences |
/notification-preferences |
PUT | Update user preferences |
Response Formats#
GET /notifications:
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "monitor_down",
"data": {
"title": "Monitor Alert",
"body": "Your monitor 'API Server' is down",
"action_url": "/monitors/1"
},
"read_at": null,
"created_at": "2024-01-15T10:30:00Z"
}
],
"meta": {
"current_page": 1,
"last_page": 3,
"per_page": 15,
"total": 42
}
}
GET /notifications/unread-count:
{
"count": 5
}
GET /notification-preferences:
{
"data": {
"push_enabled": true,
"email_enabled": false,
"in_app_enabled": true,
"type_preferences": {
"monitor_down": { "push": true, "email": true, "in_app": true },
"monitor_up": { "push": false, "email": false, "in_app": true }
}
}
}
Testing#
Test Database Notification#
use App\Models\User;
use App\Models\Monitor;
use App\Notifications\MonitorDownNotification;
use Illuminate\Support\Facades\Notification;
test('sends monitor down notification', function () {
Notification::fake();
$user = User::factory()->create();
$monitor = Monitor::factory()->create(['user_id' => $user->id]);
$user->notify(new MonitorDownNotification($monitor));
Notification::assertSentTo($user, MonitorDownNotification::class);
});
Test API Endpoints#
test('lists user notifications', function () {
$user = User::factory()->create();
$user->notify(new \App\Notifications\MonitorDownNotification($monitor));
$response = $this->actingAs($user)
->getJson('/api/v1/notifications');
$response->assertOk()
->assertJsonStructure([
'data' => [['id', 'type', 'data', 'read_at', 'created_at']],
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
]);
});
Related