The HeartBeat Job — Knowing Your Queue Is Actually Running
Series: Every Laravel Project Should Have These Building Blocks
Part: 18 of 35 Level: Intermediate Prerequisites: Jobs and Queues
What You’ll Learn
- Why queue workers die silently and why that’s dangerous
- The HeartBeat pattern: a scheduled job that proves the queue is alive
- Building the
HeartBeatmodel andHeartBeatJob - Scheduling it and wiring alerts
- Connecting it to the Status Page
The Problem: Silent Queue Death
Queue workers are long-running processes. They can die for many reasons:
- PHP out-of-memory error
- Database connection timeout
- Server restart
- Supervisor misconfiguration
- OOM killer on low-memory servers
When a worker dies, jobs accumulate in the queue silently. No errors appear in logs. No exception emails fire. Users submit forms, click buttons, and nothing happens on the other side — emails don’t send, orders don’t fulfill, invoices don’t generate. And no one knows until a user complains.
The HeartBeat pattern solves this with a simple idea: schedule a job to write a timestamp to the database every minute. If the timestamp isn’t being updated, the queue is dead.
The heartbeats Table
// database/migrations/create_heartbeats_table.php
Schema::create('heartbeats', function (Blueprint $table): void {
$table->id();
$table->string('name')->default('default'); // support multiple queues
$table->timestamp('last_beat_at')->nullable();
$table->timestamps();
});
The HeartBeat model:
// app/Models/HeartBeat.php
class HeartBeat extends Model
{
protected $fillable = ['name', 'last_beat_at'];
protected function casts(): array
{
return [
'last_beat_at' => 'datetime',
];
}
}
The HeartBeatJob
// app/Jobs/HeartBeatJob.php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\HeartBeat;
use App\Services\Core\ErrorReporter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
final class HeartBeatJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1; // No retries — it either ran or it didn't
public int $timeout = 30; // Should complete well within 30 seconds
public function handle(): void
{
HeartBeat::updateOrCreate(
['name' => $this->queue ?? 'default'],
['last_beat_at' => now()]
);
Log::channel('heartbeat')->info('HeartBeat OK', [
'queue' => $this->queue ?? 'default',
'timestamp' => now()->toISOString(),
'memory_mb' => round(memory_get_usage(true) / 1_048_576, 1),
]);
}
public function failed(Throwable $e): void
{
Log::channel('heartbeat')->error('HeartBeat FAILED', [
'queue' => $this->queue ?? 'default',
'error' => $e->getMessage(),
]);
ErrorReporter::report($e);
}
}
Three things to notice:
$tries = 1— no retries. A heartbeat that fails once is already a signal.- It logs memory usage — memory leaks in workers show up as a slow climb over time.
failed()callsErrorReporter::report()— if the heartbeat itself fails, you get an email.
Scheduling the HeartBeat
In routes/console.php (Laravel 11+):
use App\Jobs\HeartBeatJob;
use Illuminate\Support\Facades\Schedule;
// Every minute — tight enough to detect failures quickly
Schedule::job(new HeartBeatJob)->everyMinute()->onQueue('default');
// If you have multiple queues, beat on each one:
Schedule::job((new HeartBeatJob)->onQueue('high'))->everyMinute();
Schedule::job((new HeartBeatJob)->onQueue('notifications'))->everyMinute();
For the scheduler to dispatch the job, the cron must be running:
# crontab -e
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1
Checking Queue Health
The HeartBeat feeds directly into the Status Page’s QueueCheck (see Article 17):
final class QueueCheck implements StatusCheckInterface
{
public function run(): ServiceCheckResult
{
$heartbeat = HeartBeat::where('name', 'default')->first();
if ($heartbeat === null) {
return new ServiceCheckResult(
name: 'Queue',
icon: 'fa-list-check',
status: ServiceStatus::Outage,
message: 'No heartbeat ever recorded',
);
}
$ageSeconds = (int) $heartbeat->last_beat_at->diffInSeconds(now());
return match(true) {
$ageSeconds > 300 => new ServiceCheckResult( // > 5 minutes
name: 'Queue',
icon: 'fa-list-check',
status: ServiceStatus::Outage,
message: "Queue down — no beat for {$ageSeconds}s",
),
$ageSeconds > 90 => new ServiceCheckResult( // > 90 seconds
name: 'Queue',
icon: 'fa-list-check',
status: ServiceStatus::Degraded,
message: "Queue slow — last beat {$ageSeconds}s ago",
),
default => new ServiceCheckResult(
name: 'Queue',
icon: 'fa-list-check',
status: ServiceStatus::Operational,
message: "Healthy — beat {$ageSeconds}s ago",
),
};
}
}
Pairing with External Uptime Monitors
Connect the /status endpoint to an external monitor (UptimeRobot, Better Uptime, Freshping) and configure it to alert when the queue check returns non-operational. This gives you:
- Internal detection — the Status Page knows the queue is dead
- External alerting — you get a text/email if the web process itself is down too
The two-layer monitoring means you’re notified whether it’s the worker that died or the whole server.
Key Takeaways
- Queue workers die silently. Without a heartbeat, you won’t know until users complain.
HeartBeatJobwrites a timestamp every minute via the scheduler. If it stops writing, the queue is dead.$tries = 1— a heartbeat that needs retrying has already failed its purpose.- Log memory usage in
handle()to detect worker memory leaks over time. - Feed
HeartBeat::last_beat_atinto theQueueCheckon your Status Page. - Wire the Status Page to an external uptime monitor for two-layer alerting.
Tips and Gotchas
⚠️ Warning: Set
$tries = 1on the HeartBeat job. If it fails and retries, a failing queue will repeatedly attempt retries that never succeed, obscuring the real problem. One try, fail fast, alert clearly.
💡 Tip: Monitor
HeartBeat::where('name', 'default')->value('last_beat_at')in your Status Page (see Article 17) as aStatusCheckInterfaceimplementation. This gives you one unified health check UI for queue liveness.
🔥 Expert Note: Run the HeartBeat on every named queue, not just
default. Schedule separate instances:->everyMinute()->onQueue('default')and->everyMinute()->onQueue('emails'). A brokenemailsqueue won’t show up in adefault-only heartbeat.
Further Reading
- Laravel Docs: Queues
- Laravel Docs: Task Scheduling
- Laravel Horizon — provides queue monitoring including liveness metrics out of the box
One Comment