grizzlylab/deny-routes-by-env-bundle
Symfony bundle that restricts routes to specific environments, letting you deny configured paths outside allowed envs. Simple configuration keeps dev-only or admin routes unreachable in production without touching controllers or firewall rules.
This is a Symfony bundle, not a Laravel package — the first decision is whether the pattern or the code is what you actually want. If you're in a Laravel codebase, read this as a design reference and reimplement it as a 15-line middleware (see below). If you're in a Symfony app, it's installable today.
Minimal path:
composer require grizzlylab/deny-routes-by-env-bundle (MIT, but 4 stars / 0 dependents — treat it as vendored code you own, not infrastructure you depend on).config/bundles.php if Flex doesn't do it for you:
GrizzlyLab\DenyRoutesByEnvBundle\DenyRoutesByEnvBundle::class => ['all' => true],
config/packages/deny_routes_by_env.yaml — confirm the exact root key against the README). The shape is a mapping of environment → route names/patterns:
deny_routes_by_env:
rules:
prod:
- 'admin_'
- 'debug_'
bin/console cache:clear, then curl a matching route in that env and confirm you get a 404/403 instead of the controller.Where to look first, in this order:
DependencyInjection/Configuration.php — this is the ground truth for config keys, nesting, and whether wildcards/regexes are supported. Don't trust blog snippets over this file.EventSubscriber/…Subscriber.php) — you need to know two things: which exception it throws (404 vs 403) and at what priority it hooks kernel.request. Both have real security consequences.First use case: you have admin dashboards, webhook test endpoints, /dev/mailer, profiler routes, or demo-only pages, and one environment (staging, a "demo" env, or a dev-mode prod box used for debugging) accidentally exposes them. You want them to not exist there, without deleting routes, duplicating route files, or fragmenting firewalls.
Environment → route-name prefix mapping. The join point is the route name, not the path. Establish naming conventions (admin_*, debug_*, _preview_*) so the config stays a short list of prefixes rather than a brittle enumeration.
Decide deliberately between 404 and 403. 404 hides the route's existence from probing bots; 403 is honest and easier to debug. If the bundle gives you a choice, block unauthenticated probes with 404 and authenticated misuse with 403. If it doesn't, wrap or replace the subscriber.
Layer it with access_control, don't replace it. access_control is authorization (there is a user, there is a role). This bundle is route existence enforcement (there is no user, the route shouldn't resolve). Running the deny check early in kernel.request — before the firewall redirects to a login page — avoids leaking the route's existence via a 302 /login.
Use Symfony's env-scoped config rather than one big file. In Symfony 5.3+, when@prod: blocks keep the intent local:
when@prod:
deny_routes_by_env:
rules:
prod: ['admin_', 'debug_']
Older versions: separate config/packages/prod/deny_routes_by_env.yaml.
Prove it in CI, per environment. A route that is "denied in prod" is a security control, so it needs a test, not a code review:
public function testAdminRoutesAreDeniedInProd(): void
{
$client = static::createClient(['environment' => 'prod']);
$client->request('GET', '/admin');
self::assertResponseStatusCodeSame(404);
}
Run that across an env matrix in your pipeline, and add a post-deploy smoke check against staging before promotion.
The Laravel translation, if that's where you actually live — same intent, no dependency:
// AppServiceProvider::boot()
Route::middleware('deny.route.in.env:production')->group(function () {
Route::get('/admin', AdminController::class)->name('admin.index');
});
// app/Http/Middleware/DenyRouteInEnv.php
public function handle(Request $request, Closure $next, string ...$envs)
{
abort_if(app()->environment($envs) && $request->routeIs('admin.*'), 404);
return $next($request);
}
Or, more simply, skip registration entirely in environments where the route shouldn't exist:
if (! app()->environment('production')) {
Route::get('/dev/mailer', MailPreviewController::class)->name('dev.mailer');
}
The registration-time approach is strictly stronger — the route is absent from route:list, from URL generation, and from the router — which is worth noting as a design critique of the bundle: it denies at request time rather than at routing time.
#[Route] attributes, won't match a prefix rule. Verify with bin/console debug:router --show-controllers before you assume a rule is live.kernel.request too. ESI/fragments/render(controller(...)) will be evaluated against the same rules. If the subscriber doesn't guard on isMainRequest() (or isMasterRequest() on older Symfony), you can silently break fragment rendering in the target env..env.local beats your config. Half the "it doesn't work" reports are APP_ENV not being what you think, especially in Docker/CI. Print it at boot, or check bin/console about.cache:clear + (in some setups) cache:warmup. when@prod: blocks also require a recent Symfony — check the constraint.kernel.request; if a "denied" route's logic is also invoked from a command, this bundle does nothing to stop it. That's a real gap — deny at routing/config time, not just at request time.bin/console debug:event-dispatcher kernel.request tells you whether the listener is registered and where it sits in the priority order relative to the firewall and router.composer.json, and be prepared to fork it — or better, inline the ~20-line EventSubscriberInterface implementation into your own src/EventSubscriber/ so upgrades to Symfony are your problem and not a dead package's.on_deny behavior toggle between 404 and 403. If neither is exposed, that's the fork trigger.How can I help you explore Laravel packages today?