unirend / php-static-server
PHP companion to StaticWebServer — deploy Unirend SSG output on shared hosting with clean URLs, proper 404/500 status codes, range requests, and custom routes.
Requires
- php: ^8.1
Requires (Dev)
- phpunit/phpunit: ^11.0
README
Current version: 0.1.1
Serve Unirend SSG output on shared hosting (cPanel, Apache). Mirrors StaticWebServer from the Node.js package. It reads the same page-map.json format, serves clean URLs, handles 404/500 error pages with correct status codes, range requests, and custom API routes.
- Requirements
- Installation
- Quick Start
- Options
- Error Pages
- Error Logging
- Custom Routes
- Range Requests
.htaccess- Local Development
- Versioning
- Changelog
- Contributing to unirend-php
- License
Requirements
- PHP 8.1+
- Apache with
mod_rewrite(standard on cPanel/shared hosting)
Installation
composer require unirend/php-static-server
Quick Start
-
Build your Unirend SSG project. This produces a
build/client/directory withpage-map.jsoninside. -
Copy the templates into your hosting document root:
cp vendor/unirend/php-static-server/templates/index.php . cp vendor/unirend/php-static-server/templates/.htaccess .
- Edit
index.phpto point at your build directory:
<?php require_once __DIR__ . '/vendor/autoload.php'; use Unirend\StaticServer\StaticServer; $server = new StaticServer([ 'buildDir' => __DIR__ . '/build/client', ]); $server->serve();
- Deploy
index.php,.htaccess,vendor/, andbuild/client/to your host.
Options
| Option | Type | Default | Description |
|---|---|---|---|
buildDir |
string |
required | Absolute path to your SSG build directory |
pageMapPath |
string |
'page-map.json' |
Path to page map, relative to buildDir |
singleAssets |
array |
[] |
Map individual files (favicon, robots.txt, etc.), merged with page map, takes precedence on conflicts with page map and asset folders |
assetFolders |
array |
[] |
URL prefix → directory mappings for asset folders. A value can also be a ['path' => ..., 'detectImmutableAssets' => ...] array to set immutable detection per folder |
notFoundPage |
string|null |
null |
Custom 404 page path, relative to buildDir |
errorPage |
string|null |
null |
Custom 500 page path, relative to buildDir |
cacheControl |
string |
'public, max-age=0, must-revalidate' |
Cache-Control for HTML pages |
immutableCacheControl |
string |
'public, max-age=31536000, immutable' |
Cache-Control for hashed assets |
isDevelopment |
bool |
false |
Show stack traces in default 500 error page HTML |
logErrors |
bool |
true |
Enable error_log() as the fallback when no onError hook is set (or when the hook throws) |
onError |
callable|null |
null |
Custom error hook called with (\Throwable $e, string $context). Fires regardless of logErrors. If the hook throws, falls back to error_log() only if logErrors is true. |
singleAssets
Map individual URLs to files, useful for robots.txt, favicon.ico, etc.
'singleAssets' => [ '/robots.txt' => 'robots.txt', '/favicon.ico' => 'favicon.ico', '/sitemap.xml' => 'sitemap.xml', ],
assetFolders
Map URL prefixes to asset directories. Files with content hashes in their names (e.g. app.abc123ef.js) automatically get immutable Cache-Control headers when detection is on for that folder.
Detection resolves per folder, mirroring StaticWebServer from the Node.js package: the per-folder value if you set one, otherwise the name-based default. That default is on for /assets (Vite's hashed build output) and off for every other folder, since those usually hold verbatim public/ files where a name that merely looks hashed must not get a year-long immutable header. There is no top-level flag, so passing detectImmutableAssets at the top level throws. Detection is a filename heuristic: a dot or dash followed by a run of 6 or more characters from the base64url alphabet that contains at least one digit or uppercase letter, then another dot. Plain lowercase words like some-multi-word.txt or apple-touch-icon.png therefore do not match, though rarer false positives are still possible when a name segment does contain a digit or uppercase letter, such as an all-caps word with digits like report-CHAPTER2.pdf. The reverse miss is accepted too: a rare all-lowercase real hash (well under 0.1% of outputs) is indistinguishable from a word, so it falls back to the regular cache header rather than risking names like chunk-vendors.js being cached as immutable. Only opt a folder in when it genuinely contains fingerprinted files.
'assetFolders' => [ '/assets' => 'assets', // Per-folder config: opt another folder of hashed files into detection '/downloads' => ['path' => 'downloads', 'detectImmutableAssets' => true], // Verbatim files: plain string, detection off by default '/.well-known' => '.well-known', ],
OS metadata files are never served from a folder mount. A request that resolves to a junk name (macOS .DS_Store and ._* AppleDouble files, Windows Thumbs.db, ehthumbs.db, desktop.ini, and the rest) returns 404 before touching disk, matching StaticWebServer from the Node.js package. Folder mounts resolve the file straight from the URL, so a junk file that slips into a declared folder (SSG copies public/ verbatim into the build) would otherwise be reachable. Every path segment is checked, not just the basename, so a file routed through an OS metadata directory like /assets/.AppleDouble/metadata is blocked even though metadata is not itself junk, and a junk-named mount prefix cannot launder junk either. This filter is folder-only. An explicit singleAssets entry is an exact-match opt-in, so it is honored even when its URL is a junk name, the sole escape hatch.
Error Pages
Error pages are loaded at startup using the same priority chain as the Node.js StaticWebServer:
/404or/500entry inpage-map.json(your SSG-generated error page)notFoundPage/errorPageoption404.html/500.htmlinbuildDir- Built-in generic HTML fallback
If your SSG generates /404 or /500 pages, they are automatically removed from the normal route map so they can only be served via error handlers with the correct status codes.
Error Logging
Default Behavior (logErrors: true)
By default, exceptions are written to PHP's error log via error_log() before displaying error pages:
$server = new StaticServer([ 'buildDir' => __DIR__ . '/build/client', ]); // Exceptions are logged to PHP's error log automatically
Custom Error Hook (onError)
Use onError to route errors to your own logging system instead of error_log(). The hook receives the exception and a context string describing where the error occurred (e.g. 'Custom route handler error'):
$server = new StaticServer([ 'buildDir' => __DIR__ . '/build/client', 'onError' => function (\Throwable $e, string $context): void { // Send to your logging service, write to a custom log file, etc. myLogger()->error($context . ': ' . $e->getMessage(), [ 'exception' => $e, ]); }, ]);
If the hook itself throws, the error is silently caught and error_log() is used as a fallback (unless logErrors: false). The 500 response is still sent correctly.
Disabling error_log() Fallback
Set logErrors: false to disable the built-in error_log() fallback. A custom onError hook will still fire if one is provided. logErrors only controls whether error_log() is used:
$server = new StaticServer([ 'buildDir' => __DIR__ . '/build/client', 'logErrors' => false, // Disables error_log() — onError hook still fires if set ]);
To suppress all error logging entirely, set logErrors: false and omit onError.
Error pages are always displayed normally regardless of logging configuration.
PHP Error Log Location
Where error_log() writes depends on your PHP and server configuration:
- cPanel/shared hosting: Usually
~/logs/error_logor the domain's error log in the control panel - Apache: Typically
/var/log/apache2/error.logor/var/log/httpd/error_log - PHP-FPM: Configured via
error_loginphp-fpm.conf - Local dev (
php -S): Printed to the terminal
Custom Routes
Add API endpoints or other server-side logic before calling serve(). Custom routes are checked before static file lookup, so they can also override static pages if needed.
Note: Custom route handlers are responsible for setting their own headers (Content-Type, Cache-Control, etc.). Only static files served from the page map or asset folders get automatic cache headers.
Route Path Normalization
Routes are automatically normalized for convenience:
- Empty paths (
'') are treated as root ('/') - Missing leading slashes are added automatically (
'api/users'→'/api/users') - Trailing slashes are removed for flexible matching (
'/users/'→'/users')- Both
/usersand/users/will match the same route
- Both
- HTTP methods are case-insensitive (
'get'and'GET'both work) - Paths are case-sensitive (
'/api/Users'≠'/api/users')
Design Note: This is more forgiving than the default TypeScript/Fastify implementation. Since PHP executes per-request rather than as a long-running server, we normalize paths instead of throwing errors to avoid production outages from configuration mistakes.
$server = new StaticServer([ 'buildDir' => __DIR__ . '/build/client', 'assetFolders' => ['/assets' => 'assets'], ]); // Simple endpoint $server->addRoute('POST', '/api/contact', function ( array $params, array $body, ): void { // $body is parsed from JSON body or $_POST $name = $body['name'] ?? 'stranger'; // send email, save to DB, etc. header('Content-Type: application/json'); echo json_encode(['ok' => true]); }); // Dynamic route with named :param segments $server->addRoute('GET', '/api/posts/:id', function ( array $params, array $body, ): void { $id = (int) $params['id']; header('Content-Type: application/json'); echo json_encode(['id' => $id]); }); // Start serving requests — handles routing, static files, and error pages // (Your web server with PHP handles the actual HTTP listening) $server->serve();
Request Body Parsing
The $body parameter in route handlers is parsed automatically based on the request's Content-Type:
application/jsonis decoded from the raw input streamapplication/x-www-form-urlencodedis parsed from$_POST
Range Requests
Supports HTTP range requests for video/audio seeking and resumable downloads. Single-range requests (Range: bytes=0-499, Range: bytes=500-, Range: bytes=-500) return 206 Partial Content. Multipart range requests are not supported and return 416 Range Not Satisfiable.
.htaccess
The included .htaccess routes all requests through index.php:
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L]
Note there is no !-f condition. This means raw .html files are never served directly by Apache. All requests go through index.php. This prevents React hydration mismatches that would occur if a user accessed /about.html instead of /about.
Local Development
PHP's built-in server works for local testing (.htaccess rules don't apply, but all requests go through index.php automatically).
php -S localhost:8080 index.php
Versioning
This package is versioned independently from the unirend npm package. It targets a specific use case (PHP shared hosting) and changes less frequently, so version numbers will not match between the two.
Changelog
Runs oldest to newest. The 0.0.x line was early iteration that was not logged per version, so entries start at 0.1.0.
0.1.0 (July 16, 2026)
- Breaking: Immutable-asset detection now resolves per folder, and the top-level
detectImmutableAssetsoption is removed (the constructor throws if it is passed, pointing at the replacement). AnassetFoldersvalue can be a['path' => ..., 'detectImmutableAssets' => ...]array as well as a plain string, and detection is the per-folder value if given, otherwise a name-based default matchingStaticWebServerfrom theunirendnpm package: on for/assets(Vite's hashed build output), off for every other folder. Previously the top-level flag defaulted totrueand applied to every folder, so a folder of verbatimpublic/files with a name that merely looks hashed was served with a year-long immutable header. Configs that mount only/assetskeep identical behavior with no flag at all. - Fixed immutable-asset detection missing fingerprinted files whose hash contains
_or-. Vite/Rollup hashes use the base64url alphabet, but detection only accepted alphanumerics, so a file likeindex-CRJ_nHAW.csswas served withmust-revalidateinstead of the immutable Cache-Control header. Same fix applied as theunirendnpm package, whose detection had the identical gap. assetFoldersURL prefix keys collapse repeated slashes now, and when two keys normalize to the same mount the last-declared entry wins, both matchingStaticWebServerfrom theunirendnpm package. Previously a key like/images//generatedcould never match a request, and duplicate-normalizing keys resolved first-declared-wins.singleAssetsURL keys are normalized now likeassetFoldersprefixes, matchingStaticWebServerfrom theunirendnpm package: a leading slash is ensured and repeated slashes are collapsed. Previously a key written asrobots.txtor/icons//logo.svgwas used verbatim, so it never matched any request and the asset silently 404ed.singleAssetsandassetFoldersvalues that resolve outsidebuildDirthrough..segments or symlinks are rejected, matching the Node.jsStaticWebServer. Previouslyrealpath()normalized the traversal but did not verify that the result remained inside the build directory.assetFoldersmounts now resolve by longest matching URL prefix instead of declaration order, and prefixes only match on path-segment boundaries. Previously the first prefix that matched won, so a shallow mount like/imagesdeclared before a nested one like/images/generatedswallowed every request meant for the nested mount, serving the wrong file or returning 404, and a raw prefix match let/images/generatedcapture/images/generated-other/...requests that belong to the shallow mount. Matches theunirendnpm package, whose cache had the same ordering fix and already matched on boundaries.
0.1.1 (July 20, 2026)
- OS junk files are no longer served from
assetFoldersmounts, matchingStaticWebServerfrom theunirendnpm package. A request that resolves to a junk name (macOS.DS_Storeand._*AppleDouble files, WindowsThumbs.db,ehthumbs.db,desktop.ini, and the rest) returns 404 before touching disk. Every path segment is checked, not just the basename, so a file routed through an OS metadata directory like/assets/.AppleDouble/metadatais blocked even thoughmetadatais not itself junk, and a junk-named mount prefix cannot launder junk either. The filter is folder-only, so an explicitsingleAssetsentry stays the one honored escape hatch even when its URL is a junk name, matching the npm package'ssingleAssetMap. Previously these files were served verbatim, exposing Finder and Explorer metadata (local filenames, folder structure, window state) that had slipped into a deployed folder. The recognition logic lives in a newOSJunkclass ported from the npm package'sos-junkmodule, tests included.
Contributing to unirend-php
The canonical source for this package is the unirend monorepo, open issues and PRs there. Changes should be made in the main Unirend repo first, then published separately to the github.com/keverw/unirend-php mirror that Packagist reads from. Do not commit to the mirror directly except as part of the publish process.
Running Tests
From the monorepo root:
Install PHP dependencies (first time, or after dependency changes):
bun run php-install-deps
Run tests:
bun run php-test
Running the Demo Locally
A minimal demo site is included in the monorepo under unirend-php/demo/ for development and testing purposes. It exercises clean URLs, a custom 404, an immutable-cached asset, and custom routes.
Note: The demo is not included in the published Composer package. It's only available in the monorepo.
cd unirend-php composer install cd demo php -S localhost:8080 index.php
Open http://localhost:8080 and explore the links listed on the home page.
Publishing a New Version
- Update
unirend-php/version.jsonwith the new version number. - Run the publish script from the monorepo root:
bun run php-publish
The script clones the mirror repo, syncs files (excluding vendor/, demo/, version.json, etc.), updates the version line in this README, commits Release vX.Y.Z, tags it, and pushes. That triggers Packagist to update automatically via webhook.
License
MIT