Oct 27, 2026

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:

  1. 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.
  2. 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.
  3. Simple hosting requirements - what gets deployed is HTML, CSS, JS, and images.
  4. 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.
  5. 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:

  1. 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.
  2. 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.
  3. Simple hosting requirements - The build crawls the running site and saves the responses, so production is plain HTML, CSS, JS, and images.
  4. A deploy in minutes - composer build:zip produces one file I can upload to Amplify, whether it becomes the live site or a preview link for a customer.
  5. 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
Share this