Laravel Exception Emails Without the Spam (Rate-Limited)
Series: Every Laravel Project Should Have These Building Blocks Part: 28 of 35 Level: Intermediate Prerequisites: Request Logger Middleware, Notifications and Mail What Youβll Learn Why exception notif
Series: Every Laravel Project Should Have These Building Blocks
Part: 28 of 35 Level: Intermediate Prerequisites: Request Logger Middleware, Notifications and Mail
What Youβll Learn
- Why exception notification needs rate-limiting
- The
Cache::add()deduplication pattern - Building the
ErrorReporterstatic service - The
ExceptionOccurredMailable with full context - Wiring into Laravelβs exception handler
- Sensitive field masking
The Problem with Naive Exception Emails
If you wire a simple exception email into bootstrap/app.php like this:
$exceptions->report(function (Throwable $e): void {
Mail::to('[email protected]')->send(new ExceptionOccurred($e));
});
One runtime error β say, a database timeout during a traffic spike β fires that email 300 times per minute. Your inbox is flooded. The mail queue backs up. The serverβs mail rate limit kicks in. And you still donβt know what caused the spike.
The ErrorReporter solves this with a deduplication window: the same exception only fires one email per 5 minutes, regardless of how many times it occurs.
The Deduplication Key
The key insight: Cache::add() only sets a value if the key doesnβt already exist. It returns true if the key was newly set, false if it already existed:
// Only proceeds on the FIRST occurrence within 5 minutes
if (! Cache::add($dedupKey, true, now()->addMinutes(5))) {
return; // already reported this exception recently
}
This is simpler and more reliable than Cache::get() + Cache::put(), because add() is atomic β no race condition between the check and the set.
The Full ErrorReporter
// app/Services/Core/ErrorReporter.php
<?php
declare(strict_types=1);
namespace App\Services\Core;
use App\Http\Middleware\RequestLogger;
use App\Mail\ExceptionOccurred;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class ErrorReporter
{
private const DEDUP_MINUTES = 5;
/**
* Never throws. This runs inside Laravel's exception handler: an error here
* (say, the database cache store during a database outage) would otherwise
* hide the very exception you need to see.
*/
public static function report(Throwable $e): void
{
try {
self::send($e);
} catch (Throwable $reporterError) {
Log::error('ErrorReporter could not send the alert.', [
'exception' => $reporterError,
'original_exception' => $e,
]);
}
}
private static function send(Throwable $e): void
{
// 1. Bail early if no recipients configured, but say so: a blank
// ERROR_REPORT_EMAILS would otherwise drop every alert silently
$recipients = self::recipients();
if ($recipients === []) {
Log::warning('ErrorReporter: no valid recipients configured, alert dropped.');
return;
}
// 2. Rate-limit: skip if we reported this same exception recently
$dedupKey = 'exception:' . hash('sha256',
$e::class . $e->getMessage() . $e->getFile() . $e->getLine()
);
if (! Cache::add($dedupKey, true, now()->addMinutes(self::DEDUP_MINUTES))) {
Log::channel('error')->debug('Exception suppressed (dedup)', [
'class' => $e::class,
'message' => $e->getMessage(),
]);
return;
}
// 3. Log the exception to the dedicated error channel
Log::channel('error')->error('Exception reported', [
'class' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
'url' => request()->fullUrl(),
'user_id' => auth()->id(),
]);
// 4. Build the payload and send the email
$payload = self::buildPayload($e);
try {
Mail::to($recipients)->send(new ExceptionOccurred($payload));
} catch (Throwable $mailException) {
// If mail fails, at least log it
Log::channel('error')->critical('ErrorReporter failed to send email', [
'mail_error' => $mailException->getMessage(),
]);
}
}
/**
* @return array<string>
*/
private static function recipients(): array
{
$configured = config('app.error_report_emails', []);
// Accept an array or a comma-separated string from .env
$emails = is_array($configured)
? $configured
: explode(',', (string) $configured);
return array_values(array_filter(
array_map('trim', $emails),
fn (string $email): bool => filter_var($email, FILTER_VALIDATE_EMAIL) !== false
));
}
/**
* @return array<string, mixed>
*/
private static function buildPayload(Throwable $e): array
{
$request = request();
return [
'environment' => app()->environment(),
'exception_class' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'url' => $request->fullUrl(),
'method' => $request->method(),
'input' => self::sanitizeInput($request->all()),
'user_id' => auth()->id(),
'user_type' => self::resolveGuard(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'request_id' => $request->header(RequestLogger::X_REQUEST_ID),
'reported_at' => now()->toISOString(),
];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private static function sanitizeInput(array $input): array
{
$sensitive = config('error_reporting.sensitive_fields', [
'password',
'password_confirmation',
'current_password',
'token',
'secret',
'api_key',
'card_number',
'cvv',
]);
foreach ($sensitive as $field) {
if (array_key_exists($field, $input)) {
$input[$field] = '[REDACTED]';
}
}
return $input;
}
private static function resolveGuard(): ?string
{
foreach (['admin', 'merchandiser', 'employee', 'web'] as $guard) {
// Skip guards this app doesn't define; auth('missing') throws
if (config("auth.guards.{$guard}") !== null && auth($guard)->check()) {
return $guard;
}
}
return null;
}
}
The ExceptionOccurred Mailable
// app/Mail/ExceptionOccurred.php
class ExceptionOccurred extends Mailable
{
public function __construct(
private readonly array $payload
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[{$this->payload['environment']}] {$this->payload['exception_class']}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.exception-occurred',
with: ['payload' => $this->payload],
);
}
}
The view renders: exception class, message, file/line, stack trace (collapsible), URL, method, sanitized input, user info, request_id, and environment. Everything you need to debug without SSH access.
Wiring into the Exception Handler
In bootstrap/app.php (Laravel 11):
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->report(function (Throwable $e): void {
if (! app()->isProduction()) {
return; // Only email on production
}
// Skip common non-errors
if ($e instanceof \Illuminate\Auth\AuthenticationException) {
return;
}
if ($e instanceof \Illuminate\Validation\ValidationException) {
return;
}
if ($e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
return;
}
ErrorReporter::report($e);
});
})
Configuration
// config/app.php
'error_report_emails' => array_filter(array_map('trim', explode(',', (string) env('ERROR_REPORT_EMAILS', '')))),
// In .env: [email protected],[email protected]
// config/error_reporting.php
return [
'sensitive_fields' => [
'password', 'password_confirmation', 'current_password',
'token', 'api_key', 'secret', 'card_number', 'cvv',
],
];
FAQs
How do I email Laravel exceptions without spam?
Rate-limit by exception signature (class + message + file) with a cache lock β first occurrence emails, repeats within the window are counted, not sent.
Is this a replacement for Sentry?
For small apps, yes β zero cost, zero third parties. Past a few thousand errors/day, use a dedicated tracker.
Key Takeaways
-
Cache::add()is atomic β use it (notget+put) for dedup logic. - The dedup key includes exception class + message + file + line β enough specificity without being too granular.
- Log to
errorchannel AND email β the log is for correlation, the email is for alerting. - Sanitize input before including it in the email payload β never email passwords or tokens.
- Skip 401/403/404/422 exceptions β theyβre user errors, not application errors.
- Wrap the
Mail::send()call itself in try/catch β a broken mail connection should not prevent the error from being logged.
Tips and Gotchas
β οΈ Warning: The dedup key includes file + line number. If you deploy a refactor that moves exception-throwing code to a different line, the dedup key changes and the first occurrence after deploy fires a new email. This is correct behaviour β itβs a new location β but worth knowing during post-deploy monitoring.
π‘ Tip: Test your
ErrorReporterin staging by temporarily settingDEDUP_MINUTES = 0or callingCache::flush(). Then trigger a deliberate exception and verify the email arrives with the correct payload, sanitized input, andrequest_id.π₯ Expert Note: The
Cache::add()deduplication pattern is universally applicable: rate-limiting any one-per-N-minutes action, ensuring an idempotency key is only processed once, or preventing duplicate webhook acknowledgements. Learn it once, use it everywhere.
Further Reading
- Laravel Docs: Error Handling
- Laravel Docs: Cache β
Cache::add()atomic behavior - Laravel Docs: Mail
- Sentry for Laravel β a managed alternative to rolling your own error reporter
β Request Logger Middleware | Next: Slow Query Detection β
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.