Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Failed Job Monitor Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.
  • The package's Notification class — read it before overriding, since it defines the payload (job name, connection, queue, exception) and the Horizon deep-link behavior.
  • The service provider — it's thin; it registers a listener for Laravel's 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).

Implementation Patterns

Environment-based channel routing

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".

A custom notifiable for routing by job class

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.

Filtering noise with a static callable class

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.

Custom notification for richer context

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,

Horizon integration

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.

Testing the behavior

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.

Pairing with a queue observability layer

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:

  • This package → "something broke, someone look now."
  • Horizon / failed_jobs table → triage, retry, inspect payload.
  • A custom notifiable with Cache → prevents alert storms during an outage affecting thousands of jobs.

Gotchas and Tips

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)?

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core