sandermuller / laravel-queue-insights
Self-hosted, driver-agnostic queue observability for Laravel. Per-class throughput, durations, failures, and live depth/in-flight/delayed metrics with a Livewire dashboard.
Package info
github.com/SanderMuller/laravel-queue-insights
pkg:composer/sandermuller/laravel-queue-insights
Requires
- php: ^8.3
- aws/aws-sdk-php: ^3.0
- illuminate/console: ^11.0||^12.0||^13.0
- illuminate/contracts: ^11.0||^12.0||^13.0
- illuminate/queue: ^11.0||^12.0||^13.0
- illuminate/redis: ^11.0||^12.0||^13.0
- illuminate/support: ^11.0||^12.0||^13.0
Requires (Dev)
- driftingly/rector-laravel: ^2.3
- larastan/larastan: ^3.9.3
- laravel/boost: ^2.4.2
- laravel/pint: ^1.29
- livewire/livewire: ^3.0 || ^4.0
- mockery/mockery: ^1.6
- mrpunyapal/rector-pest: ^0.2.7
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0||^10.0||^11.0
- pestphp/pest: ^3.0||^4.0
- pestphp/pest-plugin-arch: ^3.0||^4.0
- pestphp/pest-plugin-laravel: ^3.0||^4.0
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan-deprecation-rules: ^2.0.4
- phpstan/phpstan-phpunit: ^2.0.16
- phpstan/phpstan-strict-rules: ^2.0.10
- predis/predis: ^2.2
- rector/rector: ^2.4.1
- rector/type-perfect: ^2.1.2
- sandermuller/package-boost: ^0.9.0
- spaze/phpstan-disallowed-calls: ^4.10
- symplify/phpstan-extensions: ^12.0.2
- tomasvotruba/cognitive-complexity: ^1.1
- tomasvotruba/type-coverage: ^2.1
Suggests
- livewire/livewire: Required only to use the bundled dashboard route. Capture + snapshot run without it.
README
Self-hosted queue observability for Laravel. A Horizon-style dashboard that doesn't lock you into the Redis queue driver.
Features
- Live depth, in-flight, and delayed counts per queue. Works on SQS, Redis, and database queues.
- Wait time per queue (p50 / p95) and per job. Measures enqueue to worker pickup.
- 24h throughput sparkline (processed + failed) with hover tooltips per hour, alongside a headline-stats panel: jobs/min, jobs past hour, failed past hour, max throughput hour, max wait p95, max runtime p95.
- Queues grouped into Needs attention (errored or stale) and Healthy so a broken queue can't hide in a long list.
- Per-job-class metrics: 24h processed and failed, average and max duration, last run.
- Recent completed jobs. Metadata-only by default; opt-in payload capture with a pluggable sanitizer. Filter row mirrors the failed-jobs filter (connection, queue, class, from, to).
- Recent failed jobs from Laravel's
failed_jobstable, with a filter row over connection, queue, class, and date range. Filters persist in the URL. - Retry failed jobs from the dashboard, single or bulk. Gated, rate-limited, and audit-logged.
- Markdown export of failed-job details for handing off to an AI agent or pasting into a tracker.
- Standalone Livewire + Blade. No Filament or Nova coupling.
- Small Redis footprint, bounded and auto-evicting. No external observability service required.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
- Redis (for insights storage)
livewire/livewire3 or 4 (only if you use the bundled dashboard route).
CI runs against three Livewire resolver legs: Livewire 3.0, Livewire 3 latest, and Livewire 4 latest. Coverage is PHP-side only. The JS and Alpine paths aren't browser-tested, so do a smoke render in your own staging before upgrading the host.
Install
composer require sandermuller/laravel-queue-insights php artisan vendor:publish --tag=queue-insights-config
The service provider auto-discovers.
Payload capture
Off by default. Laravel payloads embed serialized and sometimes encrypted job state, and a regex over JSON keys can't sanitize that safely.
Three modes via QUEUE_INSIGHTS_CAPTURE_PAYLOADS:
| Mode | Behavior |
|---|---|
off (default) |
No payload persisted. |
metadata |
displayName, maxTries, timeout, backoff only. No user data, no serialized command body. |
full |
Raw body after a sanitizer pass. Apps with sensitive jobs MUST bind a custom PayloadSanitizer that understands their job shape. |
Read SECURITY.md before enabling full.
Dashboard
Mounts at /queue-insights when dashboard.enabled=true and livewire/livewire is installed. Define the viewQueueInsights Gate in your app:
// app/Providers/AuthServiceProvider.php Gate::define('viewQueueInsights', fn ($user) => $user->isAdmin());
Retry permissions (write actions)
Retrying a failed job is a write action and needs its own Gate, separate from the read-only viewQueueInsights:
Gate::define('retryFailedJobs', fn ($user) => $user->isAdmin());
Without that Gate, the Retry button stays hidden in the failed-job modal, the bulk Retry button stays hidden above the failed-jobs table, and direct calls to the underlying Livewire methods (retryFailed, retryFailedBulk) return 403.
The retry path uses Laravel's first-party queue:retry Artisan command, so it's idempotent against an already-retried row and works regardless of queue driver.
Guards on the retry path:
- 30 retries per minute, per user.
- The server rejects a bulk retry when the matching set is over 100 rows. The UI shows a "narrow to retry" hint instead of the action button.
- The server also rejects a bulk retry when no filter is set, so you can't accidentally one-click retry every failed job.
- Every retry writes an
info-level log line with channelqueue-insights.retry, including the user id and the active filter set. Forward that to your audit log.
Retry workflow
To triage a failed job:
- Open the dashboard and find the row in the Recent failed list.
- Optional: click Filter ⌄ above the list and narrow by connection, queue, class, or date range. The URL updates as you change a field, so the filtered view is shareable.
- Click any row to open the failed-job modal. You'll see the exception, stack trace, payload, and metadata.
- To retry one job, click Retry in the modal header. The button flips to a red "Confirm retry?" for two seconds; click again to fire. The modal closes and a green banner confirms dispatch. If
queue:retryexits non-zero, you get a red banner instead of a misleading success. - To retry several at once, set at least one filter. A Retry N jobs button appears next to the section heading, with the same two-click confirm pattern. Anything matching more than 100 rows shows a N matches · narrow to retry hint instead of an action button.
A failed retry never leaves the dashboard in a half-broken state. The row is either re-dispatched (and removed from failed_jobs) or left alone.
Filtering
Both Recent completed and Recent failed have a collapsible filter row above the list. Click Filter ⌄ to expand. Each field binds to a short query-string key, so a narrowed view is shareable and bookmarkable.
Connection, Queue, and Class are populated as <select> dropdowns from the configured snapshots and the 24h class roster — no free-text typos.
Recent failed filter
| Field | Query-string key | Match semantics |
|---|---|---|
| Connection | fc |
Exact (connection column) |
| Queue | fq |
Exact (queue column) |
| Class | fk |
Anchored prefix substring on payload.displayName, case-insensitive |
| From | ffrom |
failed_at >= <Y-m-d> 00:00:00 |
| To | fto |
failed_at <= <Y-m-d> 23:59:59 |
The class filter avoids JSON-extract syntax, which diverges across MySQL, Postgres, and SQLite. Instead it runs LOWER(payload) LIKE '%"displayname":"<input>%', which produces the same match set on all three. Picking App\Jobs\SendEmail matches that exact class, and the underlying LIKE semantics still anchor the prefix so e.g. selecting a parent namespace would match its descendants.
The filter row also drives the bulk-retry scope. The Retry N jobs button retries the same set the list is showing.
Recent completed filter
Same five fields, separate state, separate query-string keys. Class is pre-filtered at the storage layer (per-class Redis stream key); the other four narrow the already-fetched 50-row default cap in PHP.
| Field | Query-string key | Match semantics |
|---|---|---|
| Connection | cc |
Case-insensitive substring |
| Queue | cqu |
Case-insensitive substring |
| Class | ck |
Exact FQCN — picks a single per-class stream |
| From | cfrom |
processed_at >= <Y-m-d> 00:00:00 |
| To | cto |
processed_at <= <Y-m-d> 23:59:59 |
Wait time
Wait time is the gap between enqueue and worker pickup. Duration is the gap between worker pickup and completion. They're different numbers, and wait time is the one to look at when depth / in-flight look fine but jobs feel slow.
It shows up in two places:
- Queue rows show a
p50 / p95Wait column, computed over the most recent 1000 jobs on that queue and refreshed every poll. Shows—until 10 samples have accumulated. - The completed-job and failed-job modals show
wait <human> (NN ms)next to the Duration row. Shows—for jobs queued before theJobQueuedlistener was wired, and for drivers that don't stamppayload.uuid.
Capture is automatic. Installing the package wires an Illuminate\Queue\Events\JobQueued listener that records the enqueue timestamp, so no host-app config is needed. The cost per job is one Redis SETEX at push, plus a GET + ZADD + ZREMRANGEBYRANK + EXPIRE chain at worker pickup. Retention: 1h on the per-uuid pushed: key, 7d on the per-uuid wait: sample, rolling 1000 most-recent on the per-queue ZSET.
A 7-day clock-skew guard rejects any wait sample over that, so a producer host with bad NTP can't poison the percentile pool indefinitely.
Customising row markup
The dashboard's queue, completed, and failed lists are each rendered through a Blade partial, plus a shared filter-form partial. They're publishable — a host that wants to swap a row's columns or restyle the filter chrome can publish the partials and edit them in place without forking the whole dashboard.blade.php view:
php artisan vendor:publish --tag=queue-insights-views
| Partial | What it renders |
|---|---|
partials/queue-row.blade.php |
One row in the Queues list (Needs attention + Healthy groups) |
partials/completed-row.blade.php |
One row in Recent completed |
partials/failed-list-row.blade.php |
One row in Recent failed |
partials/filter-form.blade.php |
The collapsible 5-field filter form (used by both completed + failed) |
partials/stat-tile.blade.php |
One tile in the headline-stats panel beside the throughput sparkline |
If you only want to override one row layout, leave the others unpublished — Blade will fall back to the package's bundled version for those.
Embedding the dashboard inside an admin layout
Disable the bundled route and mount the Livewire component yourself:
// config/queue-insights.php 'dashboard' => ['enabled' => false, /* ... */],
{{-- resources/views/admin/queue-insights.blade.php --}} @extends('admin.layout') @section('content') @livewire('queue-insights-dashboard') @endsection
Custom payload sanitizer
The default KeyRedactingSanitizer can't see inside PHP-serialized data.command bodies. Apps with sensitive jobs should bind their own:
// app/Providers/AppServiceProvider.php use SanderMuller\QueueInsights\Contracts\PayloadSanitizer; $this->app->bind(PayloadSanitizer::class, YourSanitizer::class);
Ops runbook
Dashboard signals
| Signal | Meaning |
|---|---|
— on in-flight / delayed |
Driver can't produce the metric (Null / sync), or the live cache expired (>90s since the last successful snapshot). |
stale badge |
No snapshot ran in the last 2 minutes. |
error badge |
Last snapshot run failed for this queue. Hover for the error message (10-minute TTL). |
no snapshot yet |
The command has never completed successfully against this queue. |
Driver-specific quirks
- SQS values are AWS approximations.
GetQueueUrlis cached for 1h in Redis; the first run per new queue name costs one extra API call. - Redis reads
LLEN queues:{name}plusZCARDon:reservedand:delayed. Matches Laravel's own queue key convention. - Database depth includes rows whose reservation has expired (crashed workers leave their jobs poppable again). Matches
DatabaseQueue::getNextAvailableJob()exactly.
Key-prefix strategies
- Shared Redis (multi-tenant, or multiple apps or envs on the same Redis): keep the default
QUEUE_INSIGHTS_KEY_PREFIX=qm:{APP_ENV}:. Safe against collision. - Dedicated Redis: override to
QUEUE_INSIGHTS_KEY_PREFIX=qm:to drop the env segment and shorten every key.
Alerting
Enable via QUEUE_INSIGHTS_ALERTS_ENABLED=true and declare thresholds in config/queue-insights.php:
'alerts' => [ 'enabled' => true, 'cooldown_seconds' => 900, 'thresholds' => [ ['connection' => 'sqs', 'queue' => 'work', 'depth' => 1000], ], ],
Listen for SanderMuller\QueueInsights\Events\QueueDepthExceeded and route notifications via Notification::route(...) (Slack, Teams, email, PagerDuty, etc.).
License
MIT. See LICENSE.