spatie/laravel-failed-job-monitor
Spatie's Laravel Failed Job Monitor sends notifications when a queued job fails. It uses Laravel's native notification system and can alert via mail or Slack, with configuration for enabled status, channels, email addresses, and Slack webhook URL.
1. Install
composer require spatie/laravel-failed-job-monitor
# only if you plan to use the Slack channel
composer require guzzlehttp/guzzle
The service provider is auto-discovered — no registration needed.
2. Publish the config
php artisan vendor:publish --tag=failed-job-monitor-config
3. Set environment variables
FAILED_JOB_MONITOR_ENABLED=true
FAILED_JOB_CHANNELS=mail,slack
[email protected],[email protected]
FAILED_JOB_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ
Where to look first
config/failed-job-monitor.php — the only file you touch 90% of the time. Four knobs matter: enabled, channels, notification, notifiable, plus the optional notificationFilter.Notification class — read it before overriding, since it defines the payload (job name, connection, queue, exception) and the Horizon deep-link behavior.JobFailed event and dispatches the configured notification.First use case
Get an alert on your phone within a minute of a real failure. Create a deliberately failing job, push it to your real queue worker (not sync), and confirm the mail/Slack message lands:
// app/Jobs/ExplodeJob.php
public function handle(): void
{
throw new \RuntimeException('Testing the failed job monitor');
}
php artisan tinker
>>> App\Jobs\ExplodeJob::dispatch();
If nothing arrives: check FAILED_JOB_MONITOR_ENABLED, check config('failed-job-monitor.channels'), and confirm your worker is the one that failed the job (a sync connection fails inline in tinker, which sometimes masks the issue).
The single most useful pattern is making channels differ per environment so local dev is silent and production is loud:
# .env.local / .env.testing
FAILED_JOB_MONITOR_ENABLED=false
# .env.staging
FAILED_JOB_CHANNELS=slack
FAILED_JOB_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/STAGING/...
# .env.production
FAILED_JOB_CHANNELS=slack,mail
[email protected],[email protected]
Because channels and mail.to come from explode(',', env(...)), keep the values comma-separated with no spaces — mail, slack produces a channel literally named " slack".
The default Notifiable is a plain "send to these addresses" object. Replace it when different jobs should reach different teams:
// app/Support/FailedJobNotifiable.php
namespace App\Support;
use Illuminate\Notifications\Notifiable as NotifiableTrait;
use Spatie\FailedJobMonitor\Notification;
class FailedJobNotifiable
{
use NotifiableTrait;
public function routeNotificationForMail(Notification $notification): array
{
$job = $notification->event->job::class ?? '';
return str_contains($job, 'Billing')
? ['[email protected]']
: config('failed-job-monitor.mail.to');
}
public function routeNotificationForSlack(Notification $notification): string
{
return config('failed-job-monitor.slack.webhook_url');
}
}
// config/failed-job-monitor.php
'notifiable' => \App\Support\FailedJobNotifiable::class,
This keeps zero business logic in the vendor config file and is trivially unit-testable.
Production queues always have one flaky job that would page you nightly. Use notificationFilter:
// app/Notifications/FailedJobFilter.php
namespace App\Notifications;
use Spatie\FailedJobMonitor\Notification;
class FailedJobFilter
{
public static function shouldNotify(Notification $notification): bool
{
$job = $notification->event->job ?? null;
// Ignore known-transient third-party timeouts
if ($job instanceof \App\Jobs\SyncCrmContact) {
return false;
}
// Ignore duplicate alerts for the same job within 15 minutes
$key = 'failed-job-alert:' . md5(serialize($notification->event->job));
return \Illuminate\Support\Facades\Cache::add($key, true, now()->addMinutes(15));
}
}
'notificationFilter' => [\App\Notifications\FailedJobFilter::class, 'shouldNotify'],
The method must be static — since 4.3.0 the package only invokes non-instance callables. Combined with Cache::add() this gives you free per-job throttling, which the package itself does not provide.
The stock notification gives you the job, connection, queue and exception. Teams usually want more: environment, app URL, deploy SHA, release tag, Horizon link. Extend rather than replace:
// app/Notifications/RichFailedJobNotification.php
namespace App\Notifications;
use Spatie\FailedJobMonitor\Notification as BaseNotification;
class RichFailedJobNotification extends BaseNotification
{
public function toSlack($notifiable): \Illuminate\Notifications\Messages\SlackMessage
{
return parent::toSlack($notifiable)
->attachment(function ($attachment) {
$attachment->fields([
'env' => app()->environment(),
'release' => config('app.release'),
'host' => gethostname(),
]);
});
}
}
'notification' => \App\Notifications\RichFailedJobNotification::class,
The notification automatically includes a "view in Horizon" link when Horizon is installed (it checks horizon.path, not the whole config, for performance). To make that link actually resolve to the failed job, tag your jobs so failures are findable:
public function tags(): array
{
return ['billing', 'customer:' . $this->customerId];
}
If you don't run Horizon, the link is suppressed automatically — no action needed.
The package goes through Laravel's notification system, so Notification::fake() works. Test the filter separately since it's just a static method:
it('alerts on a failed job', function () {
Notification::fake();
$this->expectException(\RuntimeException::class);
(new ExplodeJob())->handle();
});
it('suppresses alerts for known-noisy jobs', function () {
Notification::assertFalse(FailedJobFilter::shouldNotify($fakeNotification));
});
For end-to-end coverage, use an in-memory queue driver and assert Notification::assertSentTo(...) after the job exhausts its attempts — this verifies your config actually wires the right channel.
This package is an alerting tool, not a dashboard. Use it as the pager, and Horizon / a failed-jobs table as the investigation surface. Typical split:
failed_jobs table → triage, retry, inspect payload.Cache → prevents alert storms during an outage affecting thousands of jobs.Closures in config break config:cache. The README's own example is a trap:
'notificationFilter' => function ($n) { return true; }, // LogicException: not serializable
Always use [Class::class, 'staticMethod']. Run php artisan config:cache once locally in CI to catch it.
The filter method must be static. Since 4.3.0 the package checks whether the callable is a static method before invoking it. A non-static callable silently never filters (or errors depending on version) — you'll get alerts you thought you'd muted.
FAILED_JOB_MONITOR_ENABLED=true is the default. A fresh install in a dev environment with a broken local queue will start emailing the FAILED_JOB_EMAILS default ([email protected]). Explicitly set it to false in local/testing env files before your teammates get confused.
Slack needs Guzzle and a real webhook. Without guzzlehttp/guzzle the Slack channel throws. Without FAILED_JOB_SLACK_WEBHOOK_URL, you get a validation-style failure at send time — and because the monitor runs inside the failure path, that secondary exception can obscure the original job exception in logs. Guard by only listing slack in channels where the webhook exists.
explode(',', ...) does not trim. FAILED_JOB_CHANNELS="mail, slack" yields ['mail', ' slack'] and Laravel will throw Notification [slack] does not exist. If you need whitespace tolerance, override channels in the config file with array_map('trim', explode(',', env(...))).
After editing .env, the running worker is stale. Config is read per-process. php artisan queue:restart after changing channels, or your first alert after a config change may go to the old recipients. Also php artisan config:clear on any env that had config:cache run.
Immediate failures alert immediately. Calling $this->fail() inside a job, or a failure with $tries = 1, fires JobFailed right away. Jobs with retries/backoff only notify once attempts are exhausted — this is what you want, but it means a job stuck in a long retry chain sends you one late alert, not an early warning. If you want early warning, use JobExceptionOccurred with your own listener and throttle it.
Version pinning matters. v4.x requires PHP 8+ and Laravel 7+; on Laravel 5.8/6.x you need v3.x. The package supports Laravel 13 as of 4.3.6, so it's safe to keep on the latest minor.
No dedupe, no grouping, no quiet hours out of the box. A single broken downstream service can generate thousands of JobFailed events. The notificationFilter + Cache::add() pattern above is the cheapest mitigation; a slightly fancier one writes the failure into a failed_jobs-adjacent table and alerts once per job signature per window.
Extension points, in order of effort: notificationFilter (cheapest) → custom notification (change message shape) → custom notifiable (change recipients/routing) → listen to JobFailed yourself (full control, bypass the package). Don't jump straight to the last one; the filter and notifiable options cover most real needs.
Debugging checklist when no notification arrives: is enabled true? does channels contain a configured channel? does Notification::fake() in a test pass? is the worker process running with the current config? did the filter return false (add a logger() inside it temporarily)?
How can I help you explore Laravel packages today?