Skip to main content

Push Notifications System (API)

Overview

ZÈYA uses Expo Push Notifications to send real-time notifications to users across iOS and Android devices. The system is built on Laravel and uses a queue-based architecture for reliable, scalable notification delivery.

Architecture

Technology Stack

  • Push Service: Expo Push Notification Service
  • SDK: alymosul/exponent-server-sdk-php
  • Queue System: Laravel Queue (Redis/Database driver)
  • Database: MySQL for notification tracking
  • Real-time Sync: Firebase Realtime Database for notification badges

Key Components

┌─────────────────────────────────────────────────────────┐
│ Notification Flow │
└─────────────────────────────────────────────────────────┘

1. Trigger Event (e.g., swap match, message)

2. NotificationController::sendNotification()

3. NotificationService::send()

4. Create Notification Records in DB

5. Send to Expo Push Service

6. Receive Ticket ID from Expo

7. Update Notification Status (is_send, ticket_id)

8. Fetch Delivery Receipt (async)

9. Update Delivery Status (is_delivered)

10. User Receives Push Notification

Database Schema

Notifications Table

CREATE TABLE notifications (
notification_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
notification_content TEXT NOT NULL,
notification_type ENUM('promotional', 'non_promotional') DEFAULT 'non_promotional',
mobile_devices_id BIGINT,
is_send BOOLEAN DEFAULT false,
is_delivered BOOLEAN DEFAULT false,
is_open BOOLEAN DEFAULT false,
ticket_id VARCHAR(255),
error_message TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP NULL,

FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
FOREIGN KEY (mobile_devices_id) REFERENCES mobile_devices(id)
);

Mobile Devices Table

CREATE TABLE mobile_devices (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token VARCHAR(255) NOT NULL, -- Expo Push Token
device_id VARCHAR(255),
platform ENUM('ios', 'android'),
created_at TIMESTAMP,
updated_at TIMESTAMP,

FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
UNIQUE KEY unique_user_token (user_id, token)
);

User Settings Table

CREATE TABLE user_settings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
setting_name VARCHAR(255),
setting_value VARCHAR(255),
deleted_at TIMESTAMP NULL,

FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);

-- User notification preference
INSERT INTO user_settings (user_id, setting_name, setting_value)
VALUES (1, 'notification', '1'); -- 1 = enabled, 0 = disabled

Core Services

NotificationService

Located at: app/Services/NotificationService.php

Key Methods

1. send(array $message, array $users, bool $promotional = false)

Sends push notifications to multiple users.

Parameters:

  • $message: Array with title, body, and optional data
  • $users: Array of user IDs
  • $promotional: Boolean flag for notification type

Message Structure:

$message = [
'title' => 'You got a match!',
'body' => 'Someone wants to swap with you',
'data' => [
'link' => '/swap/123',
'transaction_id' => 123,
// ... custom data
]
];

Process Flow:

  1. Validates message structure
  2. Fetches user devices from database (with optimization)
  3. Creates notification records (bulk insert)
  4. Sends to Expo Push Service
  5. Handles tickets and receipts
  6. Bulk updates notification statuses

Response:

[
'success' => true,
'data' => [
[
'user' => [...],
'notifications' => [
[
'notification_id' => 1,
'is_send' => true,
'ticket_id' => 'xxxx-xxxx',
'is_delivered' => true,
'device_id' => 1
]
]
]
]
]
2. fetchAndProcessReceipts(array $ticketIds)

Fetches delivery receipts from Expo and updates notification status.

Parameters:

  • $ticketIds: Array of Expo ticket IDs

Process:

  1. Calls Expo API to fetch receipts
  2. Parses receipt status (ok/error)
  3. Updates is_delivered and error_message fields
  4. Returns updated records

NotificationController

Located at: app/Http/Controllers/NotificationController.php

Key Endpoints

1. Save Device Token
POST /api/user/save-phone-token
Authorization: Bearer {token}

{
"token": "ExponentPushToken[xxxxxxxxxxxx]",
"device_id": "uuid-device-id",
"platform": "ios"
}
2. Send Test Notification
GET /api/send-test-notification
Authorization: Bearer {token}

?user_id=1&title=Test&text=This is a test
3. Send Bulk Notifications (Admin)
POST /api/admin/send-notification-all
Authorization: Bearer {admin-token}

{
"title": "New Feature Available!",
"text": "Check out our latest update",
"data": {
"link": "/features"
}
}

Process:

  • Dispatches SendBulkNotifications job
  • Processes users in batches of 50
  • Only sends to users with notifications enabled
  • Returns immediately, processing happens in background
4. Send Notification to Specific User
POST /api/send-group-join-notification
Authorization: Bearer {token}

{
"admin_user_id": 123,
"title": "New member joined",
"text": "Someone joined your group",
"link": "(community)/groups_index"
}
5. Get User Notifications
GET /api/notification
Authorization: Bearer {token}

Returns unread notifications for the authenticated user.

6. Mark Notification as Read
GET /api/notification/{notification_id}/read
Authorization: Bearer {token}
7. Get Promotional Notifications (Admin)
GET /api/admin/promotional-notifications
Authorization: Bearer {admin-token}

?page=1&per_page=10
8. Get Promotional Notification Details (Admin)
GET /api/admin/promotional-notifications/{notification_id}
Authorization: Bearer {admin-token}

?name=John&email=john@example.com&status=sent&page=1&per_page=10

Supports filtering by:

  • User name
  • Email
  • Device ID
  • Platform
  • Status (sent/delivered/opened)
9. Process Expo Receipts (Admin)
GET /api/admin/process-receipts
Authorization: Bearer {admin-token}

?limit=200

Triggers background job to fetch delivery receipts from Expo.

Queue Jobs

SendBulkNotifications

Located at: app/Jobs/SendBulkNotifications.php

Purpose: Coordinator job that dispatches batch jobs for bulk notifications.

Process:

  1. Queries users with notifications enabled
  2. Filters active users only
  3. Chunks users into batches (default: 50)
  4. Dispatches SendNotificationBatch job for each batch

Usage:

SendBulkNotifications::dispatch($message);

SendNotificationBatch

Located at: app/Jobs/SendNotificationBatch.php

Purpose: Processes a specific batch of users.

Configuration:

  • $tries = 3: Maximum retry attempts
  • $backoff = 60: Wait 60 seconds before retry

Process:

  1. Receives message and user IDs
  2. Calls NotificationService::send()
  3. Logs success/failure
  4. Retries on failure (up to 3 times)

SendBulkNotificationsSandbox

Located at: app/Jobs/SendBulkNotificationsSandbox.php

Similar to SendBulkNotifications but only sends to sandbox/test users.

ProcessExpoReceipts

Located at: app/Jobs/ProcessExpoReceipts.php

Purpose: Fetches delivery receipts from Expo in background.

Process:

  1. Queries notifications with ticket_id but no delivery status
  2. Fetches receipts in batches
  3. Updates is_delivered and error_message

Notification Types

1. Non-Promotional (Transactional)

Used for user-triggered events:

  • Swap matches
  • New messages
  • Swap status changes
  • Group join requests
  • System notifications

Respects user notification preferences

2. Promotional (Marketing)

Used for bulk campaigns:

  • Marketing announcements
  • Feature updates
  • Re-engagement campaigns
  • General announcements

Always sent, regardless of user preference (unless user opts out at OS level)

Status Tracking

Notification Lifecycle

Created → Sent → Delivered → Opened
↓ ↓ ↓ ↓
is_send ticket_id is_delivered is_open

Status Fields

  1. is_send (boolean)

    • true: Successfully submitted to Expo
    • false: Failed to send or not yet sent
  2. ticket_id (string)

    • Expo's unique identifier for tracking delivery
    • Used to fetch delivery receipt
  3. is_delivered (boolean)

    • true: Expo confirmed delivery to device
    • false: Delivery failed or not yet confirmed
    • null: Receipt not yet fetched
  4. is_open (boolean)

    • true: User opened/viewed the notification
    • false: Not yet opened
  5. error_message (text)

    • Contains error details if sending/delivery failed
    • Common errors:
      • DeviceNotRegistered: Token invalid/expired
      • InvalidCredentials: Expo credentials issue
      • MessageTooBig: Payload exceeds size limit

User Notification Preferences

Users can enable/disable notifications via settings:

// Check if user has notifications enabled
$settings = UserSetting::where('user_id', $userId)
->where('setting_name', 'notification')
->first();

if ($settings && $settings->setting_value == "1") {
// Send notification
} else {
// Skip
}

Best Practices

1. Batching

Always batch notifications for multiple users:

// Good ✅
NotificationController::sendNotification($message, [1, 2, 3, 4, 5]);

// Bad ❌
foreach ($users as $user) {
NotificationController::sendNotification($message, [$user]);
}

2. Queue Usage

For bulk notifications, always use queues:

// Good ✅
SendBulkNotifications::dispatch($message);

// Bad ❌
foreach ($users as $user) {
NotificationService::send($message, [$user]);
}

3. Message Size

Keep payload small to avoid MessageTooBig errors:

  • Maximum payload: 4KB
  • Keep title and body concise
  • Use data field for additional information
  • Store large data in database, pass IDs only

4. Token Management

  • Remove invalid tokens when DeviceNotRegistered error occurs
  • Support multiple devices per user
  • Update tokens on app launch
  • Clean up old/inactive device tokens periodically

5. Error Handling

Always handle notification failures gracefully:

try {
$result = NotificationService::send($message, $users);
if (!$result['success']) {
Log::warning('Notification failed', ['result' => $result]);
}
} catch (\Exception $e) {
Log::error('Notification exception', ['error' => $e->getMessage()]);
}

6. Testing

Use sandbox endpoints for testing:

// Test with specific users
GET /api/send-test-notification?user_id=123

// Test with sandbox users only
POST /api/admin/send-notification-sandbox

Performance Optimization

1. Bulk Insert

The API uses bulk insert for creating notification records:

DB::table('notifications')->insert($notificationsToInsert);

This is 10-50x faster than individual inserts.

2. Bulk Update

Status updates use MySQL CASE statements:

UPDATE notifications 
SET is_send = CASE notification_id
WHEN 1 THEN 1
WHEN 2 THEN 1
ELSE is_send END
WHERE notification_id IN (1, 2);

3. Eager Loading

The service eager loads relationships to avoid N+1 queries:

$userDevicesMap = MobileDevice::whereIn('user_id', $users)
->whereNotNull('token')
->get()
->groupBy('user_id');

4. Queue Workers

Run multiple queue workers for parallel processing:

# Start 5 queue workers
php artisan queue:work --queue=default --sleep=3 --tries=3 --max-jobs=1000

Monitoring & Analytics

Delivery Metrics

Query notification statistics:

-- Overall stats
SELECT
COUNT(*) as total,
SUM(is_send) as sent,
SUM(is_delivered) as delivered,
SUM(is_open) as opened
FROM notifications
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY);

-- By notification type
SELECT
notification_type,
COUNT(*) as total,
AVG(is_send) * 100 as send_rate,
AVG(is_delivered) * 100 as delivery_rate,
AVG(is_open) * 100 as open_rate
FROM notifications
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY notification_type;

-- Failed notifications
SELECT
error_message,
COUNT(*) as count
FROM notifications
WHERE is_send = false
AND error_message IS NOT NULL
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY error_message
ORDER BY count DESC;

Logging

All notification operations are logged:

Log::info("Notification sent", [
'user_id' => $userId,
'notification_id' => $notificationId,
'ticket_id' => $ticketId
]);

Log::error("Notification failed", [
'user_id' => $userId,
'error' => $e->getMessage()
]);

Troubleshooting

Common Issues

  1. Notifications not sending

    • Check queue is running: php artisan queue:work
    • Verify Expo credentials in .env
    • Check user has valid device token
  2. DeviceNotRegistered error

    • Token is invalid/expired
    • Remove token from mobile_devices table
    • User needs to restart app to register new token
  3. High delivery failure rate

    • Check Expo service status
    • Verify network connectivity
    • Review error messages in database
  4. Slow bulk notifications

    • Increase queue workers
    • Reduce batch size
    • Optimize database queries

Security Considerations

  1. Token Security

    • Never expose Expo push tokens in client responses
    • Validate token format before storing
    • Implement rate limiting on token registration
  2. Authorization

    • Always verify user ownership before sending notifications
    • Use middleware for admin-only endpoints
    • Validate notification content
  3. Data Privacy

    • Don't include sensitive data in notification payload
    • Use notification IDs to fetch data on app open
    • Respect user privacy settings