Fix: root route matching with route cache when served from a subdirectory (#61794)
Contributed by Simon0Harms
Part of the framework v13.35.0 release
Applications deployed under a subdirectory — behind an Apache Alias, a reverse proxy that forwards /app/ without stripping the prefix, or a plain subfolder install — could not safely use php artisan route:cache or optimize: the home page would throw a confusing 405 while every other route kept working. PR #61794 fixes that in CompiledRouteCollection, the class behind Laravel's cached route matcher. If you're upgrading from v13.34.0, this is a drop-in patch: no configuration changes, no API changes, and no behavior change for apps served from the domain root.
The symptom: a 405 on exactly one URL
With routes cached and the request hitting the application root (for example https://example.com/app/), Laravel threw:
MethodNotAllowedHttpException: The GET method is not supported for route /. Supported methods: HEAD.
The maddening part was how narrow the failure was. /app/login, /app/api/... — everything else matched fine. Turn the route cache off and the root worked again. That combination (only the root, only when cached) made the bug easy to hit in production and hard to connect to route:cache, since caching is supposed to make matching faster, not different.
Why the root route failed
Under route:cache, requests are matched by CompiledRouteCollection. So that /login/ can match a route registered as /login, its requestWithoutTrailingSlash() method clones the incoming request and trims the trailing slash from REQUEST_URI. For a subdirectory deployment, that trim was fatal: /app/ became /app.
Symfony's request class derives the base URL from SCRIPT_NAME (here /app/index.php) and expects REQUEST_URI to start with the script's directory. Given /app — one character short of /app/ — it concluded the base URL was empty and the path info was /app rather than /. The cached matcher therefore failed to match the root route, and the mismatch surfaced as the 405 above.
The fix: keep the slash when the URI is the base URL
The patch adds one conditional to requestWithoutTrailingSlash() in src/Illuminate/Routing/CompiledRouteCollection.php:
$parts = explode('?', $request->server->get('REQUEST_URI'), 2);
$uri = rtrim($parts[0], '/');
if ($uri !== '' && $uri === rtrim($request->getBaseUrl(), '/')) {
$uri .= '/';
}
$trimmedRequest->server->set(
'REQUEST_URI', $uri.(isset($parts[1]) ? '?'.$parts[1] : '')
);
After trimming, the collection now checks whether the result is exactly the base URL. If it is — /app versus a base URL of /app — the trailing slash is restored, so Symfony sees /app/, detects the base URL correctly, and resolves the path info to /. The root route matches again.
You can verify it the same way the new integration test does, by simulating the subdirectory server variables directly:
$request = Request::create('http://example.com/app/', 'GET', [], [], [], [
'SCRIPT_NAME' => '/app/index.php',
'PHP_SELF' => '/app/index.php',
'SCRIPT_FILENAME' => '/var/www/public/index.php',
]);
$this->assertSame('home', $routes->match($request)->getName()); // resolves the '/' route
Or end to end: run php artisan route:cache, then curl -I https://example.com/app/ — you should now get 200 OK where v13.34.0 returned the 405.
What stays exactly the same
The condition is deliberately narrow, and the PR's tests pin the surrounding behavior:
- Any other path is still trimmed as before —
/app/login/still matcheslogin(testTrailingSlashIsTrimmedWhenMatchingCachedRoutesstill passes). - At the domain root,
/passes through unchanged and matches as before (testMatchingRootUristill passes); the$uri !== ''guard prevents edge cases with empty URIs and repeated slashes. - Uncached route matching is untouched; the change only lives in the compiled-collection code path used by
route:cache/optimize.
Three integration tests were added to CompiledRouteCollectionTest: root matching from a subdirectory, root matching with repeated slashes (// and /app//), and regular subdirectory routes like /app/foo/bar/.
Upgrade impact
For the overwhelming majority of apps — deployed at the domain root, or under a subdirectory without route caching — this release changes nothing observable. The impact is concentrated in one previously broken scenario. Subdirectory deployments that worked around the bug by skipping route:cache / optimize, or by stripping the prefix at the proxy, no longer need those workarounds and can re-enable route caching. There are no new config keys, no deprecations, and no public API changes, so upgrading from v13.34.0 is otherwise a routine composer update.
Takeaways
- PR #61794 fixes root (
/) route matching with cached routes when the app is served from a subdirectory. - Cause: the cached matcher trimmed
/app/down to/app, breaking Symfony's base-URL detection and leaving a path info of/appinstead of/. - Fix: when the trimmed URI equals the base URL, the trailing slash is restored before matching.
- Root deployments, uncached matching, and trailing-slash handling for other paths are unchanged.
- If you disabled route caching to avoid this bug, you can turn
route:cacheoroptimizeback on after upgrading.