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.