If you have a Laravel project, you might be surprised to find your routes are probably available on a number of different URLs.
Imagine the following example from your routes file:
<?php
Route::view('/feature/uptime-monitoring', 'front/feature/uptime-monitoring');
This creates a route at yourdomain.tld/feature/uptime-monitoring, which you’d expect.
But in most webserver configs, this means the following URLs are all valid and will all return an HTTP/1.1 200 OK with the same page:
https://ohdear.app/feature/uptime-monitoring
https://ohdear.app/index.php/feature/uptime-monitoring
What’s that index.php doing in there? It’s needed for some hosting environments that don’t support pretty-printed URLs.
But you might not need/want it, so let’s get rid of it!
Removing index.php from within your Laravel code#
One way to get rid of it, is to check the route in Laravel and issue a redirect there if needed.
Here’s the code I originally published for app/Providers/RouteServiceProvider.php. Don’t use it. It has an open redirect in it, which I’ll break down right after.
<?php
use Illuminate\Support\Str;
class RouteServiceProvider extends ServiceProvider
{
public function map(Router $router)
{
$this->removeIndexPhpFromUrl();
}
// Vulnerable. See "The open redirect" below for the fixed version.
protected function removeIndexPhpFromUrl()
{
if (Str::contains(request()->getRequestUri(), '/index.php/')) {
$url = str_replace('index.php/', '', request()->getRequestUri());
if (strlen($url) > 0) {
header("Location: $url", true, 301);
exit;
}
}
}
}
A heads-up if you’re on a recent Laravel: this hooks RouteServiceProvider::map(), which is the Laravel 6 way. The map() method was dropped in Laravel 8, and RouteServiceProvider itself is gone entirely in Laravel 11+ (routing now lives in bootstrap/app.php). On a modern app you’d drop the same removeIndexPhpFromUrl() check into a middleware or a boot() closure instead. The place you hang it changes, the bug below doesn’t.
For the happy path, it does what it says on the tin.
$ curl -I "http://ohdear.app.test/index.php/pricing"
HTTP/1.1 301 Moved Permanently
Location: /pricing
$ curl -i "http://ohdear.app.test/index.php/feature/uptime-monitoring"
HTTP/1.1 301 Moved Permanently
Location: /feature/uptime-monitoring
The open redirect#
Update, July 2026. A reader mailed me to say the snippet above is an open redirect, and they were right. Hat tip, and sorry to everyone who copy-pasted it in the meantime.
The problem is that request()->getRequestUri() is whatever the client typed, and str_replace() doesn’t care what’s left over. Ask for /index.php//github.com and you get this:
$ curl -sI --path-as-is "http://127.0.0.1:8791/index.php//github.com"
HTTP/1.1 301 Moved Permanently
Location: //github.com
A Location: that starts with // is a protocol-relative URL. The browser reads it as “same scheme, whole new host”, so your visitor lands on https://github.com with your domain in the referrer and your domain in the link they clicked. That’s the whole trick behind a phishing link that passes the “does it start with the real domain?” eyeball test.
Backslashes work too, because browsers normalise \ to / while parsing. Four payloads, all of which walked off my site:
/index.php//github.com -> Location: //github.com -> github.com
/index.php///github.com -> Location: ///github.com -> github.com
/index.php/\github.com -> Location: /\github.com -> github.com
/index.php/\\github.com -> Location: /\\github.com -> github.com
I checked that last column with Node’s URL parser rather than guessing, since it follows the same WHATWG rules the browser does:
$ node -e "console.log(new URL('/\\\\github.com', 'https://ohdear.app/index.php/x').href)"
https://github.com/
Don’t assume Symfony catches this for you. There is a “URI cannot contain a backslash” guard in Request, but it lives in Request::create(), the factory used by tests. Real traffic comes in through createFromGlobals(), which doesn’t run it.
The fix#
Two things have to be true. The path has to start with exactly one slash and no backslash, and the redirect has to go out as an absolute URL on your own host.
use Illuminate\Support\Str;
protected function removeIndexPhpFromUrl(): void
{
$requestUri = request()->getRequestUri();
if (! Str::startsWith($requestUri, '/index.php/')) {
return;
}
// Collapse any leading slashes and backslashes, so this can never
// become //github.com or /\github.com.
$path = '/'.ltrim(Str::after($requestUri, '/index.php'), '/\\');
redirect()->to($path, 301)->send();
exit;
}
Be warned though, the ltrim() is doing the real work here and you can’t skip it by leaning on redirect(). Laravel’s UrlGenerator::isValidUrl() treats anything starting with // as an already-valid URL and hands it straight back to you:
url()->to('/pricing') = https://ohdear.app/pricing
url()->to('//github.com') = //github.com <-- still an open redirect
url()->to('/github.com') = https://ohdear.app/github.com
Normalise first, then let to() bolt your scheme and host on the front. Running the fixed version against the same payloads on Laravel 13.16.1:
/index.php/pricing -> https://ohdear.app/pricing
/index.php/feature/uptime-monitoring?a=b -> https://ohdear.app/feature/uptime-monitoring?a=b
/index.php//github.com -> https://ohdear.app/github.com
/index.php///github.com -> https://ohdear.app/github.com
/index.php/\github.com -> https://ohdear.app/github.com
/index.php/\\github.com -> https://ohdear.app/github.com
Query strings survive, and every host is mine.
There are some alternative ways to get the same result, by redirecting on the webserver itself. I re-tested all three of those while I was at it, and one of them was broken too.
Redirecting index.php in Nginx#
This is what I originally had here, and it’s wrong in two separate ways:
# Broken. Keep reading.
if ($request_uri ~* "^/index\.php(/?)(.*)") {
return 301 $2;
}
The capture group swallows the leading slash, so a plain request gets you a Location with no slash on the front at all:
$ curl -sI --path-as-is "http://127.0.0.1:8801/index.php/pricing"
HTTP/1.1 301 Moved Permanently
Location: pricing
The browser resolves pricing relative to /index.php/, which sends it right back to /index.php/pricing. A redirect loop, on the one URL the rule exists to handle. Nobody ever told me, which I assume means everybody was using the Apache version.
And it’s an open redirect on the backslash payload, same as the PHP one:
$ curl -sI --path-as-is 'http://127.0.0.1:8801/index.php/\\github.com'
HTTP/1.1 301 Moved Permanently
Location: \\github.com
(Single quotes matter there, otherwise your shell eats one of the backslashes and you’ll think it’s fine.)
Nginx passes raw backslashes through $request_uri untouched, and Location: \\github.com parses out to http://github.com/. A single backslash survives here, and so does //github.com, because Nginx rebuilds that one as an absolute URL on $host. That’s luck rather than design, and it isn’t luck you want to depend on.
Here’s the version that holds up. Anchor the regex, keep the slash inside the capture, and emit an absolute URL so the host can never be anything but yours:
if ($request_uri ~ "^/index\.php(/.*)$") {
return 301 $scheme://$host$1;
}
That leaves http://your.tld//github.com and http://your.tld/\github.com as the worst case, which are just ugly paths on your own domain.
Two things to keep in mind. $host comes from the Host header, so make sure you have a real server_name and a catch-all default_server that rejects unknown hosts, otherwise you’ve swapped an open redirect for host header injection. And you might have to repeat the rule if you have different location {} blocks.
Redirecting index.php in Apache#
You can add an additional line in your .htaccess.
$ cat .htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect if index.php is in the URL
RewriteRule ^index\.php/(.+) /$1 [R=301,L]
</IfModule>
This one I threw every payload at and it shrugged them all off, on Apache 2.4.68:
/index.php//github.com -> Location: http://127.0.0.1:8802/github.com
/index.php/\github.com -> Location: http://127.0.0.1:8802/%5cgithub.com
Three things save it. mod_rewrite with R=301 builds a fully qualified URL instead of echoing your path back at you, MergeSlashes (on by default since 2.4.39) collapses the doubled slashes before the rule ever sees them, and the backslash comes out percent-encoded as %5c so the browser can’t reinterpret it. At least that’s an easy regex/fix. :-)
Redirecting index.php in Caddy#
On Caddy v2 (the syntax above was for the long-gone Caddy v1) it’s a one-liner with a path_regexp matcher and a redir.
$ cat Caddyfile
ohdear.app {
@indexphp path_regexp idx ^/index\.php(/.*)$
redir @indexphp {scheme}://{host}{re.idx.1}?{query} 301
}
The named matcher captures whatever follows /index.php, and redir issues the 301 to the cleaned path using the {re.idx.1} capture group.
Caddy normalises the path before matching, so //github.com and ///github.com both come out as /github.com on their own. What it doesn’t do is normalise backslashes, and the original one-liner I had here (redir @indexphp /{re.idx.1} 301) sent /index.php/\github.com off to Location: /\github.com, which is github.com again. Building the absolute URL from {scheme}://{host} fixes it, the same way it does in Nginx.
The ?{query} is there because redir drops the query string otherwise. It leaves a bare trailing ? on URLs that didn’t have one, which is cosmetically annoying and functionally harmless.