Skip to content
LibxaFrame
Engineering 12 August 2026 7 min read

Your routes work locally and 404 on the server

The starter kit shipped no .htaccess, so a deployed application answered the home page and returned 404 for everything else. Three development environments each hid it for a different reason, and the one that shows it is the one you only reach after deploying.

Every route except the home page returned 404. Not locally, where the whole site worked, but the moment it went on a server. The starter kit had shipped that way since its first release.

What was happening

Locally, a LibxaFrame application is served like this:

php libxa serve

which is php -S 127.0.0.1:8000 -t src/public src/public/router.php. That last argument is the entire story. router.php exists to emulate Apache's mod_rewrite for PHP's built-in server:

if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
    return false;
}

require_once __DIR__ . '/index.php';

If the requested path is a real file, serve it. Otherwise hand the request to the front controller, which is what makes routing work.

A real web server does not do that on its own, and the starter kit shipped no .htaccess telling it to. Ask Apache for /login and it looks for a file called login, does not find one, and returns its own 404 before PHP is ever started. The framework never sees the request. / worked, and only because DirectoryIndex finds index.php without needing a rule.

So the one URL anybody checks after deploying was the one URL that could not fail.

Why nobody caught it

Every environment where the kit was developed and tested has the rewrite already. php libxa serve has router.php. CI drives requests through the HTTP kernel directly, so it never involves a web server at all. Libxa Desktop generates nginx config with try_files $uri $uri/ /index.php?$query_string baked in, so sites served through it work.

Three environments, three different reasons the bug is invisible, and a fourth environment (a real deployment) where it is the only thing you see.

The fix

src/public/.htaccess now ships with the kit. The rule that matters:

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

Both conditions are load-bearing. Without !-f every real asset would be handed to PHP; without !-d a directory request would be too.

Three other things ride along in the same file:

MultiViews off. With content negotiation enabled, Apache answers /about with about.php if such a file exists, silently bypassing the front controller and serving a page your router knows nothing about.

The Authorization header restored. CGI and FastCGI drop it. Anything reading a bearer token sees no token, so API authentication fails and nothing anywhere says why.

One canonical URL per page, with /about/ redirecting to /about.

Shared hosting

Most shared hosts serve public_html and will not let you move the document root, which puts .env, vendor/ and your application code inside the web root. A second .htaccess at the project root handles that case: it rewrites everything into src/public/ and refuses the paths that must never be served.

One detail in it is worth pulling out, because it is easy to get wrong:

RewriteCond %{REQUEST_URI} !^/src/public/
RewriteRule ^(.*)$ /src/public/$1 [L]

The leading slash on the substitution is not cosmetic. A relative target is resolved against the current directory and %{REQUEST_URI} keeps the original request, so the guard on the line above would never match and the rule would rewrite into itself indefinitely. An absolute URL-path forces an internal redirect, %{REQUEST_URI} updates, and the guard does its job on the second pass.

What we could not fix

Installing into a subdirectory still does not work. example.com/my-app/ means requests arrive as /my-app/about, routes are matched against the full request path, and nothing matches. There is no base-path handling in Request::path(), which only strips /index.php.

That is a real limitation rather than a configuration mistake, so it is documented plainly instead of left to be discovered. Use a subdomain, or point a virtual host at the project.

Guarding it

The fix is a file existing. Files get deleted, .gitattributes entries get added, and nothing complains until somebody deploys. So DeploymentConfigTest now asserts the rules ship and still say what they need to say.

It earned its place immediately. The composer create-project job started failing because /docs is export-ignored, which meant a freshly created project had no deployment guide: exactly the problem this release exists to fix, reproduced in miniature. The guide moved to the project root, where it ships.

A test that fails in CI is a test that does not fail on somebody's server.

Keep reading