Routing
OxPHP routes incoming HTTP requests using one of three modes, chosen by ENTRY_FILE, and WORKER_MODE_ENABLED=true switches routing to Worker mode instead. Each of the three modes mirrors a familiar nginx try_files configuration, so you can predict exactly what happens for any URL.
How it works
Every request runs through a shared pipeline before the mode-specific logic kicks in:
- The dot-path filter blocks paths containing hidden segments (
.git,.env), with an exception for/.well-known/*(RFC 8615) - The route cache returns recently resolved URIs from an LRU cache (10 000 entries)
- Percent-decoding and sanitization decode sequences like
%2e%2eand strip traversal segments (..,., empty) - Path deny rules apply when a
.oxphpdenyfile holds at least one deny or>rule. The sanitized path is matched against them before any disk access: a deny rule answers withPHP_DENY_FALLBACK, a>rule sends the request to the entry script, and a path past the file's depth and length limits gets 404 - The well-known PHP block never lets
.phpscripts inside/.well-known/execute, as defense-in-depth - URI classification sorts the sanitized path, once, into
NoExtension,Php, orOtherExtension - Mode dispatch hands the request to the active mode, which handles the three URI kinds with its own rules
- Symlink validation requires every resolved filesystem path to canonicalize inside the document root. A static file or PHP script is checked again, on the file actually opened, when it is read or executed
Classification is where the pipeline saves work. For OtherExtension URIs, the disk check for static assets (/style.css, /logo.png) runs once in the shared layer, so all three modes pay the same cost.
Configuration
| Variable | Default | Description |
|---|---|---|
DOCUMENT_ROOT |
/var/www/html/public |
Root directory for serving files and PHP scripts |
ENTRY_FILE |
(unset) | Single canonical entry script. Unset = Traditional. *.php = Framework. Non-.php = SPA. With WORKER_MODE_ENABLED=true = Worker. Accepts an absolute path or one relative to DOCUMENT_ROOT (.. allowed); the resolved path must exist |
WORKER_MODE_ENABLED |
false |
Enable persistent worker mode. Requires ENTRY_FILE to point at a .php script |
The legacy INDEX_FILE and WORKER_FILE variables are still parsed (with a startup WARN) and map onto the new model. See Configuration → Deprecated.
Routing modes
Traditional, Framework, and SPA modes are selected by ENTRY_FILE while WORKER_MODE_ENABLED=false, and each maps onto an equivalent nginx try_files configuration.
Active when ENTRY_FILE is not set (or empty) and WORKER_MODE_ENABLED=false. Equivalent nginx config:
location / {
try_files $uri $uri/ /index.php /index.html =404;
}
location ~ \.php$ {
try_files $uri =404; # PATH_INFO splitting enabled
}Resolution order:
$uri: exact file on disk → serve (or execute if.php)$uri/: directory → look forindex.php, thenindex.htmlinside it- PATH_INFO split: when the URI contains
.php/, the script prefix is matched on disk and the remainder becomesPATH_INFO(e.g./api.php/users/42→ scriptapi.php,PATH_INFO=/users/42) /index.php: root front-controller fallback/index.html: root static index fallback- 404
Examples:
| Request | Result |
|---|---|
/about.php |
Execute about.php |
/style.css |
Serve style.css |
/blog/ (with blog/index.php) |
Execute blog/index.php |
/api.php/users/42 |
Execute api.php with PATH_INFO=/users/42 |
/missing.txt |
Falls back to /index.php |
/some/route |
Falls back to /index.php |
PATH_INFO splitting is always on in Traditional mode. There is no env switch. The previous SPLIT_PATH_INFO_ENABLED flag has been removed.
Active when ENTRY_FILE=index.php (or any value ending in .php) and WORKER_MODE_ENABLED=false. Equivalent nginx config:
location ~ \.(?!php$)[a-zA-Z0-9]+$ {
try_files $uri /index.php; # static assets: fall back to front controller
}
location / {
rewrite ^ /index.php last; # everything else → front controller
}
location = /index.php {
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass ...;
}Resolution rules:
| URI kind | Behavior |
|---|---|
.css, .png, .js, … (any non-php extension) |
Serve the file if it exists, otherwise rewrite to /index.php |
.php (any path) |
Rewrite to /index.php |
no extension (/api/users, /) |
Rewrite to /index.php |
PATH_INFO is set only when the request explicitly names the entry file with a trailing segment (/index.php/extra); for application routes the original path is read from REQUEST_URI.
Examples:
| Request | Result | $_SERVER['PATH_INFO'] |
|---|---|---|
/style.css (exists) |
Serve style.css |
— |
/style.css (missing) |
Execute index.php |
(absent) |
/api/users |
Execute index.php |
(absent) |
/about.php |
Execute index.php |
(absent) |
/index.php/news/local |
Execute index.php |
/news/local |
/index.php (direct) |
Execute index.php |
(absent) |
/ |
Execute index.php |
(absent) |
For application routes the original path is exposed via REQUEST_URI, so your router reads $_SERVER['REQUEST_URI'] to dispatch. Direct access to /index.php is no longer blocked. The rewrite is idempotent, so hitting it directly produces the same result as hitting /.
A missing static asset falls back to the front controller rather than returning a fast 404. Laravel and Symfony ship the same try_files $uri /index.php behavior by default, and it lets your application render its own 404 for missing assets. The trade-off is that every request to a non-existent asset now runs PHP. If the front controller still has nothing to serve (/index.php itself absent), the request returns a hard 404.
Active when ENTRY_FILE=index.html (or any non-.php value) and WORKER_MODE_ENABLED=false. Equivalent nginx config:
location ~ \.php$ {
try_files $uri =404; # PHP: file must exist, no fallback
}
location ~ \. {
try_files $uri =404; # other extensions: hard 404 if missing
}
location / {
try_files /index.html =404; # no-extension paths: straight to index.html
}Resolution rules:
| URI kind | Behavior |
|---|---|
.php |
Execute the file if it exists, otherwise hard 404 |
.css, .png, … (any other extension) |
Serve the file if it exists, otherwise hard 404 |
no extension (/dashboard, /api/users, /) |
Serve /index.html directly, with no disk probe of $uri |
Examples:
| Request | Result |
|---|---|
/style.css (exists) |
Serve style.css |
/style.css (missing) |
404 |
/dashboard |
Serve /index.html |
/users/42/edit |
Serve /index.html |
/api.php (exists) |
Execute api.php |
/api.php (missing) |
404 |
/index.html (direct) |
Serve index.html |
Two of these rules are deliberate:
- No-extension paths skip the disk. SPA mode never asks whether
/dashboardexists on disk; it always returns the index. This is correct for client-side routers and avoids unnecessarystat()calls. - Missing static files get a hard 404 instead of falling through. A missing
/style.cssdoes not silently serveindex.html, which catches broken asset references early instead of returning HTML where JS expected CSS.
Because SPA mode executes existing .php files directly, PHP_DENY_PATHS applies. Use it to block execution inside writable directories such as /uploads.
Worker Mode
Worker mode activates when WORKER_MODE_ENABLED=true and ENTRY_FILE points at a .php script. The router serves static assets from disk and dispatches every other request to the worker ENTRY_FILE, which makes the worker the single front controller.
| URI kind | Behavior |
|---|---|
Static assets (any non-.php extension: .css, .png, …) |
Served directly from disk if present; a missing asset falls through to the worker ENTRY_FILE (not a hard 404) |
Anything else (.php URIs, extensionless paths, /) |
Dispatched to the worker ENTRY_FILE |
Arbitrary .php files in the document root are never executed directly in worker mode. A request to /about.php reaches the worker callback like any other route, even if about.php exists on disk. There is no directory-index lookup and no root index.php fallback either; the worker sees those requests itself.
The exceptions are server-level defenses that run before mode dispatch, and none of them reaches the worker. Dot-segment paths (/.git/config, /.env, bare /.well-known) are rejected by dot-path blocking, and .php URIs under /.well-known/ are refused as defense-in-depth, both with 404. With a .oxphpdeny file holding at least one deny or > rule, a path a deny rule matches gets the PHP_DENY_FALLBACK status, and a path past the file's depth and length limits gets 404.
Startup-time validation rejects three combinations:
WORKER_MODE_ENABLED=truewith noENTRY_FILE→WORKER_MODE_ENABLED=true requires ENTRY_FILE to be set.WORKER_MODE_ENABLED=truewith a non-.phpENTRY_FILE→WORKER_MODE_ENABLED=true requires a .php ENTRY_FILE.- A
.oxphpdenydeny rule with a scriptPHP_DENY_FALLBACK→PHP_DENY_FALLBACK is a script, but in worker mode every request runs the worker entry. A worker thread runs nothing but the entry, so it cannot run a fallback script in a denied request's place; use a status code.
See Worker Mode for full configuration details.
PATH_INFO behavior
$_SERVER['PATH_INFO'] is populated differently depending on mode:
| Mode | When set | Value |
|---|---|---|
| Traditional | Only when the URI contains .php/ (PATH_INFO split) |
Tail after the script segment, e.g. /users/42 |
| Framework | Only for an explicit /index.php/extra request |
Tail after the entry file, e.g. /news |
| SPA | Never | (PHP is only invoked for exact .php files; no PATH_INFO) |
PATH_INFO follows CGI semantics: it is present only when SCRIPT_NAME (the executed script) is a literal prefix of the request path. A front-controller rewrite that the URL does not name (an application route, a directory index, a static-miss fallback) carries no PATH_INFO, so read REQUEST_URI instead. In Traditional mode the previous SPLIT_PATH_INFO_ENABLED env variable has been removed.
Path security
OxPHP applies multiple layers of protection to prevent directory traversal, hidden file disclosure, and symlink escape attacks:
- Percent-decoding runs before sanitization, so encoded traversal attempts like
/%2e%2e/etc/passwdare caught - Segment filtering removes
..,., and empty segments from the resolved path - Symlink validation canonicalizes every resolved path and verifies it remains within the document root. Symlinks that point outside the served directory are blocked, including ones repointed after the path was first served: static files and PHP scripts are checked again on the file actually opened (see Symlink Allow Paths)
- Dot-path blocking blocks any path segment starting with
.(e.g./.git/config,/.env), with an exception for/.well-known/*per RFC 8615 - The well-known PHP block never executes
.phpscripts under/.well-known/, even though the dot-path exception lets that directory through (defense-in-depth) - The PHP execution deny-list,
PHP_DENY_PATHS, blocks.phpexecution at configured glob patterns (e.g./uploads/**, or a single file like/admin/legacy.php) before any disk I/O. It applies in the direct-mapping modes (Traditional and SPA). See PHP Execution Deny-List - Path deny rules live in a
.oxphpdenyfile at the top ofDOCUMENT_ROOT. In gitignore syntax, it lists paths that are never served (static files and scripts alike) or, with a>prefix, are handed to the entry script. It applies in every routing mode, right after the path is decoded and normalized and before any disk access
If the document root directory does not exist at startup, the server exits with a fatal error. Symlink escape protection requires a valid, resolvable document root path.
Troubleshooting
All requests return 404 in Traditional mode
Check that index.php or index.html exists at the document root. The Traditional mode's try_files chain only falls back to these, so if both are missing and no file matches the URL, you get 404.
docker exec <container> ls /var/www/html/publicMissing static asset returns 404 instead of the SPA shell
This is intentional in SPA mode: a missing /style.css is a hard 404, not a silent fallback to index.html, which catches broken asset references early. In Framework and Traditional modes a missing static file falls back to the front controller (/index.php), so your application router renders the 404. Use SPA mode if you want hard 404s on missing assets.
Direct /index.php no longer returns 404
In Framework mode, direct access to the front controller is now allowed (the rewrite to /index.php is idempotent). If you previously relied on the 404 to detect direct hits, switch to checking REQUEST_URI from inside the controller.
PATH_INFO is empty in Framework mode
This is expected for application routes. Framework mode follows CGI semantics: PATH_INFO is set only when the request explicitly names the entry file with a trailing segment (/index.php/news → /news). For a normal app route like /users/42, the front controller is reached by an internal rewrite it does not name, so PATH_INFO is absent. Read the path from $_SERVER['REQUEST_URI']. (If ENTRY_FILE does not end in .php, OxPHP picks SPA mode, which never populates PATH_INFO.)
Symlink inside document root returns 404
Symlinks that point outside the document root are blocked by design. Move the target content inside the document root, or mount it as a directory at the correct path.
Docker example
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
environment:
- DOCUMENT_ROOT=/var/www/html/public
- ENTRY_FILE=index.phpPrompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Pick the right OxPHP routing mode for this project. Read the `features/routing` page through the OxPHP documentation MCP server.
Work out from the framework and the document root in this repository which mode it belongs in, and give me the `DOCUMENT_ROOT` and `ENTRY_FILE` values along with the nginx `try_files` block they replace.URLs that worked under nginx are returning 404 on OxPHP. Read the `features/routing` page through the OxPHP documentation MCP server.
Compare the mode my configuration selects against the paths this repository actually serves — front-controller rewrites, directory indexes, anything reading `PATH_INFO` — and tell me which of them that mode does not cover.See also
- Static Files: MIME detection, caching, and streaming for served files
- Worker Mode: persistent PHP workers and worker mode routing
- Configuration Reference: full list of environment variables