cerbero/notifiable-exception is a small, zero-config package: it lets a specific exception class carry its own notification logic, so that when Laravel's exception handler reports it, the right people get pinged instead of the event disappearing into storage/logs/laravel.log.
composer require cerbero/notifiable-exception
There is nothing to publish — no config file, no migration, no service provider to register (it's auto-discovered). Check compatibility before you commit:
composer show cerbero/notifiable-exception # confirm PHP/Laravel constraints
Where to look first. The package is a handful of files. Read vendor/cerbero/notifiable-exception/src/ in five minutes — the contract is the whole API, and the README's example is the canonical shape. Every notifiable exception answers two questions: who should be notified and what notification should be sent. Names may vary slightly between versions, so confirm the exact methods against the interface before writing ten exception classes.
First use case. A business-level failure that a human must act on, not a stack trace you'll grep later:
namespace App\Exceptions;
use App\Notifications\PaymentFailedNotification;
use Cerbero\NotifiableException\NotifiableException;
use Illuminate\Notifications\Notification;
use RuntimeException;
class PaymentFailedException extends RuntimeException implements NotifiableException
{
public function notifiables(): array
{
return User::where('role', 'billing')->get();
}
public function toNotification(): Notification
{
return new PaymentFailedNotification($this->getMessage());
}
}
Throw it as usual — the notification fires when the exception is actually reported, i.e. when it reaches the handler uncaught:
throw new PaymentFailedException("Gateway declined order {$order->id}");
Because it rides on top of Laravel's notification system, you immediately get mail, Slack, SMS, database channels, ShouldQueue, and Notification::fake() in tests for free.
Opt-in per exception class. This is the package's real value: nothing is notifiable unless you say so. Reserve it for exceptions that represent a business condition (payment declined, webhook signature invalid, import failed, third-party quota exhausted). Let genuine bugs go to your error tracker.
Keep a base class in your app. Rather than repeating the contract on every exception, define one:
abstract class NotifiableException extends RuntimeException implements NotifiableException
{
public function notifiables(): array
{
return User::whereIn('role', ['admin', 'oncall'])->get();
}
}
Subclasses then only override toNotification(). If a subclass needs different recipients, it overrides notifiables() too.
On-demand routing for ops channels. Slack/ops channels aren't Notifiable models. Return an empty array (or omit the model lookup) and route explicitly:
public function notifiables(): array
{
return [Notification::route('slack', config('services.slack.alerts_webhook'))];
}
Capture context at throw time, not at notification time. By the time report() runs, auth(), the request, and the session may be in an odd state — especially in queue workers. Pass scalars into the exception constructor:
throw new ImportFailedException($import->id, $import->user_id, $rowCount);
and let the notification read those properties. Never re-query the database from inside a notification that may run on a queue after the row was deleted.
Deduplicate before you fan out. A failing cron job can throw the same exception every minute:
public function notifiables(): array
{
$key = 'notify:import-failed:'.$this->importId;
return Cache::add($key, true, now()->addMinutes(15))
? User::where('role', 'oncall')->get()
: [];
}
Returning an empty array means "report it, but stay quiet."
Escalate on repetition. First occurrence Slack, third occurrence email the lead — the exception object is a fine place to encode that threshold logic, since it's constructed fresh on each throw.
Queue the notification, not the exception. Implement ShouldQueue on the notification and pass scalars/IDs to it; the notification's constructor should not hold the Throwable (see Gotchas). If you need the stack trace in the payload, store the formatted string eagerly:
new ImportFailedNotification($this->importId, $this->getTraceAsString())
Test it like any other notification. The package adds no testing surface of its own:
Notification::fake();
try {
$this->post('/imports');
} catch (ImportFailedException $e) {
// or use $this->withoutExceptionHandling()
}
Notification::assertSentTo($admin, ImportFailedNotification::class);
Note that testing through the HTTP layer requires the exception handler to actually run — withoutExceptionHandling() will bypass the reporting path entirely.
Only reported exceptions notify. If something catches your exception and swallows it, no notification is sent — by design. Conversely, in queue workers, failed() jobs and retries can report the same exception multiple times. Combine the package with the dedupe cache above, or you'll learn this from your Slack bill.
Returning false from the reporting method silences logging. Laravel's handler honours a false return from an exception's report hook to skip default reporting. If you override the hook to add your own logic, don't accidentally return false — you'll lose the log entry while keeping the Slack ping.
Serialization is the sharpest edge. A queued notification is serialized into the job payload. An Exception instance carries its trace, file contents, and potentially closures, PDO handles, or the whole request via previous. Queued notifications built around new MyNotification($this) frequently fail with Serialization of 'Closure' is not allowed or bloat to hundreds of KB. Keep exceptions out of queued notifications.
Beware notification-induced recursion. If a channel throws (bad Slack webhook, malformed mail config), that exception is reported too — and if it's notifiable, you get a loop. Wrap the send in a try/catch and log locally, or ensure your notifiable exceptions are leaf nodes that never touch notification infrastructure.
Don't confuse this with error tracking. Sentry/Bugsnag/Flares already handle "something broke." This package is for "something happened that a person must know about now." If you're wiring QueryException into it, you've built a worse Sentry — reach for the tracker instead.
Environment guards are on you. Nothing stops notifications firing in local or CI. Either add if (app()->environment('local', 'testing')) return []; to your base class, or Notification::fake() in your test suite. Forgetting this produces a memorable afternoon of Slack spam from a test suite.
Respect $dontReport. HttpException, ModelNotFoundException, and auth exceptions are already excluded by Laravel's internal list, and anything you add to App\Exceptions\Handler::$dontReport is reported-never — so the package silently does nothing for those. If a notification "isn't firing," check that list before you debug the package.
The package is small and lightly maintained (76 stars, no dependents). It's ~a few hundred lines that hook into an existing Laravel convention, so the blast radius is low — but pin the version in composer.json and read the source rather than trusting the docs. If it ever breaks against a new Laravel major, replicating it yourself is a one-afternoon job: implement the reporting hook on a base exception and call Notification::send().
Extension point: the package deliberately doesn't own delivery — every queue, channel, retry, and rate-limit concern is Laravel's. Custom channels, ShouldQueue, via() filtering by recipient preference, and notification events all work normally, which is the best reason to use this over a bespoke Mail::raw() in a catch block.
How can I help you explore Laravel packages today?