008 —Architecture
Front-controller routing: why /contact is not contact.php
The contact page on a 12-year-old WordPress site shows a 500 error. The first instinct is to open contact.php in FTP. That file does not exist, and it never did.
A developer is staring at a 12-year-old WordPress install. The contact form returns 500. They SSH in, ls the docroot, and look for contact.php. It is not there. wp-config.php is there. index.php is there. The URL in the browser is /contact. The file the URL appears to point at does not exist, and it never did, because the site uses front-controller routing.
This catches almost every developer who came up writing about.php and products.php by hand. The web they learned routed one URL to one file. The web they inherited uses front-controller routing, where every URL flows to the same file. Knowing the difference is the difference between a five-minute fix and a four-hour wild-goose chase through someone else's legacy site.
One file per URL was the old contract
In a classic PHP 3 or PHP 4 site, the web server's job was simple: take the path from the URL, find the file, run it. A request for /about.php ran /about.php. A request for /products/list.php ran /products/list.php. The filesystem and the URL space were the same shape. You added a page by adding a file. You renamed a page by renaming a file, and every link to the old name broke.
WordPress shipped pretty permalinks in 2.0, back in 2005. Drupal had clean URLs from version 4. Symfony, CodeIgniter, Laravel and every PHP framework written after about 2008 picked the same pattern: send every request to a single index.php and let PHP decide what to do. The pattern is called the front controller, and it has been the default for a long time.
The .htaccess that quietly rewrites everything
Open the .htaccess in the root of any WordPress install built since 2007 and you will find this block:
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
Read it slowly. RewriteEngine On turns mod_rewrite on for this directory. RewriteRule ^index\.php$ - [L] says: if the request is literally for index.php, do nothing and stop. The two RewriteCond lines say: only apply the next rule if the requested filename is not a real file (!-f) and not a real directory (!-d). The final line then rewrites anything that passes those checks to /index.php. That is the front-controller pattern at the Apache layer.
So when the browser asks for /contact, Apache asks the filesystem two questions. Is there a file at /contact? No. Is there a directory? No. It rewrites the request internally to /index.php. The address bar still shows /contact. The process that runs is index.php, and only index.php.
Drupal 7 has a one-line variant: RewriteRule ^ index.php [L]. Laravel ships a public/index.php and a near-identical rewrite. Magento 2 routes through pub/index.php. The cosmetics differ. The contract does not.
When the rewrite never runs
If mod_rewrite is missing, or the docroot's AllowOverride is set to None in the Apache config, the .htaccess block is silently ignored. Apache then tries to serve /contact as a literal file, fails, and returns a 404 with no PHP execution at all. This is the single most common reason a freshly migrated WordPress site loads its homepage but 404s on every other URL. The fix is at the server level: enable mod_rewrite, or set AllowOverride All on the docroot. .htaccess alone cannot fix it, because .htaccess is what is being ignored.
Index.php is the only entry point
Once Apache hands /contact over, PHP takes the URL as data. It lives in $_SERVER['REQUEST_URI']. The filesystem no longer has anything to say about which page renders.
In WordPress, index.php is six lines that boot wp-blog-header.php, which loads wp-load.php, which loads wp-config.php and the rest of the core. WordPress then reads REQUEST_URI, fetches the rewrite_rules row from wp_options, unserialises it, and matches the URL against an array of regular expressions. For a Page with slug contact, the match resolves to ?page_id=42 or similar, the global $wp_query is populated, and the template hierarchy picks a file from the active theme: page-contact.php, then page.php, then singular.php, then index.php as a last resort.
Every front-controller PHP app does the same shape of thing. Drupal uses a router service. Symfony reads attributes or YAML route files. Laravel reads routes/web.php. None of them touch the filesystem to find the handler. Which is why grep -r "function contact" . on a Drupal site to find the contact page handler returns either nothing or a hundred false positives.
A debugging order that actually works
When a route on a site you did not build stops working, run through these in order. Most front-controller routing failures are one of the first four.
- Confirm
mod_rewriteis alive.curl -I https://example.com/contactand read the response. If the 404 page is the framework's branded 404, the rewrite ran and PHP rejected the URL. If it is a bare Apache or Nginx 404, the rewrite never ran. - Read the
.htaccesscurrently on disk. Some migrations drop it. WordPress regenerates it when you save Settings → Permalinks, but only if the file is writable by the web user. - Check the routing data, not the filesystem. On WordPress,
SELECT option_value FROM wp_options WHERE option_name = 'rewrite_rules'returns a serialised PHP array. Unserialise it and look for your URL. If it is not in there, no template will ever match it. - Look for real files that shadow the route.
find . -maxdepth 2 -name "*.php"in the docroot will surface any leftovercontact.php,shop.phporold.phpthat!-fis happily serving instead of the router. - If all four are clean, flip
WP_DEBUGon inwp-config.phpand watchwp-content/debug.log. The PHP error is usually printed within the first request.
What to change today
If you support a front-controller site you did not build, take five minutes today to open its .htaccess, read the rewrite block out loud, and find its index.php. Once you know those two files exist and what they do, every "the contact page is broken" ticket starts in the right place instead of in a hunt for a file that was never there.
When we built Pier we ran into this exact thing on almost every site we connected to. The way we ended up handling it was to surface .htaccess and index.php as first-class files in the tree, keep a version history of both so a careless edit to a rewrite block can be rolled back in one click, and let the built-in MySQL editor read the rewrite_rules option without opening a second phpMyAdmin tab.
— Questions —
If /contact is not contact.php, which file actually runs?
index.php in the docroot. Apache rewrites the request to it via mod_rewrite, and WordPress, Drupal, Laravel or Symfony then looks the URL up in its router and picks a handler.
Why does my freshly migrated WordPress site only load the homepage?
The homepage is the one URL that maps to a real directory, so no rewrite is needed. Every other URL needs mod_rewrite and a working .htaccess. One of those two is usually missing after migration.
Can a leftover contact.php still be served from a modern WordPress site?
Yes. The RewriteCond !-f check skips the rewrite when a real file exists at the URL. Any orphan PHP file from an older site will continue to serve, often with stale content or broken includes.
Where do I actually find the route for /contact in WordPress?
In wp_options where option_name = 'rewrite_rules', stored as a serialised PHP array. The matching content row lives in wp_posts with post_name = 'contact' and post_type = 'page'.