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.
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),
);
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:
<?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'
)
)
);
class ButtonOptions extends ComponentOptions
{
public function __construct(
public string $text = '',
public array $styles = [Buttons::Medium],
public string $cursor = 'pointer',
public bool $disabled = false,
public AnchorOptions $link = new AnchorOptions(),
public array $data = [],
...$args
) {
parent::__construct(...$args);
}
}
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'); ?>">
<link rel="stylesheet" href="/assets/app-DQowg002.css">
<script type="module" src="/assets/app-BmXq3sLk.js"></script>
<link rel="icon" href="/assets/img/favicon.svg?v=1761490000">
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.
$ 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
// One connection, made as the app loads and shared by the routes and every view
$db = new PDO('mysql:host=db;dbname=site', 'root', 'secret');
Route::create(
new RouteType(path: '', view: Home::class),
new RouteType(path: 'blog', view: Blog::class),
);
// One page per published post, such as /blog/hello-world/,
// using the $db that includes/functions.php connected
foreach ($db->query('SELECT slug FROM posts WHERE published = 1') as $post) {
Route::create(new RouteType(path: 'blog/' . $post['slug'], view: Post::class));
}
readonly class Post extends View
{
private array $post;
// The router calls `new Post()` for each request, so the constructor
// is the place to load data, and render() only renders.
public function __construct()
{
global $db; // Connected once, in includes/functions.php
// The path is blog/<slug>, so the slug is the second segment
$query = $db->prepare('SELECT title, body FROM posts WHERE slug = ?');
$query->execute([Request::segments()[1]]);
$this->post = $query->fetch();
}
public function render(): string
{
return $this->capture(function () {
Layout::create(
new LayoutOptions(
content: '<h1>' . e($this->post['title']) . '</h1>' . $this->post['body'],
title: $this->post['title']
)
);
});
}
}
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.
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.
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
# Layered over docker-compose.yml by the deploy workflow.
#
# On Linux, files a container writes to a bind mount belong to the user it runs
# as. The build writes dist/ from the php container, then oxfmt rewrites those
# files on the host, so php runs as the CI user rather than root.
services:
php:
user: "${HOST_UID}:${HOST_GID}"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"amplify:CreateDeployment",
"amplify:StartDeployment",
"amplify:GetJob"
],
"Resource": [
"arn:aws:amplify:<region>:<account-id>:apps/<app-id>/branches/<branch>",
"arn:aws:amplify:<region>:<account-id>:apps/<app-id>/branches/<branch>/*"
]
}
]
}
To set it up once:
-
Create the Amplify app without a Git provider and upload one
dist.zipby hand. That first upload creates the branch the workflow deploys to. -
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
subhas to match what GitHub sends, which you can check withgh api repos/<owner>/<repo>/actions/oidc/customization/sub. -
Add
AWS_ROLE_ARNas a repository secret, andAWS_REGION,AMPLIFY_APP_IDandAMPLIFY_BRANCHas repository variables.
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.