aureola/laravel-teapot
Adds HTTP 418 I'm a teapot responses to Laravel apps, with middleware, route macros, and helpers for returning proper teapot status codes, configurable messages, and a tea-themed error page for playful APIs and demos.
Install and publish config
composer require aureola/laravel-teapot
php artisan vendor:publish --tag=teapot-config
The first place to look is the published config/teapot.php. That file is where you will find (depending on the version):
wp-login.php, phpmyadmin, .env, .git/config, admin.php)Register the middleware
The package ships a middleware (something like TeapotMiddleware) that inspects incoming requests and returns 418 I'm a teapot when it matches. Wire it up in app/Http/Kernel.php:
// Laravel 10 and below – app/Http/Kernel.php
protected $middleware = [
\Aureola\Teapot\Http\Middleware\Teapot::class,
// ...rest
];
For Laravel 11+ register it in bootstrap/app.php via $middleware->append(...).
First use case
The immediate value: your Laravel app sits behind a public domain and is being hammered by /wp-login.php, /.env, /vendor/phpunit/... probes. Install the package, enable the middleware, and every scanner hit now:
418 instead of a 404/200Verify it works with curl before moving on:
curl -i https://your-app.test/wp-login.php
# Expect: HTTP/2 418
tail -n 5 storage/logs/teapot.log # or whatever channel config points at
If you don't see a 418, the request didn't match your config's patterns — adjust config/teapot.php first, not the middleware.
Global middleware is the typical placement. The whole point is to catch unsolicited traffic that never intended to hit a real route. Mounting it globally means /wp-admin, /.env, and /shell.php all get trapped before Laravel's router tries (and returns 404 for) them. Attaching it to a specific route defeats the purpose.
Honeypot route pattern. A common complement is exporting fake endpoints that lead nowhere:
// routes/web.php
Route::get('/wp-login.php', fn () => abort(418));
Route::post('/wp-login.php', fn () => abort(418));
Combine with the package's logging so a hit here looks identical to a hit on a scanner-pattern request.
Fail2ban integration pattern. The intended flow is log-file → fail2ban filter → jail. A typical filter on the package's log output:
# /etc/fail2ban/filter.d/laravel-teapot.conf
[Definition]
failregex = ^.*teapot.*<HOST>.*$
ignoreregex =
And a jail:
# /etc/fail2ban/jail.d/laravel-teapot.local
[laravel-teapot]
enabled = true
filter = laravel-teapot
logpath = /var/www/your-app/storage/logs/teapot.log
maxretry = 1
bantime = 86400
findtime = 600
maxretry = 1 is deliberate — a single deliberate scanner probe is enough signal. Tune only if you see false positives.
Pair with throttling, not instead of. Trap = ban signal. throttle: on normal routes = rate limiting. Keep them separate; don't let the teapot middleware swallow legitimate 429s.
Whitelisting pattern. Almost always needed for:
/healthz, /up)Do this in config or a small middleware guard that short-circuits before the teapot check:
if (in_array($request->ip(), config('teapot.whitelist'))) {
return $next($request);
}
Customising the trigger. If the package exposes the pattern list as config (likely), treat it as data. Add your own app-specific noise:
// config/teapot.php
'paths' => [
'wp-login.php', 'wp-admin', 'xmlrpc.php',
'.env', '.git/', 'phpmyadmin', 'admin.php',
'vendor/phpunit', 'actuator', 'cgi-bin',
],
Logging to a dedicated channel. Don't share your application log. storage/logs/laravel.log is rotated, noisy, and multi-purpose; fail2ban gets unreliable if it has to disambiguate. Configure a teapot log channel in config/logging.php pointing at storage/logs/teapot.log so its format is stable.
The 418 status is the point — but not every client handles it well. Browsers render the "I'm a teapot" easter egg, some scraping libraries throw on it, and a few bots retry aggressively. Fine for scanners (that's the intent), but don't wire this middleware into anything a legitimate client (mobile app, API consumer) hits.
Reverse proxies will break IP logging. If you're behind Cloudflare, an ALB, or nginx, $request->ip() is your proxy until you configure TrustProxies. Fail2ban will ban your load balancer. Verify with:
curl -i -H "X-Forwarded-For: 1.2.3.4" https://your-app.test/.env
# Check the logged IP
Fix App\Http\Middleware\TrustProxies before going live.
Log rotation vs fail2ban. Once teapot.log rotates, fail2ban needs logrotate's postrotate hook (or copytruncate) to be told. Otherwise bans stop applying silently. If the package writes via Laravel's Log facade with daily channels, rotation happens on day boundaries — check fail2ban is following the rotated file too.
Middleware order matters. Put the teapot middleware after TrustProxies and before session/CSRF middleware. You don't want a scanner hit creating a session, writing to the DB, or hitting CSRF rejection before the trap catches it — that's wasted work and logs a false "attack" signature that has nothing to do with scanning.
Test your patterns, not just the middleware. A common failure is enabling the middleware but having a pattern list that doesn't match anything you're actually seeing. Check your nginx/access logs for real probe paths and add them to config/teapot.php.
Whitelist aggressively at the edge. 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and your monitoring IPs should never be in the log. If the package supports CIDR entries in config, use them; otherwise add an allowlist check before the middleware.
Don't over-trust an obscure package. This package has 2 GitHub stars, 0 dependents, and is clearly pre-adoption. That's fine for a small trap, but:
composer.jsoncomposer update could quietly disable bansExtension points to look for (verify in the source):
TeapotTriggered) — hook that to increment a counter, alert Slack, or trigger an immediate ban via fail2ban's socket403 or a blank 444-style close, which is cheaper for scanners)Debugging a non-firing trap:
# 1. Confirm middleware is registered
php artisan route:list --middleware=Teapot 2>/dev/null || true
# 2. Confirm config is loaded
php artisan tinker --execute="dump(config('teapot'));"
# 3. Confirm the request matches a pattern manually
curl -i https://your-app.test/wp-login.php
# 4. Check the log channel is actually written to
tail -f storage/logs/teapot.log
One last operational tip: ship the fail2ban jail config alongside the Laravel change in the same deploy. Half-shipped — trap enabled but jail not installed — means you're logging hundreds of scans a day that nobody acts on, which quickly becomes noise you ignore.
How can I help you explore Laravel packages today?