The Missing FCM Scheduling API: Building an Asynchronous Delivery Engine with Laravel Queues

The Missing FCM Scheduling API: Building an Asynchronous Delivery Engine with Laravel Queues

By Reggi, 29 Dec 2022

If you have ever clicked through the Firebase Console, you know Google provides a clean interface to schedule push notifications for any arbitrary timestamp. The moment you open Postman or inspect the raw FCM HTTP endpoint to automate that pipeline programmatically, reality hits you: the FCM API provides zero native parameters for delayed execution.

Every network call to the FCM gateway processes immediately. To bridge the gap between instant downstream ingestion and scheduled outbound delivery, you have to build the scheduling layer into your own backend architecture.

Laravel Queue provides the exact primitives needed to solve this problem. Beyond offloading long-running processes to protect HTTP response cycles, its delayed execution mechanism acts as a reliable scheduling engine.


Architectural Blueprint: The Delay Engine

Instead of relying on cron-based polling patterns that query your main application tables every minute, you leverage an asynchronous queue backed by a storage driver.

[ HTTP Request / Controller ]
              │
              ▼
    Calculate Delay Target (Carbon)
              │
              ▼
   Dispatch Job with ->delay()
              │
              ▼
┌──────────────────────────────────────┐
│       Database Table: `jobs`         │
│   (Holds payload until available_at) │
└──────────────────────────────────────┘
              │
              ▼ (Worker polls available_at <= now)
┌──────────────────────────────────────┐
│       Queue Worker Engine            │
│       `php artisan queue:work`       │
└──────────────────────────────────────┘
              │
              ▼
   Execute FCM HTTP Request (cURL)
              │
              ▼
   Target Device / Topic: /topics/dongeng

Laravel offers multiple queue drivers out of the box:

DriverPrimary Use CaseCharacteristics
databaseSimple infrastructure, low overheadStores jobs in relational tables; ideal for getting started without extra daemons
redisHigh-throughput, memory-bound workloadsLow latency, in-memory processing
sqsDistributed cloud infrastructureFully managed AWS queue service
beanstalkdLightweight dedicated work queueFast, specialized queue daemon

For this implementation, we will use the database driver.


Step 1: Environment and Driver Provisioning

Open your .env configuration. Ensure your storage layer is reachable and configure the queue connection to use your database:

ini
APP_NAME=Laravel APP_ENV=local APP_KEY=base64:3k123123lm= APP_DEBUG=true APP_URL=http://localhost:8000 LOG_CHANNEL=stack LOG_LEVEL=debug DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=db DB_USERNAME=root DB_PASSWORD= BROADCAST_DRIVER=log CACHE_DRIVER=file FILESYSTEM_DRIVER=local QUEUE_CONNECTION=database SESSION_DRIVER=file SESSION_LIFETIME=120

The critical key here is QUEUE_CONNECTION=database. This tells the queue manager to bypass synchronous execution (sync) and direct dispatched jobs to your database tables.


Step 2: Database Schema Generation

To persist delayed jobs across application restarts, generate and run the migration for the queue schema:

bash
php artisan queue:table php artisan migrate

This creates the jobs table (along with failed_jobs if default migrations are present). The core engine uses the available_at column in this table to determine whether a job is eligible for worker processing or must remain dormant until a given UNIX timestamp.


Step 3: Engineering the Custom Job Class

Generate a dedicated job class via the Artisan CLI:

bash
php artisan make:job NotificationJob

Open app/Jobs/NotificationJob.php and implement the dispatch logic:

php
<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class NotificationJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected $dongeng; public $tries = 3; /** * Create a new job instance. * * @return void */ public function __construct($dongeng) { $this->dongeng = $dongeng; } /** * Execute the job. * * @return void */ public function handle() { $url = 'https://fcm.googleapis.com/fcm/send'; $api_key = env('API_KEYFCM'); $fields = array( 'to' => "/topics/dongeng", 'collapse_key' => "type_a", 'notification' => array( "body" => "Body Desc", "title" => "Title Desc", "image" => "https://image.jpg" ), "android" => array( "notification" => array( "image" => "https://image.jpg" ) ), 'data' => array( "body" => "Body Desc", "title" => "Title Desc", "link" => "https://image.jpg" ) ); $headers = array( 'Content-Type:application/json', 'Authorization:key=' . $api_key ); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($fields)); $result = curl_exec($ch); if ($result === FALSE) { die('FCM Send Error: ' . curl_error($ch)); } curl_close($ch); return $result; } /** * Handle job failure after exhausting all retry attempts. */ public function failed() { // Usually would send new notification to admin/user } }

Deep Dive: Internal Mechanics

State Hydration via Constructor

php
$this->dongeng = $dongeng;

When this job is serialized into the database, the SerializesModels trait packages the payload data cleanly, allowing it to be reconstituted when the worker executes the job at the scheduled time.

Worker Execution Pipeline

The handle() method contains the entire transport lifecycle:

  • FCM Endpoint Target: Calls https://fcm.googleapis.com/fcm/send using the Server Key configured under API_KEYFCM in your .env.
  • Payload Segregation: Constructs a structured JSON payload targeting /topics/dongeng. It sets a collapse_key alongside distinct notification (background display handlers) and data (client-side foreground runtime logic) keys.
  • Network Call: Initializes and executes a raw cURL request containing authentication headers and JSON payloads. SSL peer and host verifications are toggled off here for local testing; make sure these are strictly enabled in production.
  • Fault Handling: The $tries = 3 property informs the engine to retry up to three times upon execution errors before invoking the failed() hook for dead-letter notification handling.

Step 4: Dispatching with Delay Logic

Generate your controller to expose the endpoint:

bash
php artisan make:controller NotificationController

Implement the scheduling calculation in app/Http/Controllers/NotificationController.php:

php
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Models\Post; use App\Jobs\NotificationJob; use Carbon\Carbon; class NotificationController extends Controller { public function sendnotification(Request $request) { $dongeng = Post::find($request->id); // Calculate the minute delta between current time and target delivery $delay = Carbon::parse()->floatDiffInMinutes('17:00:00'); // Dispatch the job onto the database queue with an explicit target offset dispatch(new NotificationJob($dongeng))->delay(now()->addMinutes($delay)); return redirect()->route('posts.index') ->with('success', 'Notification Dongeng ' . $dongeng->title . ' will send in ' . ($delay / 60) . ' hours'); } }

How the Delay Mechanism Operates

  1. Carbon::parse()->floatDiffInMinutes('17:00:00') calculates the precise difference in minutes between the current system execution time and 5:00 PM.
  2. The dispatch(...)->delay(...) invocation updates the available_at column in the jobs database table to match now() + $delay minutes.
  3. The queue worker ignores records where available_at sits in the future. The record remains completely inert in database storage until that wall-clock timestamp is reached.

Step 5: Worker Lifecycle Management

A delayed job written to a database table will sit there forever unless a worker daemon is actively draining the queue.

Start the queue consumer locally:

bash
php artisan queue:work

The worker continuously polls the jobs table, checks if available_at <= NOW(), claims the payload, executes the handle() cURL sequence against FCM, and purges the row upon a successful exit code.

Production Resiliency

In production environments, running php artisan queue:work directly in a shell session will fail as soon as the terminal closes or the process hits an uncaught exception.

Use a dedicated process monitor like Supervisor to ensure the queue worker runs permanently as a background daemon, auto-restarts after crashes, and properly scales across server reboots.


Popular Reads