flat-file · assembly order

Getting started.

Seven steps to a static site: one dependency, one page, one layout, one build. The example is the one this page was verified against — PHP 8.5.11 and Composer 2.10.3 — not a sketch of it.

Commands below were run in a throwaway project while writing this page, against the revision this microsite itself pins (dev-code-highlight, 3ba5285). The two commands that would start a server or deploy were not run, and they say so where they appear.

Check the two versions that matter

You need PHP and Composer, and nothing else. PHP runs at build time; the output is plain HTML, so the host that serves the site does not need PHP at all.

The versions used here: PHP 8.5.11 (Homebrew, CLI) and Composer 2.10.3. PHP 8.3 or later is a safe floor for what follows, because the pinned revision pulls Symfony Console 7 and CommonMark 2.

The one caveat that decides the install. The published tags 1.0.0–1.0.3 and the master branch — the branch the source README's sample composer.json names as dev-master — declare php ~7.1. On current PHP, Composer refuses them outright:

- slogsdon/flat-file[1.0.0, ..., 1.0.3] require php ~7.1
  -> your php version (8.5.11) does not satisfy that requirement.

The same applies to composer create-project slogsdon/flat-file-starter: the starter requires ^1.0, so it fails the same way. The working constraint is the dev-code-highlight branch, pinned to a commit in step 2.

Write composer.json, then install

This is the README's sample file with one substitution: the dependency is the branch that installs on current PHP, not dev-master. The scripts and config sections are optional — they only give the two commands shorter names, and process-timeout lets the server script outlive Composer's 300-second default.

{
    "name": "user/site",
    "description": "",
    "require": {
        "slogsdon/flat-file": "dev-code-highlight"
    },
    "scripts": {
        "build": "flat-file build",
        "start": "flat-file serve"
    },
    "config": {
        "process-timeout": 0
    }
}

Then pull down dependencies:

composer install

That resolves 22 packages and installs slogsdon/flat-file (dev-code-highlight 3ba5285) into vendor/. Commit the generated composer.lock; without it a later install can resolve a different commit of the same branch.

If you drop the scripts section, call the binary directly: vendor/bin/flat-file build and vendor/bin/flat-file serve.

Add pages

Create a pages directory next to composer.json. This is the whole homepage:

<?php /* pages/index.php */ ?>
<h1>Hello World</h1>

A second page in Markdown needs no front matter and no wrapper:

<!-- pages/about.md -->
# About

flat-file reads this file and writes /about.

Routing is the file system. The filename, minus its extension, is the URL; a nested directory is a nested path. These are the paths this example produced:

file to URL to artifact
Source fileURLBuild output
pages/index.php/dist/index.html
pages/about.md/aboutdist/about/index.html
pages/notes/hello.php/notes/hellodist/notes/hello/index.html
pages/404.html.php/404.htmldist/404.html
public/assets/site.css/assets/site.cssdist/assets/site.css

The last two rows are the two exceptions to the <directory>/index.html rule. A page whose filename already carries an extension is written to that exact filename, which is how a 404.html.php page produces a root 404.html; and public/ is copied through verbatim, keeping its own paths.

Print, don't return. The README says a PHP page may return its content as a string (<?php return 'hello world';). In the revision pinned here that page builds to a zero-byte file: PHP pages are rendered through the template engine, which captures output and discards a return value. Use markup or echo.

Wrap the pages in a layout

Without a layout every page has to carry its own <html>, <head>, and navigation. Put the shared shell in resources/partials/, which the generator registers as a template folder named partials:

<?php /* resources/partials/layout.php */ ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title><?= $this->e($title) ?></title>
    <meta name="description" content="<?= $this->e($description) ?>">
</head>
<body>
    <main><?= $this->section('content') ?></main>
</body>
</html>

A PHP page then opens by declaring the layout and the values it needs. Everything after that call is captured as the content section the layout prints:

<?php /* pages/index.php */ ?>
<?php $this->layout('partials::layout', [
    'title' => 'Home',
    'description' => 'A flat-file example.',
]); ?>
<h1>Hello World</h1>
<p>Rendered by flat-file through a shared layout.</p>

Markdown pages reach the same mechanism through front matter, with one difference in the layout: a Markdown page is handed its converted body as a $content variable, not as a section. So a Markdown layout prints <?= $content ?>, and the page names it by filename:

<!-- pages/about.md -->
---
layout: partials::doc
title: About
description: About this example.
---
# About

Written in Markdown, wrapped by the same layout.

Two layouts are fine, and so is one layout that prints both <?= $this->section('content') ?> and <?= $content ?>. What costs something is mixing them up: a Markdown page pointed at a layout that prints only the content section builds successfully and emits an empty <main>. That was verified here, and it is silent — nothing in the build output warns.

A layout may also name a partial for repeated fragments, e.g. <?= $this->insert('partials::nav') ?>; that form was not exercised here.

Build the static site

composer build

or, without the Composer script, vendor/bin/flat-file build. The output is four lines, and the last one is the only success signal:

Discovering routes...
Generating files...
Copying public files...
Finished

The default destination is dist/ in the project root. Pass a different directory as the first argument — composer build -- dist/site — which is the form this microsite's own build script uses. Then upload that directory. It is the entire site.

Two things the generator does not do for you: it does not render a page that no file matches, and it does not invent redirects. A URL with no source file is simply absent from the artifact, so a static host's own 404 handling applies unless you build a 404.html page as in step 3.

Look at it before you build

composer start runs PHP's built-in server on http://localhost:3000, with public/ as the document root — or your own public/index.php if you write one, and the package's src/router.php otherwise. The README's custom-entrypoint example is the file to copy if you need one:

<?php declare(strict_types=1);

$uri = $_SERVER['REQUEST_URI'];

if ($uri !== '/' && file_exists(getcwd() . '/public' . $uri)) {
    return false;
}

require getcwd() . '/vendor/autoload.php';
new FlatFile\Application;

Not verified here: the server was not started while writing this page. flat-file serve --dry-run prints the command it would run — php -S localhost:3000 -t <project>/public <project>/vendor/slogsdon/flat-file/src/router.php — and that is all that was checked. The same tree, served by a web server pointed at public/, is what the README calls the flat-file CMS mode.

Deploy the artifact, not the project

There is nothing project-specific to deploy. dist/ is HTML, CSS, and whatever else you put in public/, so any static host or object-storage origin will serve it; PHP is only needed in the build step. That is also the shape used here: this microsite and the collection hub both build with composer build and publish the output as static assets under a path prefix.

The starter's README documents a Now (Vercel) Docker deployment. It is historical: now.json, a composer:latest image, and ^1.0 dependencies that no longer install on current PHP. Read it as an example of the file layout, not as a deployment recipe, and don't copy it.

Not verified here: no deploy, preview, or Cloudflare command was run while writing this page.

where to go