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 withtitle,body, and optionaldata$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:
- Validates message structure
- Fetches user devices from database (with optimization)
- Creates notification records (bulk insert)
- Sends to Expo Push Service
- Handles tickets and receipts
- 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:
- Calls Expo API to fetch receipts
- Parses receipt status (ok/error)
- Updates
is_deliveredanderror_messagefields - 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
SendBulkNotificationsjob - 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
- 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:
- Queries users with notifications enabled
- Filters active users only
- Chunks users into batches (default: 50)
- Dispatches
SendNotificationBatchjob 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:
- Receives message and user IDs
- Calls
NotificationService::send() - Logs success/failure
- 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:
- Queries notifications with
ticket_idbut no delivery status - Fetches receipts in batches
- Updates
is_deliveredanderror_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
-
is_send (boolean)
true: Successfully submitted to Expofalse: Failed to send or not yet sent
-
ticket_id (string)
- Expo's unique identifier for tracking delivery
- Used to fetch delivery receipt
-
is_delivered (boolean)
true: Expo confirmed delivery to devicefalse: Delivery failed or not yet confirmednull: Receipt not yet fetched
-
is_open (boolean)
true: User opened/viewed the notificationfalse: Not yet opened
-
error_message (text)
- Contains error details if sending/delivery failed
- Common errors:
DeviceNotRegistered: Token invalid/expiredInvalidCredentials: Expo credentials issueMessageTooBig: 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
titleandbodyconcise - Use
datafield for additional information - Store large data in database, pass IDs only
4. Token Management
- Remove invalid tokens when
DeviceNotRegisterederror 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
-
Notifications not sending
- Check queue is running:
php artisan queue:work - Verify Expo credentials in
.env - Check user has valid device token
- Check queue is running:
-
DeviceNotRegistered error
- Token is invalid/expired
- Remove token from
mobile_devicestable - User needs to restart app to register new token
-
High delivery failure rate
- Check Expo service status
- Verify network connectivity
- Review error messages in database
-
Slow bulk notifications
- Increase queue workers
- Reduce batch size
- Optimize database queries
Security Considerations
-
Token Security
- Never expose Expo push tokens in client responses
- Validate token format before storing
- Implement rate limiting on token registration
-
Authorization
- Always verify user ownership before sending notifications
- Use middleware for admin-only endpoints
- Validate notification content
-
Data Privacy
- Don't include sensitive data in notification payload
- Use notification IDs to fetch data on app open
- Respect user privacy settings