Features

Everything phossil does, from the router that picks a view to the zip file you upload.

Routing

Every page is one entry in app/routes/web.php: a path, and the view class that renders it. In development, nginx sends each request to app/index.php, and the router echoes the matching view, or the 404 page when nothing matches. The build reads the same list, so the site you preview is the site you ship.

app/routes/web.php
use App\Services\Router\Route;
use App\Services\Router\RouteType;
use App\Views\Home\Home;
use App\Views\Features\Features;
use App\Views\GettingStarted\GettingStarted;
use App\Views\Pricing\Pricing;

Route::create(
    new RouteType(path: '', view: Home::class),
    new RouteType(path: 'features', view: Features::class),
    new RouteType(path: 'getting-started', view: GettingStarted::class),
    new RouteType(path: 'pricing', view: Pricing::class, skip_build: true),
);
A route with skip_build: true is still served in development, but the build leaves it out, so a preview shows only the pages that are ready.

Views

A view is a readonly class that extends App\Components\View. Its render() method returns the page's markup, usually through capture(), which buffers a template closure bound to the view, so the template reads the view's properties directly.

Views nest. Echoing a view renders it, so a page can place a section with echo new Hero(). The home page of this site is four views inside a layout:

app/views/Home/Home.php
<?php
namespace App\Views\Home;

use App\Components\Layout;
use App\Components\LayoutOptions;
use App\Components\View;

readonly class Home extends View
{
    public function render(): string
    {
        return $this->capture(function () {
            Layout::create(
                new LayoutOptions(
                    content: $this->content(),
                    description: 'A small PHP site kit that builds to static HTML.'
                )
            );
        });
    }

    private function content(): string
    {
        return $this->capture(function () {
            // home content here...
        });
    }
}

For more on how views are typed, composed and tested, read Typed & Testable Views in PHP.

Components

Components are small classes, each configured by an options object: a class whose constructor takes named, typed parameters. Your IDE completes every option and flags a wrong type as you write it, and there's no template language to learn.

A button that links

Button::create(
    new ButtonOptions(
        styles: [Buttons::Compact, Buttons::Primary],
        link: new AnchorOptions(
            href: '/getting-started/',
            text: 'Get started'
        )
    )
);

Components echo their markup by default. Pass ob: true to get it back as a string instead, to hand to another component. phossil comes with these:

Component What it renders
Anchor A link, with data attributes and aria-current for the current page.
Button A button, or a link styled as one. Its classes come from the Buttons enum.
ButtonGroup A row of joined links that highlights the current page.
Badge A small colored label for a status or kind.
Card A bordered box with an optional title and footer.
DescriptionList Term and description pairs in a two-column grid.
Dialog A modal <dialog>, centered or sliding in from an edge, printed at the end of the page.
Layout The page chrome around a view's content.

For more on how the components and their options classes are designed, read A Composable PHP Component System.

CSS, JS and images

CSS and JS go through Vite, with Tailwind CSS v4. Tailwind reads the class names straight from your PHP templates.

The layout links them with vite(). While the Vite container runs, it points at the dev server, so CSS changes appear without a reload and saving a PHP file reloads the page. Otherwise it links the hashed files from Vite's manifest. A new build gives changed files new names, so browsers fetch them after a deploy.

Images are served as they are. asset() adds the file's modified time to its URL, so browsers fetch new copies of exactly the images that changed.

Linking assets

<?= vite('css/app.css', 'js/app.js'); ?>
<link rel="icon" href="<?= asset('img/favicon.svg'); ?>">

Docker

PHP, nginx and Vite run in Docker Compose, so every project and every machine runs the same versions with the same config. Start them with docker compose up and the site is at http://localhost:8000.

Service Image What it does
php php:8.5-fpm Renders every page request.
nginx nginx:1.29 Serves /assets/ and sends every other URL to index.php, on port 8000.
vite node:24-alpine Runs the Vite dev server for CSS and JS, on port 5173.

nginx serves nothing else under app/, so your PHP source is never reachable, even in development.

The static build

composer build runs vite build, then asks nginx for every route in turn and saves each response as dist/<route>/index.html. It writes the 404 page to dist/404.html, copies the CSS, JS and images to dist/assets/, and tidies the HTML with oxfmt.

Because the build fetches each page over HTTP, it saves exactly what a browser would get. A route that doesn't answer with a 200 stops the build, so a broken page never ships.

Terminal
$ composer build:zip
# pnpm build runs first and writes the hashed CSS and JS
Built route / -> /index.html
Built route /features -> /features/index.html
Built route /getting-started -> /getting-started/index.html
Built route /404 -> /404.html

composer build:zip does the same, then zips dist/ into dist.zip, ready to upload. The build is one command, so it also fits in a CI pipeline that deploys on every push.

Data at build time

The build doesn't render templates itself. It requests each page from nginx, so it saves whatever PHP returns, wherever the data came from. Add a database to your Docker environment, read a folder of Markdown, or call a headless CMS, and composer build:zip bakes the results into plain HTML.

Routes are PHP too, so they can come from the same data. One route per row gives you a page for every post, product or team member, and the build writes each one to its own index.html.

The router creates each view with new and no arguments, so a view’s constructor is a natural place to load its data, leaving render() to only render. The constructor sets a readonly property, and the template reads it as $this->post.

A blog from a MySQL table

services:
  php:
    # php:8.5-fpm, plus: RUN docker-php-ext-install pdo_mysql
    build: ./docker/php
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: site
      MYSQL_ROOT_PASSWORD: secret

The database, its credentials and your queries stay in Docker. The static host gets only the HTML they produced. That HTML is a snapshot from the moment you build, so when the data changes, build again: by hand, on a schedule, or from a CMS webhook in CI.

The php:8.5-fpm image includes SQLite but not the MySQL or PostgreSQL drivers. For those, build the php service from a small Dockerfile that runs docker-php-ext-install pdo_mysql or pdo_pgsql.

Formatting and linting

oxfmt formats your JS, CSS, JSON and Markdown, and the HTML the build writes. oxlint checks your JS.

Terminal
pnpm format        # format JS, CSS, JSON and Markdown
pnpm format:check  # the same, without writing
pnpm lint          # lint app/js

Deploying to AWS Amplify

The build is a folder of files, so deploying means getting dist/ onto a host. This site is hosted on AWS Amplify, and there are two ways to keep it up to date.

Upload a zip

Run composer build:zip, then in the Amplify console open the app, choose its branch, and drop the new dist.zip onto it to deploy an update. There's no pipeline and no credentials to set up, which makes it the quickest way to put a preview in front of a client. Getting started covers creating the app the first time.

Deploy on every push

This site deploys itself whenever main changes, including when a pull request is merged. A GitHub Actions workflow starts the php and nginx containers on the runner, runs composer build:zip, uploads the zip through Amplify's deployment API, and waits for the job, so a failed deploy fails the run.

Deploying from GitHub Actions

# Builds the static site and deploys it to an AWS Amplify app that was set up to
# deploy without a Git provider.
name: Deploy

on:
  # Runs for every push to main, which includes merged pull requests
  push:
    branches: [main]
  workflow_dispatch:

# One deploy at a time. A newer push waits for the running one instead of racing it.
concurrency:
  group: deploy
  cancel-in-progress: false

permissions:
  contents: read
  # Lets configure-aws-credentials sign in to AWS with OIDC, so no AWS keys are stored
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    env:
      # Repository variables, under Settings > Secrets and variables > Actions
      AMPLIFY_APP_ID: ${{ vars.AMPLIFY_APP_ID }}
      AMPLIFY_BRANCH: ${{ vars.AMPLIFY_BRANCH }}
      # Layer docker-compose.ci.yml over the usual config, for every docker compose
      # command here and the one composer build runs
      COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.5"
          tools: composer

      # Installs the pnpm version named by packageManager in package.json
      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm

      - name: Install dependencies
        run: |
          composer install --no-interaction --no-progress
          pnpm install --frozen-lockfile

      - name: Start PHP and nginx
        run: |
          # The php container runs as this user, so the files it writes stay ours
          HOST_UID=$(id -u)
          HOST_GID=$(id -g)
          export HOST_UID HOST_GID
          echo "HOST_UID=$HOST_UID" >> "$GITHUB_ENV"
          echo "HOST_GID=$HOST_GID" >> "$GITHUB_ENV"

          # Create dist/ before Docker mounts it, or Docker creates it owned by root
          mkdir -p dist

          # The build only needs these two; pnpm build runs here, not in the vite container
          docker compose up -d --wait php nginx

      - name: Build
        run: composer build:zip

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Deploy to Amplify
        run: |
          # Amplify hands back a job and a URL to upload the zip to
          deployment=$(aws amplify create-deployment --app-id "$AMPLIFY_APP_ID" --branch-name "$AMPLIFY_BRANCH")
          job_id=$(jq -r .jobId <<< "$deployment")
          upload_url=$(jq -r .zipUploadUrl <<< "$deployment")

          curl --fail --silent --show-error -H "Content-Type: application/zip" --upload-file dist.zip "$upload_url"

          aws amplify start-deployment --app-id "$AMPLIFY_APP_ID" --branch-name "$AMPLIFY_BRANCH" --job-id "$job_id" > /dev/null
          echo "Started Amplify job $job_id"

          # Wait for the job, so a failed deploy fails this run
          while true; do
            status=$(aws amplify get-job --app-id "$AMPLIFY_APP_ID" --branch-name "$AMPLIFY_BRANCH" --job-id "$job_id" --query job.summary.status --output text)
            case "$status" in
              SUCCEED)
                echo "Deployed"
                break
                ;;
              FAILED | CANCELLED)
                echo "::error::Amplify job $job_id finished with status $status"
                exit 1
                ;;
            esac
            sleep 10
          done

To set it up once:

  1. Create the Amplify app without a Git provider and upload one dist.zip by hand. That first upload creates the branch the workflow deploys to.
  2. Create an IAM role that GitHub can assume through OIDC, so no AWS keys are stored in the repository, and give it the policy above. Its trust policy's sub has to match what GitHub sends, which you can check with gh api repos/<owner>/<repo>/actions/oidc/customization/sub.
  3. Add AWS_ROLE_ARN as a repository secret, and AWS_REGION, AMPLIFY_APP_ID and AMPLIFY_BRANCH as repository variables.
On a Linux runner, files the php container writes to dist/ would belong to root, and the formatting step after the build couldn't rewrite them. docker-compose.ci.yml runs php as the runner's user instead.