Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

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 ErrorReporter static service
  • The ExceptionOccurred Mailable 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 (not get + put) for dedup logic.
  • The dedup key includes exception class + message + file + line – enough specificity without being too granular.
  • Log to error channel 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 ErrorReporter in staging by temporarily setting DEDUP_MINUTES = 0 or calling Cache::flush(). Then trigger a deliberate exception and verify the email arrives with the correct payload, sanitized input, and request_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

← Request Logger Middleware | Next: Slow Query Detection β†’

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.