Push Notifications in Laravel: Storing Device Tokens for iOS and Android
Dinesh Wijethunga
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:
- Receive the token when the user opens the app
- Store it against their user ID
- Know which service (APNs or FCM) to use when sending
- 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
upsertreplaces 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();
}
}
updateOrCreateis 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
| Decision | Why |
|---|---|
unique(user_id, platform, app_type) | One token per slot — upsert replaces stale tokens |
Separate live_activity_tokens table | Live Activity tokens rotate independently and have a max 8h lifespan |
removeInvalidToken() on the model | APNs and FCM both tell you when a token is dead — clean up immediately |
app_type column | Same 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 →"] -->
Reviews & Ratings
Sign in to leave a review.
