skydiver/laravel-route-blocker
Block routes in Laravel with a simple, configurable middleware layer. Restrict access by IP, environment, or custom rules, and return clean 403 responses. Lightweight and easy to drop into any Laravel app.
Pull the package in and publish its config — that config file is the entire control surface of this package, so read it before you write any code:
composer require skydiver/laravel-route-blocker
php artisan vendor:publish --tag=route-blocker # or --provider="Skydiver\LaravelRouteBlocker\...ServiceProvider"
Then open config/route-blocker.php first. It ships as a set of lists — blocked/whitelisted IPs, route patterns, and HTTP verbs — plus a response definition (abort code, redirect, view, or JSON body). The service provider auto-registers a global middleware, so once the config has entries the package is already active on every request; you don't need to touch routes/*.php for the common case. On Laravel 11+ verify the provider is discovered (or registered in bootstrap/providers.php) and that the global middleware is appended in bootstrap/app.php if you've customised the middleware stack.
A good first use case: you're seeing a scraper hammer /login and /search from one IP, and you want it gone in two minutes without touching nginx or Cloudflare:
'blocked' => [
'ips' => ['203.0.113.42'],
'routes'=> [],
'verbs' => [],
],
Confirm it works from outside your machine (curl --resolve or a VPN), not from 127.0.0.1 — see the gotchas below for why.
Deny-list the whole app. Populate blocked.ips for bad actors. This is the highest-value, lowest-effort use: it runs as a global middleware before your controllers, so abuse never reaches your app code, queue dispatches, or DB.
Allow-list a sensitive area. The more interesting pattern is scoping a whitelist to a route prefix — /admin/*, /horizon*, /telescope*, /api/internal/* reachable only from office/VPN IPs. This is exactly what this package is for, and it complements (doesn't replace) the auth layer: a stolen session cookie from a non-office IP still gets stopped.
Verb blocking as a maintenance lever. Blocking post, put, patch, delete app-wide is a cheap "read-only mode" while you run a migration, better than a full down page because browsing still works.
Layer it with dynamic logic. Keep the package for static, deploy-time rules and write a small custom middleware for anything DB- or cache-driven (abuse scores, temporary bans). Cache the DB lookup; a middleware that hits the database on every request is a footgun. Register your middleware before the blocker so temporary bans and config bans both apply.
Environment-drive the config. Wrap the lists in env() inside the config file so staging can be fully locked down while production is open:
'blocked' => [
'ips' => array_filter(explode(',', env('BLOCKED_IPS', ''))),
],
Test it like any other middleware. Feature tests should set the client IP explicitly:
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.42'])
->get('/admin')
->assertForbidden();
If you're behind a proxy in the test, withHeaders(['X-Forwarded-For' => ...]) only works once TrustProxies accepts that header.
Choose the response deliberately. abort(403) for internal tooling, a rendered 403 view for customer-facing routes, and a JSON body for /api/* — returning an HTML error page to an API client is a common sloppy outcome.
TrustProxies configuration you will either block everyone or block no one, and it will look like the package is broken. Verify with a temporary Log::info($request->ip()) before trusting any rules.127.0.0.1 in the blocked list locks you out of local development. Gate the whole thing behind an enabled env flag (the config exposes one) and keep it false in .env.example/testing, true in production.php artisan config:cache runs. Put the cache rebuild in your deploy script, and never call env() outside config files or it will silently return null under a cached config.admin/* won't match admin itself, and /admin vs admin matters. Test the exact patterns against real URLs rather than assuming glob semantics.203.0.113.0/24) support; if you need ranges, handle it in your own middleware or at the edge. Same for IPv6 — write addresses in the form $request->ip() actually returns.auth/can middleware exactly where it was.post globally, but allow the webhook path", write a test that proves the whitelist wins — the behaviour is config-order dependent and easy to misremember during an incident.config('route-blocker') in tinker to confirm what's actually loaded, php artisan route:list to confirm the URI shape, and a curl -i from the offending network to confirm it isn't nginx or a WAF doing the blocking instead.How can I help you explore Laravel packages today?