Routing

OxPHP routes incoming HTTP requests using one of three modes, controlled by a single environment variable. Each mode 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. Dot-path filter — paths containing hidden segments (.git, .env) are blocked, with an exception for /.well-known/* (RFC 8615)
  2. Route cache lookup — recently resolved URIs are returned from an LRU cache (10 000 entries)
  3. Percent-decoding + sanitization — encoded sequences like %2e%2e are decoded and traversal segments (.., ., empty) are stripped
  4. Well-known PHP block — defense-in-depth: .php scripts inside /.well-known/ never execute
  5. URI classification — the sanitized path is classified once into NoExtension, Php, or OtherExtension
  6. Mode dispatch — each mode handles the three URI kinds with its own rules
  7. Symlink validation — every resolved filesystem path must canonicalize inside the document root

The classification step is the key efficiency: the disk check for static assets (/style.css, /logo.png) is performed once in the shared layer for OtherExtension URIs, 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 — the worker is the single front controller.

URI kind Behavior
Static assets (.css, .png, … — any non-.php extension) 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.

Two exceptions, both server-level defenses that run before mode dispatch: 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 return 404 and never reach the worker.

Startup-time validation rejects two combinations:

  • WORKER_MODE_ENABLED=true with no ENTRY_FILEWORKER_MODE_ENABLED=true requires ENTRY_FILE to be set.
  • WORKER_MODE_ENABLED=true with a non-.php ENTRY_FILEWORKER_MODE_ENABLED=true requires a .php ENTRY_FILE.

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; 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
  • Dot-path blocking blocks any path segment starting with . (e.g. /.git/config, /.env), with an exception for /.well-known/* per RFC 8615
  • Well-known PHP block — even with the dot-path exception, .php scripts under /.well-known/ are never executed (defense-in-depth)
  • PHP execution deny-list — in the direct-mapping modes (Traditional and SPA), 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. See PHP Execution Deny-List
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 — 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.10.0 ports: - "8080:80" volumes: - ./src:/var/www/html environment: - DOCUMENT_ROOT=/var/www/html/public - ENTRY_FILE=index.php

See also