squareboat/sneaker
Squareboat Sneaker gives Laravel developers a simple way to preview and test outgoing emails before they reach real inboxes. Inspect rendered mailables in the browser, catch template issues early, and speed up email development without extra tooling.
Install and publish the config:
composer require squareboat/sneaker
php artisan vendor:publish --provider="Squareboat\Sneaker\SneakerServiceProvider"
The package auto-registers its service provider on Laravel 5.5+. If you are on a version without package discovery, add Squareboat\Sneaker\SneakerServiceProvider::class to config/app.php.
Open the published config/sneaker.php first — that is the entire surface area of the package. Typical keys you will be setting:
// config/sneaker.php
return [
'enabled' => env('SNEAKER_ENABLED', false),
'to' => env('SNEAKER_TO', '[email protected]'),
'from' => env('SNEAKER_FROM', '[email protected]'),
'subject' => env('SNEAKER_SUBJECT', 'Exception on ' . env('APP_NAME')),
// sometimes 'except' / 'ignore' / 'throttle' depending on version
];
Then in .env:
SNEAKER_ENABLED=true
[email protected]
[email protected]
Finally, wire it into the exception lifecycle. On Laravel ≤ 10 you add the package's trait to your handler (or it is already hooked — check the package README matching your installed version):
// app/Exceptions/Handler.php
use Squareboat\Sneaker\Traits\SneakerExceptionHandler;
class Handler extends ExceptionHandler
{
use SneakerExceptionHandler;
protected $dontReport = [
// ...
];
}
On Laravel 11+ do the equivalent inside bootstrap/app.php via ->withExceptions(...), or call the Sneaker hook from your own report() override.
First use case: a staging or low-traffic internal app with no Sentry/Flare subscription. Deploy, throw an exception on a route, confirm the email arrives, and you now have a zero-infrastructure "something broke on prod" alert. Do not point it at your personal inbox for a high-traffic app — see the flood warnings below.
Environment-gated enablement. Keep SNEAKER_ENABLED=false in .env.example and only flip it in .env.staging / production. Local dev should never fire these emails; the noise trains you to ignore the alert, which defeats the purpose.
Separate recipient lists per environment. Point staging at a #staging-errors-style alias and production at the on-call list. Because the recipient comes from config, this is purely an env difference — no code branches.
Slack/Teams via email ingestion. Most chat platforms give you a channel email address. Point SNEAKER_TO at it and you get a free alerting channel without writing a webhook integration.
Deduplication guard in report(). A single broken query inside a loop can generate thousands of identical mails within a minute. Wrap the send with a short-lived cache lock keyed on the exception fingerprint:
public function report(Throwable $e): void
{
$key = 'sneaker:' . md5(get_class($e) . $e->getMessage() . $e->getFile() . $e->getLine());
if (Cache::add($key, true, now()->addMinutes(10))) {
parent::report($e); // lets Sneaker (and Laravel logging) run
}
}
Contextualize the mail body. Exceptions that carry context() (Laravel's Throwable::context() convention) produce far more useful mails than a bare stack trace. Throw domain-specific exceptions with IDs attached:
throw new OrderFailedException("Order {$id} failed", context: ['order_id' => $id]);
Assert it in tests. If the package sends through Laravel's mailer, Mail::fake() works — but the hook fires from the exception handler, so you need a real $this->withoutExceptionHandling() throw or a direct report() call:
Mail::fake();
$this->expectException(RuntimeException::class);
$this->withoutExceptionHandling()->get('/boom');
Mail::assertSent(/* the package mailable */::class);
Complement, don't replace, an APM tool. Sneaker is a fire alarm, not an observability platform. It gives you one email per exception with a stack trace; it does not aggregate, trend, or let you search by user. Use it as the backup channel when a Sentry/Flare outage would otherwise leave you blind, or as the only channel for small internal tools.
Route it through a real transactional provider. Whatever MAIL_MAILER you use (SES, Postmark, Mailgun) is what Sneaker inherits. Configure that transport explicitly rather than relying on a sendmail binary being present on the box.
Synchronous mail in the exception path. If the package sends inline, every 500 now waits on an SMTP handshake. Under a traffic spike you get slow error responses and a mail provider rate-limit. If the version you install does not queue, override the send with Mail::queue() in your own handler rather than fighting the package.
Recursion is a real failure mode. Mail sending touches config, the filesystem (views, cache), and the network — all of which can throw. If Sneaker's own failure propagates, you can mask the original exception or loop. Verify by setting SNEAKER_TO to a bogus domain in staging and confirming the 500 still renders and the original error still reaches laravel.log.
Config caching hides env changes. php artisan config:cache snapshots config/sneaker.php. Changing SNEAKER_TO in .env on a cached production box silently does nothing. Always php artisan config:clear after changing these values.
Laravel's $dontReport already filters the noisy classes. ValidationException, AuthenticationException, AuthorizationException, ModelNotFoundException, and HttpExceptionInterface (404/403) are suppressed by the framework before your report() runs, so most of the "noise" problem is solved for you. What is not filtered — QueryException, ErrorException, TypeError, third-party SDK exceptions — is exactly what you want to see.
Queue/console exceptions have no request context. A failure in a worker or scheduler produces an email with no URL, no user, and a artisan queue:work stack. If that matters, add Queue::failing(fn ($event) => ...) context or accept that the trace is all you get.
PII and secrets in the mail body. Stack traces contain local paths; request payloads contain passwords and tokens if the exception serializes input. Treat these mails as sensitive. If you publish the email view to customize it (php artisan vendor:publish --tag=views), strip request params before rendering.
Version compatibility is the biggest risk. The package is small (219 stars) and its exception-handler integration predates Laravel 11's bootstrap/app.php restructure. Check the composer.json constraint against your Laravel version before installing; if it does not support your framework, the same behaviour is ~20 lines of your own report() override plus Mail::raw(), and that may well be the better call for a long-lived codebase.
Octane / long-running workers. Anything that memoizes "already sent" state on the singleton must be resettable between requests. If you add your own throttling, back it with the cache store, not an in-memory property — otherwise Octane will suppress every alert after the first one per worker lifetime.
Extension points. The useful ones are the config file, the publishable email view, and overriding the handler's report(). There is no plugin system; if you need Slack blocks, structured payloads, or per-exception routing, fork the mailable or write the notification yourself rather than bending the package.
Debugging a "no email arrived" report. Walk it in order: config('sneaker.enabled') is actually true (dump it in tinker on the server), SNEAKER_TO is populated after config cache, the mail driver is not log, storage/logs/laravel.log shows the exception at all (i.e. it is not in $dontReport), and the provider's outbound log accepts the message. Nine times out of ten it is the first or second item.
How can I help you explore Laravel packages today?