ErrorReporter — Rate-Limited Exception Emails
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('dev@example.com')->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;
public static function report(Throwable $e): void
{
// 1. Bail early if no recipients configured
$recipients = self::recipients();
if ($recipients === []) {
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', []);
return array_filter(
is_array($configured) ? $configured : [$configured],
fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)
);
}
/**
* @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) {
if (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' => env('ERROR_REPORT_EMAILS', ''),
// In .env: ERROR_REPORT_EMAILS=dev@example.com,oncall@example.com
// config/error_reporting.php
return [
'sensitive_fields' => [
'password', 'password_confirmation', 'current_password',
'token', 'api_key', 'secret', 'card_number', 'cvv',
],
];
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