Article

Push Notifications in Laravel: Storing Device Tokens for iOS and Android

D

Dinesh Wijethunga

June 4, 2026 6 min readIntermediate 51 views
📝

Two Services, One Goal

Every phone that wants to receive push notifications must give your server a device token — a long string that identifies that specific app installation on that specific device. But iOS and Android use completely separate delivery pipelines:

  • APNs (Apple Push Notification service) — Apple's infrastructure, used by every iOS app
  • FCM (Firebase Cloud Messaging) 

Your backend needs to:

  1. Receive the token when the user opens the app
  2. Store it against their user ID
  3. Know which service (APNs or FCM) to use when sending
  4. Handle tokens becoming invalid — device uninstalled, app reinstalled, OS updated

The Database Schema

A device token belongs to a user. But one user can have multiple devices, and your platform might have two apps (customer app, driver app). The table needs to handle all of that:

// database/migrations/create_device_tokens_table.php

Schema::create('device_tokens', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->text('token');                      // FCM or APNs token (can be long)
    $table->string('platform', 10);             // 'ios' or 'android'
    $table->string('app_type', 10);             // 'customer' or 'driver'
    $table->string('device_id', 255)->nullable(); // hardware fingerprint (optional)
    $table->timestamps();

    // One token per user, per platform, per app
    // If they reinstall, the new token replaces the old one
    $table->unique(['user_id', 'platform', 'app_type']);

    $table->index(['user_id', 'app_type']);
});

Why the unique constraint? A user on iOS with the customer app gets one token slot. When their token rotates (e.g., after an OS update), the upsert replaces the old value. Without this constraint, you'd accumulate dead tokens and send duplicate pushes.

The Model

// Modules/PushNotification/app/Models/DeviceToken.php

class DeviceToken extends Model
{
    protected $fillable = [
        'user_id', 'token', 'platform', 'app_type', 'device_id',
    ];

    // ── Scopes ────────────────────────────────────────────────────

    public function scopeForUser(Builder $query, int $userId): Builder
    {
        return $query->where('user_id', $userId);
    }

    public function scopeForAppType(Builder $query, string $appType): Builder
    {
        return $query->where('app_type', $appType);
    }

    public function scopeIos(Builder $query): Builder
    {
        return $query->where('platform', 'ios');
    }

    public function scopeAndroid(Builder $query): Builder
    {
        return $query->where('platform', 'android');
    }

    // ── Upsert: save a new token or replace the existing one ───────

    public static function saveOrUpdateToken(
        int    $userId,
        string $token,
        string $platform,
        string $appType,
        ?string $deviceId = null,
    ): self {
        return static::updateOrCreate(
            // Match on these three columns (the unique constraint)
            ['user_id' => $userId, 'platform' => $platform, 'app_type' => $appType],
            // Set these values
            ['token' => $token, 'device_id' => $deviceId],
        );
    }

    // ── Purge: remove a token that APNs/FCM reported as invalid ───

    public static function removeInvalidToken(string $token): void
    {
        static::where('token', $token)->delete();
    }
}

updateOrCreate is Laravel's upsert helper — it inserts if the row doesn't exist, updates if it does. The first array is the lookup key, the second is what to write.

iOS Live Activity Tokens — A Separate Table

iOS Live Activities (the persistent banners on the Lock Screen during a ride) use their own push token that is separate from the device token. It rotates during the activity's lifetime and expires after a maximum of 8 hours.

// database/migrations/create_live_activity_tokens_table.php

Schema::create('live_activity_tokens', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->text('token');
    $table->string('app_type', 10);              // 'customer' or 'driver'
    $table->string('activity_type', 30);         // 'ride_tracking' or 'driver_online'
    $table->string('activity_id', 255)->nullable(); // booking UUID for ride_tracking
    $table->timestamp('expires_at')->nullable();  // iOS enforces max 8h
    $table->timestamps();

    $table->unique(['user_id', 'app_type', 'activity_type', 'activity_id']);
    $table->index(['user_id', 'app_type', 'activity_type']);
});
// Modules/PushNotification/app/Models/LiveActivityToken.php

class LiveActivityToken extends Model
{
    protected $fillable = [
        'user_id', 'token', 'app_type',
        'activity_type', 'activity_id', 'expires_at',
    ];

    protected $casts = [
        'expires_at' => 'datetime',
    ];

    // Only return tokens that haven't expired
    public function scopeActive(Builder $query): Builder
    {
        return $query->where(function ($q) {
            $q->whereNull('expires_at')
              ->orWhere('expires_at', '>', now());
        });
    }

    public static function saveOrUpdateToken(
        int     $userId,
        string  $token,
        string  $appType,
        string  $activityType,
        ?string $activityId  = null,
        ?Carbon $expiresAt   = null,
    ): self {
        return static::updateOrCreate(
            ['user_id' => $userId, 'app_type' => $appType,
             'activity_type' => $activityType, 'activity_id' => $activityId],
            ['token' => $token, 'expires_at' => $expiresAt],
        );
    }

    public static function markActivityEnded(int $userId, string $activityType, ?string $activityId): void
    {
        static::where('user_id', $userId)
              ->where('activity_type', $activityType)
              ->where('activity_id', $activityId)
              ->delete();
    }
}

The Registration Endpoint

The Flutter app calls this immediately after login (or whenever the OS gives a new token). The controller is thin — validation then delegate to the model:

// Modules/PushNotification/app/Http/Requests/RegisterDeviceTokenRequest.php

class RegisterDeviceTokenRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'token'     => ['required', 'string', 'min:10'],
            'platform'  => ['required', 'string', Rule::in(['ios', 'android'])],
            'device_id' => ['sometimes', 'nullable', 'string', 'max:255'],
        ];
    }
}
// Modules/PushNotification/app/Http/Controllers/Api/CustomerDeviceTokenController.php

class CustomerDeviceTokenController extends Controller
{
    use ApiResponseTrait;

    public function store(RegisterDeviceTokenRequest $request): JsonResponse
    {
        DeviceToken::saveOrUpdateToken(
            userId:   auth()->id(),
            token:    $request->token,
            platform: $request->platform,
            appType:  'customer',
            deviceId: $request->device_id,
        );

        return $this->success(message: 'Device token registered');
    }
}

The driver controller is identical — only appType: 'driver' changes.

Route Registration

// Modules/PushNotification/routes/customer.php

Route::middleware(['api', 'auth:api'])
    ->prefix('api/v1/customer')
    ->group(function () {
        Route::post('device-token',        [CustomerDeviceTokenController::class, 'store']);
        Route::post('live-activity-token', [CustomerLiveActivityTokenController::class, 'store']);
    });

Flutter: Registering the Token

After login, the Flutter app retrieves the token from the device and POSTs it:

// In your auth service, after successful login

import 'package:firebase_messaging/firebase_messaging.dart';

Future<void> registerPushToken() async {
  final messaging = FirebaseMessaging.instance;

  // Request permission (iOS requires explicit permission)
  await messaging.requestPermission();

  // Get the FCM token (works for both iOS and Android via Firebase SDK)
  final token = await messaging.getToken();

  if (token != null) {
    await apiService.post('/api/v1/customer/device-token', {
      'token':    token,
      'platform': Platform.isIOS ? 'ios' : 'android',
    });
  }

  // Listen for token refreshes — re-register when token rotates
  messaging.onTokenRefresh.listen((newToken) {
    apiService.post('/api/v1/customer/device-token', {
      'token':    newToken,
      'platform': Platform.isIOS ? 'ios' : 'android',
    });
  });
}

Note for iOS: Even though iOS uses APNs natively, you can use the Firebase SDK on iOS too. Firebase receives the APNs token and proxies it. The server still talks to FCM which relays to APNs — one SDK on the client, one API on the server. In this project we use direct APNs for iOS (for Live Activities and heartbeats) which requires the raw APNs token — covered in Part 3.

Summary

DecisionWhy
unique(user_id, platform, app_type)One token per slot — upsert replaces stale tokens
Separate live_activity_tokens tableLive Activity tokens rotate independently and have a max 8h lifespan
removeInvalidToken() on the modelAPNs and FCM both tell you when a token is dead — clean up immediately
app_type columnSame user can have both customer and driver apps installed

In Part 2, we'll use these stored tokens to actually send notifications — starting with Android via the FCM HTTP v1 API.

<!-- CTA: [My boilerplate link] — "Get the push notification module pre-built — device tokens, Live Activity, FCM + APNs →"] -->

D

Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.