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:

  1. The dot-path filter blocks paths containing hidden segments (.git, .env), with an exception for /.well-known/* (RFC 8615)
  2. The route cache returns recently resolved URIs from an LRU cache (10 000 entries)
  3. Percent-decoding and sanitization decode sequences like %2e%2e and strip traversal segments (.., ., empty)
  4. Path deny rules apply when a .oxphpdeny file holds at least one deny or > rule. The sanitized path is matched against them before any disk access: a deny rule answers with PHP_DENY_FALLBACK, a > rule sends the request to the entry script, and a path past the file's depth and length limits gets 404
  5. The well-known PHP block never lets .php scripts inside /.well-known/ execute, as defense-in-depth
  6. URI classification sorts the sanitized path, once, into NoExtension, Php, or OtherExtension
  7. Mode dispatch hands the request to the active mode, which handles the three URI kinds with its own rules
  8. 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:

nginx
location / { try_files $uri $uri/ /index.php /index.html =404; } location ~ \.php$ { try_files $uri =404; # PATH_INFO splitting enabled }

Resolution order:

  1. $uri: exact file on disk → serve (or execute if .php)
  2. $uri/: directory → look for index.php, then index.html inside it
  3. PATH_INFO split: when the URI contains .php/, the script prefix is matched on disk and the remainder becomes PATH_INFO (e.g. /api.php/users/42 → script api.php, PATH_INFO=/users/42)
  4. /index.php: root front-controller fallback
  5. /index.html: root static index fallback
  6. 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.

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=true with no ENTRY_FILE → WORKER_MODE_ENABLED=true requires ENTRY_FILE to be set.
  • WORKER_MODE_ENABLED=true with a non-.php ENTRY_FILE → WORKER_MODE_ENABLED=true requires a .php ENTRY_FILE.
  • A .oxphpdeny deny rule with a script PHP_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/passwd are 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 .php scripts 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 .php execution 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 .oxphpdeny file at the top of DOCUMENT_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
Note

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.

bash
docker exec <container> ls /var/www/html/public
Missing 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

compose.yaml
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.php

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
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.
prompt
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

Found a mistake? Report it →