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-linename.$this->sanitizer->multiline($raw)is the same idea but preserves\r,\n,\tand normalises CRLF to LF. Caps at 65 535 characters. Right shape for themessagetextarea — 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)wrapsfilter_var(FILTER_VALIDATE_EMAIL)and returnsnullwhen 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,Stringableobjects, andThrowable::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:
- Action names are whitelisted in code, not config. A POST
with
action=admin-delete-everythingdoes nothing becauseadmin-delete-everythingisn't inALLOWED_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. - 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 callsaddMsg()for user-visible feedback. - 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 usesflashMsg()+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 withaddMsg()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 Foundpredates the GET/POST split, and some old user agents preserved the request method on the redirect (POST → POST), which defeats the point.303 See Otherwas 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 explicit303as 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.phpfrom 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 include404.phpfrom somewhere else (a stale link page, a hand-crafted soft-404), the response is 200 unless you also send the header yourself. Treatthrow404()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:
- A proper
name,description,license, andrequireblock incomposer.json, - a Git repository tagged with a version,
- instructions for a consumer to add the VCS repository and
requirethe 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, notscriptor-theme. Scriptor doesn't scan Composer types for themes.composer require'ing a theme drops it intovendor/like any library; the consumer is responsible for getting it intothemes/. The bundled themes drop the type field entirely;libraryis the conservative default if you publish.- Declare the markdown-pages dependency. Atelier's blog can't
function without the plugin. A
requireblock 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-autoloadruns in the consumer's Scriptor root, not in the theme folder, so the autoloader works whether the theme lives invendor/orthemes/.
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-themeis the package name, not the theme directory. Scriptor readstheme_pathand joins it withthemes/. If you skip step 3, the request hitsthemes/atelier/template.phpwhich doesn't exist, and you get a confusing "include failed" error. Either symlink, or changetheme_pathto 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: theSitesubclass 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.