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:
| Driver | Primary Use Case | Characteristics |
|---|---|---|
database | Simple infrastructure, low overhead | Stores jobs in relational tables; ideal for getting started without extra daemons |
redis | High-throughput, memory-bound workloads | Low latency, in-memory processing |
sqs | Distributed cloud infrastructure | Fully managed AWS queue service |
beanstalkd | Lightweight dedicated work queue | Fast, 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:
iniAPP_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:
bashphp 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:
bashphp 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/sendusing the Server Key configured underAPI_KEYFCMin your.env. - Payload Segregation: Constructs a structured JSON payload targeting
/topics/dongeng. It sets acollapse_keyalongside distinctnotification(background display handlers) anddata(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 = 3property informs the engine to retry up to three times upon execution errors before invoking thefailed()hook for dead-letter notification handling.
Step 4: Dispatching with Delay Logic
Generate your controller to expose the endpoint:
bashphp 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
Carbon::parse()->floatDiffInMinutes('17:00:00')calculates the precise difference in minutes between the current system execution time and 5:00 PM.- The
dispatch(...)->delay(...)invocation updates theavailable_atcolumn in thejobsdatabase table to matchnow() + $delay minutes. - The queue worker ignores records where
available_atsits 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:
bashphp 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.
