Write It in PHP, Ship It as HTML
I build sites with typed PHP components and views, and I want the same quick dist/ deploy that JavaScript developers take for granted. phossil is a new project that keeps PHP for development and crawls the site into static HTML that any static host can serve.
Introduction
I build a lot of sites for customers, and I would rather write them in PHP than in a JS framework. Over the last few articles I have covered the pieces I lean on: typed and testable views and a composable component system. Those give me the type safety and composition I like from React and TypeScript, without a template engine or a new syntax.
What I have been missing is a quick deploy that JavaScript
developers for granted with a dist/ folder: run the
build, and what comes out can go on any static host in minutes. A
PHP site usually needs a server running PHP before anyone can see
it, even when most of its pages have nothing happening on the
server. If the output is just files, a static hosting service like
AWS Amplify turns a deploy into an upload.
I want to keep all of what I love about PHP during development and ship plain HTML. I aboslutely love PHP and all of the amazing modern features it has. So I built a project called phossil. While I develop, PHP renders every page. When I am ready to deploy, one command crawls the site and writes plain HTML files that any static host can serve.
A quick deploy is useful whatever the site is for. Some sites, like a marketing site or a set of docs, may not need a server, so I build them and host the HTML anywhere. Others will need a backend eventually but need customer feedback now, so I deploy a static preview today and keep the PHP for when the backend arrives. Either way, it is the same build and the same upload.
The code is on GitHub, and the full documentation is at phossil.dev, which is itself a phossil site.
And…so is this site.
Requirements
When designing this system, I had some requirements for myself:
- Typed PHP while I build - Components take typed options objects and views are plain PHP classes, so my IDE can flag issues as I work.
- The same setup on every project - PHP, nginx, and the asset tooling run the same way on every machine and every site, with no local installs to drift.
- Simple hosting requirements - what gets deployed is HTML, CSS, JS, and images.
- A deploy in minutes - going from “it works on my machine” to a live site should be one command and an upload, whether it is a finished site or a preview for a customer.
- Automatable - the same build should be able to run in a pipeline, so a push can deploy without me in the loop.
Rendering pages in PHP while I work
Every page request goes through a single front controller. nginx
hands it to phossil/index.php, the router matches the
path against a list of routes, and the matching view renders itself.
The routes file, phossil/routes/web.php, is the one
place that decides which URLs exist:
<?php
use Phossil\Services\Router\Route;
use Phossil\Services\Router\RouteType;
use Phossil\Views\About\About;
use Phossil\Views\Home\Home;
Route::create(
new RouteType(
path: '',
view: Home::class
),
new RouteType(
path: 'about',
view: About::class
)
);
A view is the same kind of class I described in the views article.
Each page gets its own folder under phossil/views/, and
its class builds the markup from components and hands it to a
layout:
<?php
namespace Phossil\Views\About;
use Phossil\Components\Layout;
use Phossil\Components\LayoutOptions;
use Phossil\Components\View;
readonly class About extends View
{
public function render(): string
{
return $this->capture(function() {
Layout::create(
new LayoutOptions(
content: $this->content(),
title: 'About',
description: 'How the static PHP build works.'
)
);
});
}
private function content(): string
{
return $this->capture(function() {
// ...page content here
});
}
}
Composer autoloads everything under the
Phossil\ namespace, so a new view or component needs no
require. Nothing here knows it will end up as a static
file, and that is the point. I write the site the way I would write
any PHP site, with typed options and IDE autocomplete, and the
static part happens later.
The same setup everywhere
The whole development environment is a Docker Compose file with
three services: php (PHP-FPM on PHP 8.5),
nginx, and vite. nginx serves images and
built assets directly and sends everything else to
index.php. No other file under
phossil/ can be requested, so the PHP source is never
served. The Vite container runs the dev server for Tailwind and
JavaScript, so CSS changes show up without a reload, and saving a
PHP file reloads the page.
composer install
pnpm install
docker compose up --build
Then the site is at http://localhost:8000. That is the
whole setup for a new machine. Every project runs the same PHP
version and the same nginx config, so I am not debugging a
customer’s site against whatever happens to be installed on my
laptop that month.
Crawling the site
The build is the piece that makes the rest worthwhile. Instead of trying to render views directly to files, the build asks the running nginx for every route, exactly as a browser would, and saves what comes back. That way the static output cannot drift from what I saw in development, because it is the same response.
<?php
namespace Phossil\Services;
use RuntimeException;
class Build
{
// this page method is called in a loop of all of the registered routes.
// the NotFound route is built every time in a separate method.
// full class with all methods at phossil/services/Build.php
protected function page(string $route, string $file_name, int $expected_code = 200)
{
// nginx is reachable by its compose service name on the shared network.
$url = 'http://nginx' . rtrim( $route, '/' ) . '/';
// ignore_errors returns the body of a 404 too, so the error page can be built.
// X-Static-Build makes vite() link the built files, even while the dev server runs.
$context = stream_context_create( [ 'http' => [
'ignore_errors' => true,
'header' => 'X-Static-Build: 1',
] ] );
$page_html = @file_get_contents( $url, false, $context );
$status = $this->status( $http_response_header ?? [] );
if ( $page_html === false || $status !== $expected_code ) {
throw new RuntimeException( "Could not build route {$route}: {$url} answered " . ( $status ?: 'nothing' ) . ", expected {$expected_code}." );
}
$file_destination = $this->dist_dir . $file_name;
$this->checkDir( $file_destination );
file_put_contents( $file_destination, $page_html );
echo "Built route {$route} -> {$file_name}\n";
}
}
The build walks the routes file, writes each route to
dist/<route>/index.html, writes the 404 page to
dist/404.html, and copies the images and Vite’s hashed
CSS and JS into dist/assets/. If any page answers with
the wrong status code, the build stops instead of shipping a broken
page.
Building from the routes file is deliberate. The build does not
discover pages by following links or scanning folders; it builds
exactly the routes I have listed and nothing else. That makes the
routes file the decision about what goes live. A page that is still
in progress gets skip_build: true, so I can keep
working on it locally and still deploy the pages that are ready:
<?php
Route::create(
new RouteType(
path: 'about',
view: About::class
),
new RouteType(
path: 'pricing',
view: Pricing::class,
skip_build: true // served locally, left out of dist/
)
);
One thing to watch: anything that links to a skipped page, like a navigation entry, still links to it in the build, so I remove those links before I deploy.
The X-Static-Build header solves one small problem.
While the Vite dev server is running, pages link to it for hot
reload. A static file can’t do that, so the header tells the
vite() helper to link the built, hashed files instead.
From zip to live site
All of this is wrapped in Composer scripts:
composer build # vite build, crawl every route into dist/, format the HTML
composer build:zip # the same, then zip dist/ into dist.zip
dist.zip is the whole site. To deploy it by hand, I
create an Amplify app that deploys without a Git provider, drop the
zip onto it, and get a URL back. That is all a customer preview
takes too: a new app for the preview, one upload, and a link to
send. The only setting I change on the host is one rewrite rule:
Amplify sends missing pages to /index.html by default,
so I point that rule at /404.html instead. The same
output works anywhere that serves files: another static host, or a
directory on a server I already have.
Because the build is a single command, it also fits in a pipeline.
phossil.dev deploys itself this way. A GitHub Actions workflow runs
on every push to main, starts the php and
nginx containers, runs composer build:zip,
and uploads the zip through Amplify’s deployment API. It signs in to
AWS with GitHub’s OIDC provider, so no AWS keys are stored in the
repository. The one honest caveat is that the build needs the
containers running, because it crawls the real site, so a pipeline
has to start Docker first. The
Deploy to AWS Amplify
guide has the full workflow and the IAM policies.
Conclusion
Looking back at the requirements I set out at the start:
- Typed PHP while I build - Views and components are the same typed PHP classes from my earlier articles, and nothing in them knows it will become a static file.
-
The same setup on every project - Docker Compose
runs PHP, nginx, and Vite the same way everywhere, and a new
machine is
docker compose up --build. - Simple hosting requirements - The build crawls the running site and saves the responses, so production is plain HTML, CSS, JS, and images.
-
A deploy in minutes -
composer build:zipproduces one file I can upload to Amplify, whether it becomes the live site or a preview link for a customer. - Automatable - The build is one command, so a pipeline can run it on every push, and phossil.dev does exactly that.
Right now phossil is a repository I clone for each new site. That
works for me, but it means every site carries its own copy of the
framework code, and a fix in one doesn’t reach the others. The next
step I am considering is splitting it into a Composer package: the
shared code would install into vendor/ and update with
composer update, and
composer create-project would set up the views, routes,
Docker config, and everything else a site owns.
Before I do it I want to know if anyone else would use it. Would you want phossil as a Composer dependency? If so, let me know, and tell me what kind of sites you would build with it.
✌️ Matt