Laravel Backend Setup
The backend contract Magic Notifications reads from, and a worked Laravel implementation of it: the notifications API, preference matrix, OneSignal push, device reachability and socket delivery.
On this page
Overview#
Magic Notifications works with any backend that serves the REST endpoints listed under API Contract Reference. This guide builds that backend on Laravel's built-in notification system, which is the natural fit. The magic-starter-laravel package implements the contract except for two points: its unread-count answers {"data": {"count": n}} where the client reads a top-level count (so Notify.unreadCount() returns 0 against it), and its list endpoint reads the page size from per_page where the client sends perPage (so the list always uses its default of 15). A backend written to this page is not affected. Use this page when you hand-roll the backend or want to see what that package does.
Prerequisites#
- A Laravel application with notifications available, and Sanctum (or another token guard) protecting the API routes. The samples use
auth:sanctum. - For push: a OneSignal app and its REST API key.
- For socket delivery: Laravel broadcasting with a Reverb-compatible driver.
Database Setup#
Notifications Table#
Laravel provides a built-in 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 (for example App\Models\User) |
notifiable_id |
bigint | Model id |
data |
json | Notification payload |
read_at |
timestamp | When marked as read (null means unread) |
created_at |
timestamp | Creation time |
updated_at |
timestamp | Last update time |
Notification Settings Table#
The preferences screen stores one override per person, notification type and channel. A missing row means "enabled".
php artisan make:migration create_notification_settings_table
return new class extends Migration
{
public function up(): void
{
Schema::create('notification_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('type');
$table->string('channel');
$table->boolean('is_enabled')->default(true);
$table->timestamps();
$table->unique(['user_id', 'type', 'channel']);
});
}
public function down(): void
{
Schema::dropIfExists('notification_settings');
}
};
php artisan migrate
Models#
User Model#
The User model uses Laravel's Notifiable trait and answers whether a person allows a type on a channel:
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
public function notificationSettings(): HasMany
{
return $this->hasMany(NotificationSetting::class);
}
public function allows(string $type, string $channel): bool
{
$setting = $this->notificationSettings()
->where('type', $type)
->where('channel', $channel)
->first();
return $setting?->is_enabled ?? true;
}
}
NotificationSetting Model#
php artisan make:model NotificationSetting
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NotificationSetting extends Model
{
protected $fillable = [
'user_id',
'type',
'channel',
'is_enabled',
];
protected $casts = [
'is_enabled' => 'boolean',
];
}
Notification Class#
php artisan make:notification MonitorDownNotification
namespace App\Notifications;
use App\Models\Monitor;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class MonitorDownNotification extends Notification
{
use Queueable;
public function __construct(
public Monitor $monitor
) {}
public function via(object $notifiable): array
{
return $notifiable->allows('monitor_down', 'database') ? ['database'] : [];
}
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';
}
}
The client reads data.title and data.body as required strings and data.action_url as optional, so toArray() has to carry the first two on every notification. A row without them cannot be decoded, and the fetch that returns it fails.
API Controllers#
NotificationController#
php artisan make:controller Api/V1/NotificationController
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
// The client sends `page` and `perPage`.
$notifications = $request->user()
->notifications()
->paginate($request->integer('perPage', 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` sits at the top level: the client reads it from there.
return response()->json([
'count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$request->user()->notifications()->findOrFail($id)->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#
The preferences screen reads a type by channel matrix and writes one cell or a batch. The set of types and channels is yours; this sketch keeps it in a config file:
// config/notification-types.php
return [
'monitor_down' => [
'label' => 'Monitor down',
'channels' => [
'database' => ['locked' => true],
'push' => ['locked' => false],
],
],
];
php artisan make:controller Api/V1/NotificationPreferenceController
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\NotificationSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class NotificationPreferenceController extends Controller
{
public function show(Request $request): JsonResponse
{
return $this->matrix($request);
}
public function update(Request $request): JsonResponse
{
$items = $request->has('preferences')
? $request->input('preferences')
: [$request->only(['type', 'channel', 'is_enabled'])];
validator(['items' => $items], [
'items.*.type' => 'required|string',
'items.*.channel' => 'required|string',
'items.*.is_enabled' => 'required|boolean',
])->validate();
// One transaction, so a batch cannot half-succeed.
DB::transaction(function () use ($request, $items) {
foreach ($items as $item) {
$channel = config("notification-types.{$item['type']}.channels.{$item['channel']}");
if ($channel === null || ($channel['locked'] ?? false)) {
throw ValidationException::withMessages(['channel' => 'This preference cannot be changed.']);
}
NotificationSetting::updateOrCreate(
[
'user_id' => $request->user()->id,
'type' => $item['type'],
'channel' => $item['channel'],
],
['is_enabled' => (bool) $item['is_enabled']],
);
}
});
return $this->matrix($request);
}
private function matrix(Request $request): JsonResponse
{
$settings = $request->user()->notificationSettings()->get()
->keyBy(fn ($setting) => "{$setting->type}.{$setting->channel}");
$matrix = collect(config('notification-types'))->map(fn (array $type, string $key) => [
'label' => $type['label'],
'channels' => collect($type['channels'])->map(fn (array $channel, string $name) => [
'enabled' => $settings->get("{$key}.{$name}")?->is_enabled ?? true,
'locked' => $channel['locked'] ?? false,
]),
]);
return response()->json([
'data' => $matrix,
'meta' => ['push_provisioned' => filled(config('onesignal.app_id'))],
]);
}
}
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 through 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 the Notification Class#
Add the OneSignal channel to your notification:
use NotificationChannels\OneSignal\OneSignalChannel;
use NotificationChannels\OneSignal\OneSignalMessage;
public function via(object $notifiable): array
{
$channels = [];
if ($notifiable->allows('monitor_down', 'database')) {
$channels[] = 'database';
}
if ($notifiable->allows('monitor_down', 'push')) {
$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 tap,
// 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)
// Who the push is for. The client drops a push whose `subject` names
// another identity than the one the device is subscribed as.
->setData('subject', 'user_' . $notifiable->id);
}
Route User to OneSignal#
Add to your User model:
/**
* Route notifications for the OneSignal channel.
*
* IMPORTANT: the external id must match what the Flutter app declares when it
* calls 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):
// In `notificationConfig`, under the `notifications` key.
const Map<String, dynamic> notifications = {
'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, which subscribes through magic_socket). 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`. The
* client matches a listener by EXACT string, so it 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.
*
* 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 serialiser 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#
import 'package:magic/magic.dart';
import 'package:magic_notifications/magic_notifications.dart';
Future<void> listenForUser(Model user) async {
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 a Controller#
use App\Notifications\MonitorDownNotification;
public function checkMonitor(Monitor $monitor)
{
if ($monitor->isDown()) {
$monitor->user->notify(new MonitorDownNotification($monitor));
}
}
From a Job or Queue#
use Illuminate\Support\Facades\Notification;
// Single user
$user->notify(new MonitorDownNotification($monitor));
// Multiple users
Notification::send($users, new MonitorDownNotification($monitor));
API Contract Reference#
The endpoints the Flutter plugin calls on your backend, all relative to the HTTP base URL and authenticated:
| Endpoint | Method | Description |
|---|---|---|
/notifications |
GET |
List notifications, paginated; query page and perPage |
/notifications/unread-count |
GET |
Unread count |
/notifications/{id}/read |
POST |
Mark one notification as read |
/notifications/read-all |
POST |
Mark all as read |
/notifications/{id} |
DELETE |
Delete a notification |
/notification-preferences |
GET |
The type by channel preference matrix |
/notification-preferences |
PUT |
Change one cell or a batch |
/notifications/push-test |
POST |
Optional: push a test notification to the caller's own devices (used by PushChannel) |
{push_state.report_path} |
POST |
Optional: device reachability report |
{push_state.release_path} |
POST |
Optional: release the device's report on sign-out |
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 and PUT /notification-preferences are specified under Preferences: a matrix of types, each with a label and per-channel enabled and locked flags, plus meta.push_provisioned.
POST /notifications/push-test takes title (string), body (string) and optional data (array), and no recipient: the backend derives the target from the session. Answer 501 while the surface is switched off (the client half is notifications.push.self_test_enabled; set both or neither), and 2xx on success. The client treats any other status as a failure and throws.
Testing#
Test Database Notification#
use App\Models\Monitor;
use App\Models\User;
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();
$monitor = Monitor::factory()->create(['user_id' => $user->id]);
$user->notify(new 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'],
]);
});
Gotchas#
| Mistake | Fix |
|---|---|
Wrapping the unread count as {"data": {"count": 5}} |
The client reads count from the top level and shows 0 otherwise. Return {"count": 5}. |
Reading the page size from per_page |
The client sends perPage (and page). Read perPage, or the list always uses your default. |
Answering GET /notification-preferences with flat push_enabled toggles |
The screen expects the type by channel matrix with enabled and locked per cell. The flat shape belongs to NotificationPreference, which the package never fetches. |
A toArray() without title or body |
DatabaseNotification.fromMap casts both to String, so such a row fails to decode and the fetch that returns it fails. |
| Using Laravel's default broadcast payload | It is flat and carries the event under a long class name. Add broadcastAs() and a toBroadcast() that nests data, as shown. |
Sending the screen's destination with setUrl |
That is OneSignal's Launch URL and never reaches event.data. Put the route in setData('url', ...). |
Related Documentation#
- Preferences: the matrix and the models behind it.
- Shipping push in a release build: what to verify before a release, including
Notify.pushState. - Notification Manager: how the client uses these endpoints.
- Installation and Channels: the Flutter side.
- Laravel Notifications and the OneSignal Laravel channel: the packages this page builds on.
- magic_socket channels: the client-side socket layer.