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.0.2
Serve Unirend SSG output on shared hosting (cPanel, Apache). Mirrors StaticWebServer from the Node.js package — 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
- 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 |
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 |
detectImmutableAssets |
bool |
true |
Auto-detect content-hashed filenames |
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.
'assetFolders' => [ '/assets' => 'assets', ],
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/json— decoded from the raw input streamapplication/x-www-form-urlencoded— 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 — version numbers will not match between the two.
Contributing to unirend-php
The canonical source for this package is the unirend monorepo — open issues and PRs there. The repository at github.com/keverw/unirend-php is a publish-only mirror that Packagist reads from; do not commit to it directly.
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 — which triggers Packagist to update automatically via webhook.
License
MIT