Three loose ends remain. The contact form from chapter 3 doesn't do anything yet. Scriptor's built-in 404 looks like a 2003 hosting-error page. And the theme isn't packaged in a way someone else could install. This chapter closes all three.

By the end of it, Atelier is something you could tag v1.0.0, push to a Git remote, and hand to a friend with one composer require line.

The actions pattern

Scriptor doesn't have a global form router. Each theme handles its own POST actions, by convention with a hidden action=<name> field and an <name>Action() method on the theme's Site subclass.

Open lib/AtelierTheme.php from chapter 4 and add:

/**
 * Whitelist of actions this theme accepts via POST. Anything not
 * listed here is silently ignored even if posted; the whitelist
 * is the security boundary.
 *
 * @var list<string>
 */
private const ALLOWED_ACTIONS = ['contact'];

public function actions(): void
{
    $action = $this->input->postString('action');
    if ($action === '' || ! in_array($action, self::ALLOWED_ACTIONS, true)) {
        return;
    }
    $method = $action . 'Action';
    if (method_exists($this, $method)) {
        $this->{$method}();
    }
}

private function contactAction(): void
{
    $name     = $this->sanitizer->text($this->input->postString('name'));
    $emailRaw = $this->input->postString('email');
    $email    = $this->sanitizer->email($emailRaw);
    $message  = $this->sanitizer->multiline($this->input->postString('message'));

    if ($name === '' || $emailRaw === '' || $message === '') {
        $this->addMsg('error', 'Please fill in name, email, and message.');
        return;
    }
    if ($email === null) {
        $this->addMsg('error', 'That email address looks off.');
        return;
    }

    // Replace this with your real delivery mechanism: PHPMailer,
    // Symfony Mailer, an HTTP API, a queue job. Atelier just logs
    // through Scriptor's PSR-3 surface (see below).
    $this->logger->info('Contact form: {name} <{email}> — {message}', [
        'name'    => $name,
        'email'   => $email,
        'message' => $message,
    ]);

    // PRG: queue the success in the session bag, then 303 to a
    // fresh GET. The browser switches method, and reloading the
    // landing page does not resubmit the form.
    $this->flashMsg('success', 'Thanks, I will get back to you within a few days.');
    $this->redirect($this->siteUrl . '/contact/', 303);
}

Three Sanitizer methods do the work for the three field types:

  • $this->sanitizer->text($raw) strips control characters (NUL, etc.), collapses whitespace, trims, and caps the result at 255 Unicode characters. Right shape for a single-line name.
  • $this->sanitizer->multiline($raw) is the same idea but preserves \r, \n, \t and normalises CRLF to LF. Caps at 65 535 characters. Right shape for the message textarea — and en passant fixes log-injection, since the stripped control chars include the line breaks an attacker would use to forge fake log lines.
  • $this->sanitizer->email($raw) wraps filter_var(FILTER_VALIDATE_EMAIL) and returns null when the value is not a valid email format. The "empty vs invalid" split in the code above is what makes the two error messages distinguishable. Note that this is the same validator the editor's own profile form uses, so the rules are consistent across the platform.

Why Sanitizer instead of trim + filter_var directly: the editor's own forms use this exact machinery (see Profile, Pages, Files), and consistency matters. The control-character strip is also a small but real defence the raw helpers don't offer.

Logging through $site->logger

$site->logger is a PSR-3 LoggerInterface instance pulled from the container — the default binding is Scriptor's small FileLogger which appends one line per record to data/logs/scriptor.log:

[2026-05-22T10:45:31+00:00] INFO: Contact form: Jane <jane@example.com> — Hi there

Two things worth absorbing:

  • PSR-3 placeholders, not sprintf. Pass an array context and reference its keys as {name} in the message — same pattern Monolog and Symfony use. The logger interpolates scalars, Stringable objects, and Throwable::getMessage(); values it can't render leave the token in place so you notice the gap.
  • Single binding, drop-in replacement. The default is a ~110-line file appender — fine for a portfolio, intentionally not fine for a high-volume API. Production sites usually swap the container binding in their own boot.php-equivalent for Monolog (stream + syslog + Slack handler in one go) or pipe through an existing logging service. The call sites ($site->logger->info(...)) don't change.

Three rules of the road encoded here:

  1. Action names are whitelisted in code, not config. A POST with action=admin-delete-everything does nothing because admin-delete-everything isn't in ALLOWED_ACTIONS. The bundled Basic theme reads its whitelist from the theme config; either approach works, but code-as-source-of-truth is slightly safer because nothing in the config can grow the surface.
  2. Validation lives in the action method, not the template. The template prints fields and an <input type="hidden">. The server method runs the Sanitizer methods above and calls addMsg() for user-visible feedback.
  3. Success redirects via PRG; errors render in place. A naïve handler that calls addMsg('success', ...) and lets the page rerender on the same request walks straight into the reload-resubmit trap: the browser repeats the POST on every reload of the result page and the success message duplicates (or worse, the form delivers twice). The success path here uses flashMsg() + redirect(..., 303) — the message lives in the session bag, the browser follows the 303 to a fresh GET on /contact/, render('messages') drains the bag once, and a subsequent reload does nothing surprising. Errors stay with addMsg() because the user still has values in the form and needs to see what failed; redirecting on an error would wipe the inputs and force the user to retype everything.

Why 303, not 302. 302 Found predates the GET/POST split, and some old user agents preserved the request method on the redirect (POST → POST), which defeats the point. 303 See Other was added in HTTP/1.1 precisely to force the browser onto GET. Site::redirect() defaults to 302 for the generic "go elsewhere" case; POST-after-success always wants the explicit 303 as the second argument.

Gotcha: action runs before render. The actions handler has to fire before the page renders, otherwise messages set during the action arrive too late to print. The _ext.php from chapter 4 calls $site->execute() directly; you need to call $site->actions() before it.

Update _ext.php:

<?php

declare(strict_types=1);

use Scriptor\Boot\App;
use Themes\Atelier\AtelierTheme;

require_once __DIR__ . '/vendor/autoload.php';

/** @var array<string, mixed> $config */
$site = new AtelierTheme(App::container(), $config, dirname(__DIR__, 2));
$site->actions();
$site->execute();

Now POST the contact form. Empty fields produce a red error in place; invalid email produces a different error in place; a valid submission redirects to /contact/ with the green success message in the flash slot. Reload after the success: nothing resubmits, the message is gone. The "delivery" itself lands in data/logs/scriptor.log via the FileLogger discussed above — wire a real mailer when you're ready.

A custom 404.php

When Site::throw404() fires (any URL the page tree and the markdown plugin both refused) it looks for themes/<theme>/404.php and includes it with $site in scope. The page tree resolver sets $site->page = null in this case, so the template must not assume $site->page exists.

Create themes/atelier/404.php:

<?php include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--404">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <article class="error">
            <h1>404, not here</h1>
            <p>The page you asked for doesn't exist. It may have been moved or it may never have.</p>
            <p>
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/">Back to home</a> ·
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/blog/">Read the blog</a>
            </p>
        </article>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

head.php and header.php both reference $site. That works fine because Site is still around, just $site->page is null. Inside head.php the <title> uses $site->page->name; guard against null:

<title>
    <?= htmlspecialchars((string) ($site->page->name ?? 'Page not found')) ?>,
    <?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?>
</title>

Visit http://localhost:<port>/this-doesnt-exist/. You should get the styled 404 with full chrome, and the response status should be 404 Not Found (check the network panel).

Gotcha: the 404 template and the 404 status are independent. throw404() sets the HTTP status header before including the template. If you include 404.php from somewhere else (a stale link page, a hand-crafted soft-404), the response is 200 unless you also send the header yourself. Treat throw404() as the only well-formed path.

Packaging Atelier as a Composer package

Atelier currently lives inside themes/atelier/ of one Scriptor install. To make it installable elsewhere, you need three things:

  1. A proper name, description, license, and require block in composer.json,
  2. a Git repository tagged with a version,
  3. instructions for a consumer to add the VCS repository and require the package.

Flesh out composer.json

Replace the chapter-2 stub with:

{
    "name": "bigin/atelier-theme",
    "description": "Atelier, a minimal portfolio + blog theme for Scriptor.",
    "type": "library",
    "license": "MIT",
    "require": {
        "php": "^8.2",
        "bigins/scriptor-markdown-pages": "^0.1"
    },
    "autoload": {
        "psr-4": {
            "Themes\\Atelier\\": "lib/"
        }
    },
    "config": {
        "platform": {
            "php": "8.2.0"
        }
    }
}

Three changes worth pointing at:

  • type: library, not scriptor-theme. Scriptor doesn't scan Composer types for themes. composer require'ing a theme drops it into vendor/ like any library; the consumer is responsible for getting it into themes/. The bundled themes drop the type field entirely; library is the conservative default if you publish.
  • Declare the markdown-pages dependency. Atelier's blog can't function without the plugin. A require block makes Composer resolve and install the plugin alongside the theme.
  • PSR-4 over classmap. Once you publish, PSR-4 is friendlier to consumers; composer dump-autoload runs in the consumer's Scriptor root, not in the theme folder, so the autoloader works whether the theme lives in vendor/ or themes/.

Tag a release

From the theme folder, with vendor/ listed in .gitignore:

git init
git add .
git commit -m "feat: initial atelier theme"
git remote add origin git@github.com:you/atelier-theme.git
git push -u origin main
git tag v1.0.0
git push origin v1.0.0

That tag is what Composer's VCS driver pins against.

How a consumer installs it

From a fresh Scriptor install:

# 1. Add the VCS repository to Scriptor's root composer.json.
composer config repositories.atelier vcs git@github.com:you/atelier-theme.git

# 2. Require the theme.
composer require bigin/atelier-theme:^1.0

# 3. Surface it under themes/. Symlink for dev, copy for prod.
ln -s ../vendor/bigin/atelier-theme themes/atelier

# 4. Activate it.
# Edit data/settings/scriptor-config.php:
#   'theme_path' => 'atelier/',

The vendor/ → themes/ indirection is the friction point. If you want a one-step install, write a Composer installer plugin (a package of type: composer-plugin) that copies/symlinks to themes/ automatically. That is a separate project; for a single theme, the manual symlink is shorter than the installer code.

Gotcha: vendor/atelier-theme is the package name, not the theme directory. Scriptor reads theme_path and joins it with themes/. If you skip step 3, the request hits themes/atelier/template.php which doesn't exist, and you get a confusing "include failed" error. Either symlink, or change theme_path to point at the vendor path (not recommended, since it couples the install path to Composer's layout).

What you've built

A real, distributable Atelier theme. From the consumer's view it behaves like any composer-installed library: one require, one symlink, one config change.

A short tour of the surface it ships:

  • template.php: the dispatcher. Never edited after chapter 2.
  • home.php / basic.php / projects.php / contact.php: one template per page type.
  • markdown-section.php / blog-index.php: the markdown plugin's virtual pages, plus the listing override.
  • 404.php: graceful misses.
  • resources/partials/*: shared chrome.
  • resources/css/styles.css: the look.
  • lib/AtelierTheme.php: the Site subclass that does the FrontendNav merge, the template override, the actions handler.
  • _ext.php: bootstrap.
  • content/blog/*.md: the blog posts, version-controlled.
  • composer.json: the manifest.

Roughly ten PHP files plus assets and markdown. That's the whole shape of a Scriptor theme: small enough to keep entirely in your head, expressive enough that the same surface powers Scriptor's own documentation site.

What's next

You've finished the Build-a-Theme track. From here:

  • Build a Module: the parallel track for plugins. Same mini-case style, different surface.
  • API Reference: the class-by-class reference for everything you wired through here.
  • Cookbook: recipes for the things this track skipped: multi-language, sitemap, RSS, image responsive variants.

If you publish your version of Atelier, drop a link in the project's Extensions track. The catalog grows by readers like you finishing this tutorial.


Behind the scenes. The Site::throw404() path lives at boot/Frontend/Site.php around line 297. The BasicTheme::actions() pattern Atelier follows is at themes/basic/lib/Basic.php around line 161. The VCS-repository Composer recipe is documented in the Cookbook chapter on dependency setup.