Static Files
OxPHP serves static files straight from the document root without invoking PHP. Each file is served with automatic MIME type detection, an in-memory cache for fast repeated access, and full HTTP caching: ETags, conditional requests, and Range requests for partial downloads.
How it works
When a request matches a static file:
- File matched — the routing layer resolves the URL path to a file on disk
- MIME detection — the content type is determined from the file extension
- Cache check — the file cache is checked before touching the filesystem
- Conditional check — if the request carries
If-None-MatchorIf-Modified-Since, OxPHP evaluates the condition and may return304 Not Modifiedwithout sending a body - Range check — if a GET or HEAD request carries a
Rangeheader, OxPHP responds with206 Partial Content: GET receives only the requested byte range, HEAD the same range headers with no body - Response — files up to 1 MiB are served from the in-memory cache; larger files are streamed directly from disk
Configuration
| Variable | Default | Description |
|---|---|---|
STATIC_MAX_AGE |
30d |
Cache-Control: max-age for static files. Accepts 30s, 5m, 2h, 30d, 1w, 1y, a bare number of seconds (e.g. 3600), or off to disable caching headers entirely. Replaces deprecated STATIC_CACHE_TTL. |
STATIC_REVALIDATE |
off |
Set to on to enable mtime revalidation on the in-memory content cache (re-checks each file at most once per 3 seconds; changes become visible within that window). Replaces deprecated STATIC_CACHE (where off had the inverse meaning). |
MIME detection
MIME types are determined automatically from the file extension. If no type can be determined, the server falls back to application/octet-stream. Common mappings include:
| Extension | Content-Type |
|---|---|
.html |
text/html |
.css |
text/css |
.js |
text/javascript |
.json |
application/json |
.png |
image/png |
.svg |
image/svg+xml |
.woff2 |
font/woff2 |
File caching
OxPHP uses an in-memory cache to reduce disk I/O for frequently requested files:
- Files up to 1 MiB (1,048,576 bytes) are read into memory and cached. The total cache budget is 64 MiB (67,108,864 bytes). When the budget is exceeded, the least recently used entries are evicted to make room.
- Files larger than 1 MiB are always streamed directly from disk. The
Content-Lengthheader is set from file metadata so the client knows the total size upfront.
The file cache is populated on the first request to each file and retained across subsequent requests. By default, cache entries persist until evicted by the LRU policy.
Content revalidation
Set STATIC_REVALIDATE=on to enable mtime-based revalidation. In this mode the server re-checks a cached file's modification time with a stat() syscall at most once every 3 seconds per file, not on every request. If the file has changed on disk, the stale entry is evicted and the file is re-read automatically. Within the 3-second window a cached entry is served straight from memory with no syscall, so the cost is amortized rather than paid per request. Changes on disk become visible within 3 seconds.
Turn STATIC_REVALIDATE=on in development so you see file changes without restarting the server. Leave it unset in production (the default off) for maximum throughput with zero per-request syscall overhead.
HTTP caching
Cache-Control
When STATIC_MAX_AGE is set (the default is 30d), every static file response includes a Cache-Control header:
Cache-Control: public, max-age=2592000The max-age value is the TTL converted to seconds. Set STATIC_MAX_AGE=off to omit this header entirely.
ETag and Last-Modified
Every static file response includes:
- ETag — a strong ETag in the format
"<size>-<mtime_hex>", derived from the file size and last modification time. A strong validator also satisfiesIf-Range, so interrupted downloads can resume safely. When a response is served brotli-compressed, the tag is weakened toW/"…"— the compressed bytes are a different representation, and a weak tag still revalidates (304) but prevents mixing compressed and uncompressed fragments on resume. - Last-Modified — an RFC 7231 HTTP date based on the file's modification time
These headers allow browsers and CDNs to validate cached copies without re-downloading the file.
Conditional requests (304)
OxPHP evaluates conditional request headers to avoid sending unchanged file content:
- If-None-Match — the client sends the ETag it has cached. If it matches the current file, OxPHP returns
304 Not Modifiedwith no body. - If-Modified-Since — the client sends a timestamp. If the file has not been modified since that time, OxPHP returns 304.
If-None-Match takes priority over If-Modified-Since per RFC 7232. For files already in the in-memory cache, the conditional check runs without any disk I/O.
Range requests (206)
Static file responses advertise Accept-Ranges: bytes, and GET requests with a single-range Range header receive only the requested bytes:
GET /videos/intro.mp4 HTTP/1.1
Range: bytes=1048576-
HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-52428799/52428800
Content-Length: 51380224This enables <video>/<audio> seeking in browsers, resumable downloads (wget -c, download managers), and partial PDF loading. All three range forms from RFC 9110 are supported: bytes=N-M, bytes=N- (from offset to end), and bytes=-N (last N bytes).
- A range that cannot be satisfied (start beyond the end of file) returns
416 Range Not SatisfiablewithContent-Range: bytes */<size>. - If-Range is honored: when the client sends the ETag (or
Last-Modifieddate) of its partial copy and the file has changed since, OxPHP returns the full200response instead of a mismatched fragment. The date form is only accepted once the file's modification second has fully elapsed — a just-written file could change again within the same second without moving the date, so it is not yet a strong validator (RFC 9110). - Requests with multiple ranges (
bytes=0-1,4-5) receive the full file as200 OK—multipart/byterangesresponses are not generated. - HEAD requests with a
Rangeheader receive the same206/Content-Rangeheaders as GET without a body, matching nginx and Apache. - Ranges and compression are mutually exclusive. For clients that accept brotli, range handling is disabled on representations that would be served compressed, and compressed responses do not advertise
Accept-Ranges— a resumed download could otherwise splice uncompressed bytes onto a compressed prefix. Only files served from the in-memory cache (up to 1 MiB) are ever compressed, so ranges always work for the content that actually needs them: video, archives, images, and every file streamed from disk. Responses for compression-eligible files always carryVary: Accept-Encoding— even when served uncompressed — so shared caches keep the variants apart. 206responses are never compressed, and range handling does not apply to PHP responses — only to static files.
Example: resume an interrupted download with curl:
curl -C - -O https://example.com/dist/app-installer.dmgDisabling caching
There are two independent cache layers and a variable for each:
| Variable | Controls | Effect of off |
|---|---|---|
STATIC_MAX_AGE=off |
Browser cache (HTTP headers) | No Cache-Control, ETag, or Last-Modified headers sent |
STATIC_REVALIDATE=on |
Server in-memory cache | Re-checks file mtime at most once per 3s per file; stale entries evicted automatically |
For development, set STATIC_REVALIDATE=on so the server always serves fresh content. Optionally also set STATIC_MAX_AGE=off to prevent browser caching entirely.
Troubleshooting
Server keeps serving stale files
By default, the in-memory content cache does not check whether files have changed on disk. Set STATIC_REVALIDATE=on during development to enable mtime revalidation — the server detects file changes automatically (within 3 seconds).
Browser keeps serving stale files
If the server is returning fresh content but the browser still shows the old version, the browser's own cache is the culprit. Set STATIC_MAX_AGE=off to stop sending caching headers, or use your browser's hard reload (Shift+F5 or Cmd+Shift+R).
Files are served with `application/octet-stream`
OxPHP uses the file extension to determine the MIME type. If an extension is missing or not recognized, it falls back to application/octet-stream. Add the correct extension to your file, or ensure your framework sets the Content-Type header explicitly in PHP responses.
Large files seem slow
Files larger than 1 MiB are streamed from disk on every request and are not cached in memory. For very large files, place a CDN in front of OxPHP to cache them at the edge. Alternatively, restructure your assets so frequently served files stay under 1 MiB.
304 responses are returned when you expect 200
A 304 means the client already has the current version. This is correct behavior. If you need to force a fresh response during development, set STATIC_MAX_AGE=off to stop sending ETag and Last-Modified headers.
Docker example
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
- STATIC_MAX_AGE=1yBest practices
- Use long TTLs with cache-busting filenames in production (e.g.
app.a1b2c3.js). SetSTATIC_MAX_AGE=1yfor maximum browser and CDN caching. - Set
STATIC_REVALIDATE=onduring development so the server detects file changes automatically. Optionally also setSTATIC_MAX_AGE=offto bypass browser caching. - Place a CDN in front of OxPHP for high-traffic sites. The
ETag,Last-Modified, andCache-Controlheaders work with all major CDN providers. - Let your build tool handle asset hashing. Frameworks like Vite and Laravel Mix generate hashed filenames automatically, making long cache TTLs safe.
See also
- Compression — Brotli compression for compressible static file responses
- Routing — how URL paths are resolved to files on disk
- Configuration Reference — full list of environment variables