Skip to content

Laravel Backend Setup

View raw .md
On this page

Table of Contents#


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:

bash
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#

bash
php artisan make:migration create_notification_preferences_table
php
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');
    }
};
bash
php artisan migrate

Models#

User Model#

Ensure your User model uses the Notifiable trait:

php
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#

bash
php artisan make:model NotificationPreference
php
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#

bash
php artisan make:notification MonitorDownNotification
php
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#

bash
php artisan make:controller Api/V1/NotificationController
php
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#

bash
php artisan make:controller Api/V1/NotificationPreferenceController
php
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:

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:

bash
composer require laravel-notification-channels/onesignal
composer require berkayk/onesignal-laravel
bash
php artisan vendor:publish --provider="Berkayk\OneSignal\OneSignalServiceProvider"

Add to .env:

text
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:

php
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.")
        ->setUrl(url("/monitors/{$this->monitor->id}"))
        ->setData('monitor_id', $this->monitor->id);
}

Route User to OneSignal#

Add to your User model:

php
/**
 * 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]];
}

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#

php
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:

php
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#

dart
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 database in via(). 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 fetch startRealtime() does once.


Sending Notifications#

From Controller#

php
use App\Notifications\MonitorDownNotification;

public function checkMonitor(Monitor $monitor)
{
    if ($monitor->isDown()) {
        $monitor->user->notify(new MonitorDownNotification($monitor));
    }
}

From Job/Queue#

php
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:

json
{
  "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:

json
{
  "count": 5
}

GET /notification-preferences:

json
{
  "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#

php
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#

php
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

esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.