In chapter 2 you wrote a template.php that picks a per-page-template file from disk based on $site->currentTemplate(). That dispatcher is finished; it does not change for the rest of the tutorial. This chapter writes the files it dispatches to.

Atelier needs four:

  • home.php, the showcase landing,
  • basic.php, the fallback you stubbed in chapter 2 (now made real),
  • projects.php, a gallery of child pages,
  • contact.php, text plus a form (form logic lands in chapter 6).

All four follow the same shape: print HTML, pull dynamic bits off $site. No SQL, no if ($_GET[...]), no fetch loops. That all lives in the Site base class or a subclass you'll add in chapter 4.

What $site and $site->page offer

Every page template runs with three things in scope:

  • $site: your live Site instance. The default surface includes siteUrl, themeUrl, themeAssetUrl(), currentTemplate(), render(), messages, addMsg() and (via __get) pages for the PageRepository.
  • $site->page: the Page value object for the URL the router resolved. Public fields: name, slug, template, pagetype, menu_title, content, parent. Plus id(), active(), created(), updated() accessors, and __get for anything custom you put on the page.
  • $config: the scriptor-config.php array, mainly useful for site_name and language.

Templates touch $site->render('content') rather than $site->page->content directly. The renderer applies cleaning, HTML escaping decisions, and Markdown plugins. Your template just emits the result. Concepts → Frontend Events covers what runs inside render().

basic.php: make the stub real

Replace the chapter-2 stub with a usable single-column layout:

<!doctype html>
<html lang="<?= htmlspecialchars((string) ($config['lang'] ?? 'en')) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= htmlspecialchars((string) $site->page->name) ?> — <?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?></title>
</head>
<body class="page page--basic">
    <main>
        <article>
            <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
            <?= $site->render('messages') ?>
            <?= $site->render('content') ?>
        </article>
    </main>
</body>
</html>

Three things to notice:

  1. <title> uses $site->page->name, not $site->page->menu_title. The menu title is a short label for navigation; the page title wants the long version.
  2. $site->render('messages') goes above content. That is where Scriptor flushes one-shot user-feedback ("Message sent", "Could not save") that an action set via $site->addMsg(). Even templates that never set messages should render the slot so a parent template that does, still works.
  3. No PHP class involved. The default Site is enough for a minimal Atelier; you only subclass it in chapter 4.

home.php: the showcase

The home page wants a distinct layout, not just "basic with a bigger heading". Replace it with a hero block:

<!doctype html>
<html lang="<?= htmlspecialchars((string) ($config['lang'] ?? 'en')) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?></title>
</head>
<body class="page page--home">
    <main>
        <header class="hero">
            <h1>Atelier</h1>
            <p>Independent design + build. Selected work below.</p>
            <p>
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/projects/">See projects</a> ·
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/contact/">Get in touch</a>
            </p>
        </header>

        <section class="home-intro">
            <?= $site->render('content') ?>
        </section>
    </main>
</body>
</html>

The <h1> is the brand, not the page name; a deliberate home-page choice. Body content from the editor still renders, in its own <section>.

projects.php: list the children

Atelier's Projects page is a hub: it has no body content of its own, just a grid of links to its child pages. Themes legitimately need to walk the page tree for this, so it is the one template where $site->pages shows up:

<?php $children = $site->pages->findActiveByParent((int) $site->page->id()); ?>
<!doctype html>
<html lang="<?= htmlspecialchars((string) ($config['lang'] ?? 'en')) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= htmlspecialchars((string) $site->page->name) ?> — <?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?></title>
</head>
<body class="page page--projects">
    <main>
        <header>
            <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
            <?= $site->render('content') ?>
        </header>

        <?php if ($children === []): ?>
            <p>No projects published yet.</p>
        <?php else: ?>
            <ul class="project-grid">
                <?php foreach ($children as $child): ?>
                    <li>
                        <a href="<?= htmlspecialchars($site->siteUrl . '/' . $site->page->slug . '/' . $child->slug . '/') ?>">
                            <?= htmlspecialchars($child->name) ?>
                        </a>
                    </li>
                <?php endforeach ?>
            </ul>
        <?php endif ?>
    </main>
</body>
</html>

Two notes:

  • findActiveByParent() returns active children only. That matches the editor's "inactive" flag, so drafts and hidden pages drop out automatically. There is also a findByParent() that includes inactive rows; use it sparingly, and only in admin-side or preview templates.
  • The URL is hand-built from slugs. Scriptor's default front controller resolves /projects/project-a/ by walking from a parent's slug down; here we mirror that. Chapter 4 introduces a tidier helper.

Set the Projects page's template field to projects in the editor and reload /projects/. You should see the children listed, or "No projects published yet" if you haven't added any.

Gotcha: template names are user input. The template field on a page is a free-text input in the editor; nothing prevents an editor from typing Projects (capital P) or project (no s). Sanitizer::templateName() normalises whitespace and blocks traversal, but does not auto-fix casing. Stick to a small fixed set; if you find yourself with Projects vs projects collisions, document the contract.

contact.php: text and a form

Contact is basic.php plus a <form>. The form submits nothing yet; chapter 6 adds the action handler. We just want the markup visible so chapter 4's layout work has something to style:

<!doctype html>
<html lang="<?= htmlspecialchars((string) ($config['lang'] ?? 'en')) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= htmlspecialchars((string) $site->page->name) ?> — <?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?></title>
</head>
<body class="page page--contact">
    <main>
        <article>
            <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
            <?= $site->render('messages') ?>
            <?= $site->render('content') ?>

            <form class="contact-form" method="post" action="">
                <input type="hidden" name="action" value="contact">
                <p>
                    <label for="contact-name">Name</label>
                    <input id="contact-name" name="name" type="text" required>
                </p>
                <p>
                    <label for="contact-email">Email</label>
                    <input id="contact-email" name="email" type="email" required>
                </p>
                <p>
                    <label for="contact-message">Message</label>
                    <textarea id="contact-message" name="message" rows="6" required></textarea>
                </p>
                <p><button type="submit">Send</button></p>
            </form>
        </article>
    </main>
</body>
</html>

The hidden action=contact field is the convention Scriptor's sample themes use to route form submissions to a server-side handler. Chapter 6 wires the handler. For now the form just posts back to itself and reloads.

A quick smoke test

In the editor, set:

  • Hometemplate = home,
  • Abouttemplate = basic,
  • Projectstemplate = projects,
  • Project A / B / Ctemplate = basic,
  • Contacttemplate = contact.

Then visit each URL. Each should land on a different layout. No styling yet, but the four pages should differ in structure (hero on home, listing on projects, form on contact). The next chapter makes them look like a single site.

To-do before chapter 4

  • home.php, basic.php, projects.php, contact.php all exist under themes/atelier/.
  • Editor's template fields point at the new templates.
  • /, /about/, /projects/, /projects/project-a/, /contact/ all 200 without warnings.

When that's clean, open chapter 4 (partials, assets, and the top navigation).


Behind the scenes. Page's public fields live at boot/Frontend/Page.php. The PageRepository::findActiveByParent() query is around line 117 of boot/Frontend/PageRepository.php. The Site::render() event surface is at line 160 of boot/Frontend/Site.php.