See into any queue.
Periscope is a universal queue monitor, and manage for Laravel — a driver-agnostic alternative to Laravel Horizon. It works with any queue driver: Redis, database, SQS, Beanstalkd, and more, because it collects telemetry through Laravel's built-in queue events rather than reading driver-specific internals.
- Features
- Requirements
- Installation
- Running workers
- Scheduling
- Authorization
- Metrics endpoint
- Alerts
- Tags
- Batches
- Scheduled commands
- Performance percentiles
- Commands
- Screenshots
- Testing
- Contributing
- License
- Universal — works with
redis,database,sqs,beanstalkd, andsyncqueue drivers - Real-time dashboard built on Livewire 4 + Tailwind 4 — no CDN, ships with a compiled CSS bundle
- Per-attempt tracking — every retry recorded with its runtime and exception, shown as a timeline on the job detail page
- Grouped exceptions — aggregate by class + message so you see "this
RuntimeExceptionhit 47 times" instead of 47 rows, with drill-down to individual occurrences - Batch tracking — live progress for
Bus::batch()jobs with cancel action - Scheduled command monitoring — every
Schedule::command()run recorded with runtime and failure reason - Performance percentiles — p50 / p95 / p99 of runtime and wait, per queue
- Memory tracking — peak memory captured per attempt
- Alert history — every fired rule persisted, searchable, and dismissible
- Live queue depth via driver adapters — pending, delayed, and reserved counts on the Queues page
- Throughput chart — last 60 minutes of processed vs failed jobs, polled every 10s
- Worker pools — config-driven supervisors with optional auto-balance that moves processes between queues based on backlog depth
- Lifecycle control —
start/pause/continue/terminatefor deploy-friendly operation - Failed job management — search, filter, retry, forget, and bulk actions
- Alerts — failure spike, long wait, stale worker; delivered via mail, Slack, or webhook
- Prometheus + JSON metrics endpoint for external monitoring stacks (Grafana, Datadog, etc.)
- Authorization gate —
viewPeriscopeability, scoped tolocalby default - Rolling metrics — per-minute buckets rolled up into hourly, with configurable retention per tier
- Tag filtering via Laravel's
Queueable::tags()
- PHP 8.2+
- Laravel 11, 12, or 13
- Livewire 4
composer require maherelgamil/periscope
php artisan periscope:install
php artisan migrateVisit /periscope in your browser. The dashboard is gated to local environment by default — see Authorization to expose it elsewhere.
Workers must be launched through Periscope so heartbeats show up on the Workers page.
php artisan periscope:supervise redis --queue=default,emailsSame flags as queue:work (--tries, --timeout, --memory, --max-jobs, --max-time, etc.).
Define supervisors in config/periscope.php:
'supervisors' => [
'default' => [
'connection' => 'redis',
'queue' => ['default', 'emails'],
'processes' => 4,
'tries' => 1,
'timeout' => 60,
'sleep' => 1,
],
'notifications' => [
'connection' => 'redis',
'queue' => ['notifications'],
'processes' => 2,
'nice' => 10, // deprioritize with Unix nice (ignored on Windows)
],
],Start every pool with:
php artisan periscope:startThe master process respawns any child that crashes and shuts children down cleanly on SIGTERM / SIGINT. Run with --supervisor=default to start only a specific pool.
Set balance: 'auto' and Periscope allocates processes per queue proportional to live queue depth on each cycle, clamped between min_processes and max_processes:
'default' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'min_processes' => 1,
'max_processes' => 10,
],When all queues are empty, each queue runs min_processes workers. When backlog grows, processes are moved toward the busiest queues automatically.
# Stop spawning new workers; let running ones drain
php artisan periscope:pause
# Resume
php artisan periscope:continue
# Shut the master down entirely (use this in your deploy script)
php artisan periscope:terminateAdd the housekeeping commands to routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('periscope:workers:sweep')->everyMinute();
Schedule::command('periscope:snapshot')->hourly();
Schedule::command('periscope:alerts:check')->everyFiveMinutes();
Schedule::command('periscope:prune')->daily();Running periscope:install scaffolds app/Providers/PeriscopeServiceProvider.php in your application (and registers it in bootstrap/providers.php). Edit the gate() method to decide who can access the dashboard in non-local environments:
protected function gate(): void
{
Gate::define('viewPeriscope', function ($user) {
return in_array($user?->email, [
'you@example.com',
]);
});
}If you don't override it, access is restricted to the local environment. The metrics and health endpoints bypass this gate — see below.
Periscope exposes aggregated telemetry at:
/periscope/metrics— Prometheus text format (scrape target)/periscope/metrics.json— JSON for custom integrations
Both bypass the dashboard authorization by default. Protect them for production with your own middleware (IP allowlist, token guard):
// config/periscope.php
'metrics' => [
'enabled' => env('PERISCOPE_METRICS_ENABLED', true),
'middleware' => ['web', \App\Http\Middleware\AllowPrometheus::class],
],Or disable entirely:
PERISCOPE_METRICS_ENABLED=falseExample Prometheus scrape config:
scrape_configs:
- job_name: periscope
metrics_path: /periscope/metrics
static_configs:
- targets: ['your-app.test']Exposed metrics (each labelled with connection and queue where applicable):
| Metric | Type | Description |
|---|---|---|
periscope_jobs_processed_total |
counter | Successfully processed jobs |
periscope_jobs_failed_total |
counter | Jobs that failed |
periscope_jobs_queued_total |
counter | Jobs pushed onto a queue |
periscope_runtime_ms_sum |
counter | Cumulative runtime (ms) |
periscope_wait_ms_sum |
counter | Cumulative wait time (ms) |
periscope_queue_pending |
gauge | Jobs ready to process (live from driver) |
periscope_queue_delayed |
gauge | Jobs scheduled for the future |
periscope_queue_reserved |
gauge | Jobs currently claimed by a worker |
periscope_workers{status} |
gauge | Worker counts by status |
periscope_jobs_current{status} |
gauge | Monitored jobs by current status |
Three rules ship in the box:
failure_spike— more than N failed jobs in M minuteslong_wait— average wait exceeds a thresholdstale_worker— any worker has missed its heartbeat window
Each rule has a per-rule cooldown to prevent flooding. Configure thresholds in config/periscope.php and wire channels with env vars:
PERISCOPE_ALERT_CHANNELS=mail,slack,webhook
PERISCOPE_ALERT_MAIL=ops@example.com
PERISCOPE_ALERT_SLACK_WEBHOOK=https://hooks.slack.com/services/...
PERISCOPE_ALERT_WEBHOOK_URL=https://your-app.test/webhooks/periscopeWebhook payload shape:
{
"key": "failure_spike",
"title": "Queue failure spike",
"message": "23 job(s) failed in the last 5 minutes (threshold: 10).",
"severity": "error",
"context": {"count": 23, "minutes": 5, "threshold": 10},
"fired_at": "2026-04-17T20:30:00+00:00",
"source": "periscope"
}Schedule periscope:alerts:check every few minutes to evaluate the rules.
Any ShouldQueue job can expose tags by implementing a tags() method:
class SendInvoice implements ShouldQueue
{
use Queueable;
public function __construct(public Invoice $invoice) {}
public function tags(): array
{
return ['invoices', "customer:{$this->invoice->customer_id}"];
}
}Tags show on the job detail page and can be filtered on the Jobs page via the Tag input.
Periscope reads Laravel's native job_batches table, so there's no extra setup beyond running Laravel's own php artisan make:queue-batches-table + migration. The Batches page shows:
- live progress bar (completed / total)
- pending and failed counts
- cancel button for in-flight batches
Any job that uses the Batchable trait and is dispatched via Bus::batch([...]) appears automatically.
Periscope subscribes to Laravel's scheduler events (ScheduledTaskStarting, Finished, Failed, Skipped) so every run of Schedule::command(...) is recorded with its runtime, exit status, and cron expression. The Schedules page shows the last N runs with failure messages for diagnosing cron issues.
No configuration needed — if your app uses the Laravel scheduler, it just works.
The Performance page computes p50 / p95 / p99 of both runtime and wait time for every queue that had completed jobs in the selected window (1 hour, 6 hours, 24 hours, or 7 days). Percentiles are calculated in PHP from a sorted sample so they work against every supported queue driver without needing database-specific percentile functions.
The Exceptions page aggregates failures by exception_class + message. Click any row to drill into the /exceptions/show page, which lists every occurrence in the window, the affected jobs, and a sample stack trace you can click through.
Every fired alert is persisted to the periscope_alerts table so you can audit what rules triggered, when, and which channels received them. The Alerts page lists all firings and lets you dismiss rows you've triaged.
| Command | Purpose |
|---|---|
periscope:install |
Publish config, migrations, and compiled assets |
periscope:supervise {connection} |
Run a single queue:work with heartbeat reporting |
periscope:start |
Boot all supervisors from config (respawns children, handles SIGTERM) |
periscope:pause |
Stop spawning new workers; drain running ones |
periscope:continue |
Resume after a pause |
periscope:terminate |
Signal the master to shut down |
periscope:workers:sweep |
Mark workers stale past their heartbeat window |
periscope:snapshot |
Roll minute metrics into hourly buckets |
periscope:prune |
Delete old jobs and metrics per retention config |
periscope:alerts:check |
Evaluate alert rules and dispatch notifications |
Run any command with --help for full flag reference.
Click to expand
composer install
./vendor/bin/pestThe suite uses Orchestra Testbench with in-memory SQLite. CI runs the full matrix of PHP 8.2 / 8.3 / 8.4 × Laravel 11 / 12 / 13 on every push.
Bug reports and pull requests are welcome. Please run ./vendor/bin/pint and ./vendor/bin/pest before opening a PR.
Security issues: please email maherelgamil@gmail.com rather than filing a public issue.
- Maher ElGamil
- All contributors
The MIT License (MIT). See LICENSE for the full text.











