Pretty URLs with mod_rewrite, Minus the Headache
Almost every PHP framework and CMS wants the same thing from Apache: if someone asks for /blog/2010/pretty-urls, and there’s no such file on disk, hand the request to index.php and let the app figure it out. That’s the front controller pattern, and the rule for it is short. The headache is that when it doesn’t work, mod_rewrite doesn’t tell you why.
The rule
Here’s the classic version.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?path=$1 [QSA,L]
Reading it from the top: turn the rewrite engine on. Then two conditions, both of which have to pass before the rule runs. -f treats the test string as a path and checks that it exists and is a regular file, and -d does the same for a directory. The ! flips them, so together they mean “only if this request doesn’t match a real file or directory”. That’s what lets your images, stylesheets and JavaScript get served directly while everything else goes to PHP.
The rule itself captures the whole path and rewrites it to index.php, passing the original path along as a parameter. QSA appends the original query string, so ?page=2 isn’t lost. L stops processing any further rules.
There’s a subtlety with L in per-directory context. The mod_rewrite docs point out that a rewrite there frequently causes an internal redirect, which sends the request back through the rules from the top. That would be an infinite loop, except that on the second pass the request is for index.php, which is a real file, so the !-f condition fails and processing stops. So those conditions do two jobs.
Where the rule lives matters
Most apps ship this in a .htaccess file, and there’s one trap if you move it into your VirtualHost instead. In server or virtual host context, the rewrite happens before Apache has mapped the URL to a file, and the docs say REQUEST_FILENAME then just holds the same value as REQUEST_URI. Your -f test is checking a path like /css/site.css against the root of the filesystem, it never matches, and suddenly your stylesheet is being served by index.php.
The fix is to put the same rules inside a <Directory> block, where they behave the way they do in .htaccess.
<Directory /var/www/myapp>
AllowOverride None
Options FollowSymLinks
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?path=$1 [QSA,L]
</Directory>
In that context, the directory prefix is stripped before matching and added back afterward, which is why the pattern has no leading slash. If your URLs don’t map straight onto the directory, for example because the app lives under an Alias, you’ll need a RewriteBase telling mod_rewrite what URL prefix to use. And Options FollowSymLinks (or SymLinksIfOwnerMatch) has to be on, or Apache refuses per-directory rewrites with a 403 and an error log line saying so.
Why not just use .htaccess?
Because the Apache 2.2 .htaccess howto is pretty blunt about it: if you can edit the main config, use it. When AllowOverride allows .htaccess files, Apache has to look for one in every directory where they’re allowed, all the way up the path to the requested file, whether or not any exist. And when it finds one, it reads it again each time. You pay that cost for images and stylesheets too.
Setting AllowOverride None stops Apache from even trying to read them. Note that the built-in default in 2.2 is All, so if nothing in your config says otherwise, you’re paying for those lookups right now. Ubuntu’s default site, for one, already sets None for /var/www. If you’re on shared hosting without access to the main config, .htaccess is the tool you have, and for a small site the cost is modest. On your own server, move the rules into the config and turn overrides off.
Making mod_rewrite talk
When a rule doesn’t do what you expect, stop guessing and turn on the rewrite log. In Apache 2.2 that’s two directives, and they only work in the main server config or a virtual host, not in .htaccess.
RewriteLog /var/log/apache2/rewrite.log
RewriteLogLevel 4
Level 0 is the default and logs nothing, while 9 logs practically everything. Level 2 shows each actual rewrite, level 3 each pattern being tried, and level 4 each RewriteCond with its input and whether it matched, which is usually where the answer is. Each line also tags the request, and after an internal redirect that tag shows how many redirects deep it is, which is how you spot a loop.
Turn it back off when you’re done. The docs warn that a high level slows the server down dramatically and that anything above 2 is for debugging only. They also say not to “disable” it by pointing RewriteLog at /dev/null, because mod_rewrite still builds all the log output internally. Remove the directive or set the level to 0.
The takeaway
Skip real files, skip real directories, send the rest to index.php. Put that in a <Directory> block, set AllowOverride None when you control the server, and when something breaks, reach for RewriteLog before you start rewriting the regex.