The Problem It Solves
Queues fail quietly. A job throws, lands in failed_jobs, and nobody looks at that table until a customer says their invoice never arrived. Meanwhile the questions you actually want answered during an incident are:
- How many jobs are waiting right now, and is that number growing?
- Is anything running, or has the worker died?
- Which job class is failing, and with what exception?
- How long is a typical job taking, and did that change today?
Laravel ships queue:failed and queue:monitor, which cover a slice of that. Horizon covers all of it beautifully — on Redis only. Plenty of real applications run the database driver quite happily and get nothing.
Laravel Queue Monitor is a free, MIT-licensed package that records the full lifecycle of every job into a table and renders it as a live, auto-refreshing dashboard. It works with any queue driver — database, Redis, SQS, Beanstalkd.
Installing It
composer require jaydeep/laravel-queue-monitor
php artisan vendor:publish --tag=queue-monitor-migrations
php artisan migrateThat is the minimum — the migration creates the queue_monitor table and recording starts immediately. Publish the config and views only if you want to change behaviour or restyle the dashboard:
php artisan vendor:publish --tag=queue-monitor-config
php artisan vendor:publish --tag=queue-monitor-views| Requirement | Supported |
|---|---|
| PHP | 7.4 – 8.4 |
| Laravel | 8 – 12 |
| Queue drivers | Any — database, Redis, SQS, Beanstalkd |
| License | MIT |
It works by listening to Laravel’s own queue events, which is why the driver does not matter. JobProcessing, JobProcessed and JobFailed fire regardless of what is behind the queue.
The Web Dashboard
Visit /queue-monitor. You get a Bootstrap 5 interface with live counts for pending, running, completed and failed, a paginated jobs table with timestamps, and the exception message for anything that failed. It auto-refreshes every five seconds by default.
Lock this down before you deploy. The default middleware is ['web'], which means anyone who can reach the URL can read your job payloads — and job payloads routinely contain email addresses, order details and internal IDs. Change it before this goes anywhere near production.
// config/queue-monitor.php
'route' => [
'enabled' => env('QUEUE_MONITOR_ROUTES', true),
'prefix' => 'queue-monitor',
'middleware' => ['web', 'auth', 'can:viewQueueMonitor'],
'refresh' => 5,
],// app/Providers/AppServiceProvider.php — boot()
Gate::define('viewQueueMonitor', function ($user) {
return $user->hasRole('admin');
});Or disable the routes entirely in production and use the console command over SSH:
# .env
QUEUE_MONITOR_ROUTES=falseThe Artisan Command
php artisan queue-monitor:show
php artisan queue-monitor:show --limit=50
php artisan queue-monitor:show --json
php artisan queue-monitor:show --pruneThis is what I reach for during an incident, because it works over SSH with no browser and no port forwarding. --json makes it pipeable, which is where it gets genuinely useful:
# How many jobs have failed?
php artisan queue-monitor:show --json | jq '.failed'
# Alert if the pending backlog is climbing
PENDING=$(php artisan queue-monitor:show --json | jq '.pending')
if [ "$PENDING" -gt 500 ]; then
curl -X POST "$SLACK_WEBHOOK" -d "{\"text\":\"Queue backlog: $PENDING jobs\"}"
fiPut that check on a five-minute cron and you have queue alerting for free. A growing pending count is the earliest reliable signal that a worker has died — usually well before anyone notices the missing emails.
The JSON Endpoint
/queue-monitor/stats returns the same snapshot as JSON, which is what the dashboard polls. It is also what you point an external monitor at:
const res = await fetch('/queue-monitor/stats', { credentials: 'include' });
const { pending, running, completed, failed } = await res.json();
if (failed > 0) {
showBanner(`${failed} background jobs have failed`);
}Feeding it into an uptime monitor or a Grafana panel gives you queue health next to the rest of your metrics without installing an agent.
Configuration
| Key | Default | Purpose |
|---|---|---|
enabled | true | Master switch for lifecycle recording |
table | queue_monitor | Table name |
connection | null | Database connection — null uses the default |
route.enabled | true | Register the dashboard routes |
route.prefix | queue-monitor | Dashboard URL prefix |
route.middleware | ['web'] | Dashboard guards — change this |
route.refresh | 5 | Auto-refresh interval in seconds |
recent_limit | 25 | Recent jobs shown in the console |
per_page | 15 | Jobs per dashboard page |
prune_after_hours | 72 | Retention window; null keeps everything |
connection is worth knowing about. On a busy application the monitor table takes a write per job transition, and putting it on a separate connection keeps that traffic off your primary database.
Retention and Pruning
Recording every job means the table grows forever unless you prune it. Three days is a sensible default — long enough to investigate Monday’s incident on Wednesday, short enough that the table stays small.
// routes/console.php (Laravel 11/12)
Schedule::command('queue-monitor:show --prune')->hourly();// app/Console/Kernel.php (Laravel 10 and earlier)
protected function schedule(Schedule $schedule): void
{
$schedule->command('queue-monitor:show --prune')->hourly();
}On an application processing hundreds of thousands of jobs a day, reconsider before enabling this globally. Each job becomes several row writes, and at high volume that is real database load. Either raise the pruning frequency, move it to a separate connection, or disable recording for your highest-volume job classes.
Querying It From Code
The records are a normal Eloquent model, so you can build on them:
use Jaydeep\QueueMonitor\Models\QueueMonitor;
// Everything that failed today
$failures = QueueMonitor::query()
->where('status', 'failed')
->whereDate('created_at', today())
->latest()
->get();
// Which job class fails most?
$worst = QueueMonitor::query()
->where('status', 'failed')
->selectRaw('job_name, count(*) as failures')
->groupBy('job_name')
->orderByDesc('failures')
->limit(5)
->get();
// Surface it on your own admin page
$counts = [
'pending' => QueueMonitor::where('status', 'pending')->count(),
'running' => QueueMonitor::where('status', 'running')->count(),
'failed24h' => QueueMonitor::where('status', 'failed')
->where('created_at', '>=', now()->subDay())
->count(),
];That second query is the one that pays for the package. “Which job class fails most” is a question nobody can answer from failed_jobs without writing SQL, and the answer is almost always surprising.
When to Use This vs Horizon
| Queue Monitor | Horizon | |
|---|---|---|
| Queue drivers | Any | Redis only |
| Extra infrastructure | None — one table | Redis |
| Worker auto-balancing | No | Yes |
| Throughput and wait-time metrics | Basic | Detailed, with history |
| Retry from the UI | No | Yes |
| Setup time | Two commands | Redis plus configuration |
The honest summary: if you are on Redis and can run Horizon, run Horizon — it does more. Queue Monitor is for the large number of projects that are not on Redis, do not want to add it, and currently have no visibility at all. Something small beats nothing.
Links
- Packagist: jaydeep/laravel-queue-monitor
- GitHub: JaydeepGadhiya/laravel-queue-monitor
- More of my work: jaydeepgadhiya.netlify.app