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.

Maintainers

Package info

github.com/keverw/unirend-php

pkg:composer/unirend/php-static-server

Transparency log

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-07-21 01:45 UTC

This package is auto-updated.

Last update: 2026-07-21 01:45:20 UTC


README

Packagist Version Packagist Downloads

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

  • PHP 8.1+
  • Apache with mod_rewrite (standard on cPanel/shared hosting)

Installation

composer require unirend/php-static-server

Quick Start

  1. Build your Unirend SSG project. This produces a build/client/ directory with page-map.json inside.

  2. 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 .
  1. Edit index.php to 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();
  1. Deploy index.php, .htaccess, vendor/, and build/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:

  1. /404 or /500 entry in page-map.json (your SSG-generated error page)
  2. notFoundPage / errorPage option
  3. 404.html / 500.html in buildDir
  4. 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_log or the domain's error log in the control panel
  • Apache: Typically /var/log/apache2/error.log or /var/log/httpd/error_log
  • PHP-FPM: Configured via error_log in php-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 /users and /users/ will match the same route
  • 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 is decoded from the raw input stream
  • application/x-www-form-urlencoded is 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 detectImmutableAssets option is removed (the constructor throws if it is passed, pointing at the replacement). An assetFolders value 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 matching StaticWebServer from the unirend npm package: on for /assets (Vite's hashed build output), off for every other folder. Previously the top-level flag defaulted to true and applied to every folder, so a folder of verbatim public/ files with a name that merely looks hashed was served with a year-long immutable header. Configs that mount only /assets keep 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 like index-CRJ_nHAW.css was served with must-revalidate instead of the immutable Cache-Control header. Same fix applied as the unirend npm package, whose detection had the identical gap.
  • assetFolders URL prefix keys collapse repeated slashes now, and when two keys normalize to the same mount the last-declared entry wins, both matching StaticWebServer from the unirend npm package. Previously a key like /images//generated could never match a request, and duplicate-normalizing keys resolved first-declared-wins.
  • singleAssets URL keys are normalized now like assetFolders prefixes, matching StaticWebServer from the unirend npm package: a leading slash is ensured and repeated slashes are collapsed. Previously a key written as robots.txt or /icons//logo.svg was used verbatim, so it never matched any request and the asset silently 404ed.
  • singleAssets and assetFolders values that resolve outside buildDir through .. segments or symlinks are rejected, matching the Node.js StaticWebServer. Previously realpath() normalized the traversal but did not verify that the result remained inside the build directory.
  • assetFolders mounts 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 /images declared before a nested one like /images/generated swallowed every request meant for the nested mount, serving the wrong file or returning 404, and a raw prefix match let /images/generated capture /images/generated-other/... requests that belong to the shallow mount. Matches the unirend npm 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 assetFolders mounts, matching StaticWebServer from the unirend npm package. 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. 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. The filter is folder-only, so an explicit singleAssets entry stays the one honored escape hatch even when its URL is a junk name, matching the npm package's singleAssetMap. 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 new OSJunk class ported from the npm package's os-junk module, 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

  1. Update unirend-php/version.json with the new version number.
  2. 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