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 migrate

That 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
RequirementSupported
PHP7.4 – 8.4
Laravel8 – 12
Queue driversAny — database, Redis, SQS, Beanstalkd
LicenseMIT

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=false

The 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 --prune

This 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\"}"
fi

Put 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

KeyDefaultPurpose
enabledtrueMaster switch for lifecycle recording
tablequeue_monitorTable name
connectionnullDatabase connection — null uses the default
route.enabledtrueRegister the dashboard routes
route.prefixqueue-monitorDashboard URL prefix
route.middleware['web']Dashboard guards — change this
route.refresh5Auto-refresh interval in seconds
recent_limit25Recent jobs shown in the console
per_page15Jobs per dashboard page
prune_after_hours72Retention 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 MonitorHorizon
Queue driversAnyRedis only
Extra infrastructureNone — one tableRedis
Worker auto-balancingNoYes
Throughput and wait-time metricsBasicDetailed, with history
Retry from the UINoYes
Setup timeTwo commandsRedis 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.