Skip to content
Article Building a docs module (partial + store)
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

Building a docs module (partial + store)

This guide explains how to add a new self-contained feature to the docs system — the same machinery that powers Blog, Courses, Quiz, Membership and Forms. By the end you will be able to ship a module that:

  • renders an aggregate index (a searchable/paginated grid of items),
  • renders a single item page as a custom server-rendered shell,
  • persists data through a Store (flat file source-of-truth plus an optional DB mirror),
  • accepts writes through a real POST route (because the docs mount is read-only),
  • shows manager-only views to editors/admins.

Throughout, we build one concrete example — a Polls module — so every step has real code you can adapt.

Canonical references in the codebase: docsComponents/forms/forms.php (partial), SepoEngine\Core\FormSubmissionStore (store), route-membership.php (POST route). When a signature below differs from what you see there, trust the codebase — this guide follows those patterns, it does not replace them.


1. How the docs system fits together

The docs mount is served file-first and is GET-only:

Router::mountFiles('/docs')
      │  serves *.md from content/pages via FilePageStore
      │  (GET only — no writes here)
      ▼
App/views/layouts/docs.php     ← the ORCHESTRATOR
      │  sets shared vars: $withBase, $urlFor, $mountPrefix,
      │  $apiBase, $icon, $canEdit, and the injected stores
      │  from its use(...) closure
      │
      ├─ include docsComponents/blog/aggregator.php
      ├─ include docsComponents/courses/...
      ├─ include docsComponents/forms/forms.php
      ├─ include docsComponents/polls/polls.php   ← YOUR MODULE
      └─ include docsComponents/membership/membership.php  (gate — runs LAST)
      │
      ▼  each partial inspects $meta + $content, decides if the page is "its"
         page, and either APPENDS to or REPLACES $content (server-rendered HTML)

Writes take a different door:
      Client JS  ──fetch──▶  POST /api/v2/polls/vote  (route-poll.php)
                                   │  AuthMiddleware
                                   ▼
                             SepoEngine\Core\PollStore
                                   │  content/.../polls_votes/<slug>.json  (truth)
                                   └─ optional DB mirror  sepo_store_poll_votes

Three things to internalise:

  1. The partial only renders. It never writes. It reads the current page's front matter ($meta) and body ($content) and produces HTML.
  2. The mount is GET-only, so any state change (a vote, a submission, an enrolment) must be a fetch() to a separate /api/v2/... POST route.
  3. The store owns persistence. The flat JSON file under content/ is the source of truth; the DB mirror is best-effort and optional.

2. The three moving parts

To add a feature you write, at most, three files plus two wiring edits:

  • The partialApp/views/layouts/docsComponents/polls/polls.php
  • The storeSepoEngine/Core/PollStore.php
  • The POST routeroute-poll.php (or an entry in your existing routes file)

Wiring:

  • instantiate the store in the routes file and add it to both the docs.php layout use(...) and the route use(...),
  • include the partial from docs.php in the correct order.

3. The partial contract — what is in scope

When docs.php includes your partial, these variables already exist in scope. Treat them as the API surface for a partial:

VariableTypeWhat it is
$storeFilePageStoreThe page store. listFiles(), pathFor($slug), splitFrontMatter().
$routerRouter or nullRouting helper. fileUrl($slug, $mountName) builds a page URL.
$filearrayThe current page row. $file['slug'] is the page slug.
$metaarrayParsed front matter of the current page.
$contentstringThe current page's rendered HTML. Mutate this to inject your UI.
$mountNamestringThe mount identifier (used by fileUrl).
$mountPrefixstringURL prefix for the mount (e.g. the docs base path).
$apiBasestringAPI base, ends in /pages. Derive other bases by replacing that suffix.
$withBasecallableClosure that prefixes a path with the app base (sub-path aware).
$urlForcallableClosure that resolves an asset/page URL.
$canEditboolTrue when the viewer may edit docs — your "is manager" seed.
$_SESSIONarrayuser_id, user_uid, sp_flag when signed in.
injected storesobjectAnything the layout passes through use(...), e.g. $pollStore.

The four rules every partial follows

  1. Decide if the page is yours, then return early otherwise. If the page is neither your index nor one of your items, return; and leave $content untouched — other partials get their turn.
  2. Index vs item detection. An index declares aggregate_dir: <dir> in front matter and is not itself an item. An item lives under a <module>/ directory or declares <module>: true.
  3. Append for an index, replace for an item. An index page usually keeps its authored markdown and appends a grid below it ($content .= ...). A single item page is a full custom shell, so it replaces the body ($content = ...).
  4. Guard and prefix your helpers. Wrap every helper in if (!function_exists('pl_x')) { ... } and use a short unique prefix (pl_ here, sf_ in forms). Partials share a global function namespace.

4. Partial skeleton

Every partial in this family shares the same shape. Start from this and fill in the views:

<?php
use SepoEngine\Core\FilePageStore;
use SepoEngine\App\Views\Layouts\DocsComponents\Reusables\UIComponents;

/** @var \SepoEngine\Core\FilePageStore $store */
/** @var \SepoEngine\Core\Router|null   $router */

// ---- config ---------------------------------------------------------------
const POLL_LIST_PER_PAGE = 12;
const POLL_MAX_OPTIONS   = 20;

// ---- early exit -----------------------------------------------------------
if (!($store instanceof FilePageStore)) {
    return;
}

// ---- context detection ----------------------------------------------------
$currentSlug  = (string)($file['slug'] ?? '');
$aggregateDir = trim((string)($meta['polls_dir'] ?? ''));

$slugDir = str_contains($currentSlug, '/')
    ? substr($currentSlug, 0, (int)strrpos($currentSlug, '/'))
    : '';

$isPollPage = ((bool)($meta['poll'] ?? false))
    || $slugDir === 'polls'
    || str_ends_with($slugDir, '/polls')
    || str_contains($slugDir, '/polls/');

$isPollsIndex = ($aggregateDir !== '' && !$isPollPage
    && !str_starts_with($currentSlug, $aggregateDir . '/'));

if (!$isPollsIndex && !$isPollPage) {
    return; // not our page — leave $content alone
}

// ---- shared helpers (guarded + prefixed) ----------------------------------
if (!function_exists('pl_safe')) {
    function pl_safe(string $v): string
    {
        return htmlspecialchars($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }
}
// ... pl_parseOptions(), pl_renderResults(), etc.

// ---- VIEW 1: aggregate index ---------------------------------------------
if ($isPollsIndex) {
    // build a JSON of items, render a grid via window.Components,
    // then: $content .= "\n" . $gridHtml;
    return;
}

// ---- VIEW 2: single item --------------------------------------------------
try {
    // parse $meta + $content into a poll, render the shell,
    // then: $content = $shellHtml;
} catch (Throwable $e) {
    $content = '<div class="pl-error">Something went wrong.</div>';
}

The forms partial fleshes this out with tabbed shells and manager-only tabs — copy that structure when your item page is complex.


5. Building the Store — step by step

This is the part most worth getting right, because the store is reused by both the partial (to read) and the route (to write). Stores live in SepoEngine\Core, persist to flat JSON under content/, and optionally mirror to a sepo_store_* table.

5.1 Location and namespace

Create SepoEngine/Core/PollStore.php:

<?php

namespace SepoEngine\Core;

final class PollStore
{
    private string $baseDir;
    private ?\PDO  $pdo;
    private string $table;
    private bool   $schemaReady = false;

Match the convention of your other stores (FormSubmissionStore, MembershipStore): if they use StoreDbTrait / DbConnectionSelectorTrait, use those instead of hand-rolling the mirror. The version below is standalone so the whole thing is visible in one place.

5.2 Constructor

The constructor takes the base directory, an optional PDO (so the store works with the file backend even when no DB is wired), and configurable table names. It ensures the data subdirectory exists.

    public function __construct(
        string $baseDir,
        ?\PDO $pdo = null,
        string $table = 'sepo_store_poll_votes'
    ) {
        $this->baseDir = rtrim($baseDir, "/\\") . DIRECTORY_SEPARATOR . 'polls_votes';
        $this->pdo     = $pdo;
        $this->table   = $table;

        if (!is_dir($this->baseDir)) {
            @mkdir($this->baseDir, 0775, true);
        }
    }

5.3 Safe key → path (path-traversal guard)

Never build a file path from a raw slug. Sanitize to a whitelist and confirm the resolved path stays inside your base directory. This is the single most important safety step in a file-backed store.

    private function pathFor(string $slug): string
    {
        // strip a trailing .md, collapse separators, whitelist characters
        $slug = preg_replace('/\.md$/i', '', $slug);
        $slug = str_replace('\\', '/', $slug);
        $slug = preg_replace('#[^a-zA-Z0-9/_-]#', '_', $slug) ?? '';
        $slug = trim($slug, '/');

        // flatten path segments into one safe filename (no nesting, no ..)
        $flat = str_replace('/', '__', $slug);
        if ($flat === '' || str_contains($flat, '..')) {
            throw new \RuntimeException('Invalid poll reference.');
        }

        $path = $this->baseDir . DIRECTORY_SEPARATOR . $flat . '.json';

        // final belt-and-braces: resolved path must live under baseDir
        $realBase = realpath($this->baseDir) ?: $this->baseDir;
        $realDir  = realpath(dirname($path)) ?: dirname($path);
        if (!str_starts_with($realDir, $realBase)) {
            throw new \RuntimeException('Path escapes store directory.');
        }
        return $path;
    }

5.4 Read and write JSON (atomic + locked)

Reads tolerate a missing file. Writes go to a temp file and rename() into place so a crash never leaves a half-written JSON. A lock serialises concurrent votes.

    private function read(string $slug): array
    {
        $path = $this->pathFor($slug);
        if (!is_file($path)) {
            return [];
        }
        $raw = @file_get_contents($path);
        if (!is_string($raw) || $raw === '') {
            return [];
        }
        $data = json_decode($raw, true);
        return is_array($data) ? $data : [];
    }

    private function write(string $slug, array $data): void
    {
        $path = $this->pathFor($slug);
        $json = json_encode(
            $data,
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
        );
        if ($json === false) {
            throw new \RuntimeException('Could not encode poll data.');
        }
        $tmp = $path . '.tmp';
        if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
            throw new \RuntimeException('Could not write poll data.');
        }
        @rename($tmp, $path);
    }

5.5 Domain methods

Now the actual behaviour: cast a vote (one per user for the file backend), check whether a user has voted, tally results, and summarise.

    /** Record a vote. Returns the updated tally. One vote per user id. */
    public function vote(string $slug, int $optionIndex, string $userId): array
    {
        if ($optionIndex < 0) {
            throw new \RuntimeException('Invalid option.');
        }

        $data          = $this->read($slug);
        $data['votes'] = $data['votes'] ?? [];   // [ userId => optionIndex ]
        $data['votes'][$userId !== '' ? $userId : 'guest_' . bin2hex(random_bytes(4))]
                       = $optionIndex;

        $this->write($slug, $data);
        $this->mirrorVote($slug, $optionIndex, $userId); // best-effort
        return $this->tally($slug);
    }

    public function hasVoted(string $slug, string $userId): bool
    {
        if ($userId === '') {
            return false;
        }
        $data = $this->read($slug);
        return isset($data['votes'][$userId]);
    }

    /** Count votes per option index. Returns [ index => count ]. */
    public function tally(string $slug): array
    {
        $data   = $this->read($slug);
        $counts = [];
        foreach ((array)($data['votes'] ?? []) as $opt) {
            $i = (int)$opt;
            $counts[$i] = ($counts[$i] ?? 0) + 1;
        }
        ksort($counts);
        return $counts;
    }

    public function getStats(string $slug): array
    {
        $tally = $this->tally($slug);
        return [
            'total'   => array_sum($tally),
            'options' => $tally,
        ];
    }

5.6 The DB mirror — lazy, dual-driver, best-effort

The mirror is optional and must never break a write. Create the schema lazily (once), support both MySQL and SQLite by branching on the driver name, and swallow every PDOException into error_log. The file already succeeded by the time we get here, so a mirror failure is a log line, not an error.

    private function ensureSchema(): void
    {
        if ($this->pdo === null || $this->schemaReady) {
            return;
        }
        try {
            $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
            $auto   = $driver === 'mysql'
                ? 'BIGINT AUTO_INCREMENT PRIMARY KEY'
                : 'INTEGER PRIMARY KEY AUTOINCREMENT';

            $this->pdo->exec(
                "CREATE TABLE IF NOT EXISTS {$this->table} (
                    id            {$auto},
                    poll_slug     VARCHAR(255) NOT NULL,
                    option_index  INTEGER      NOT NULL,
                    user_id       VARCHAR(191) NULL,
                    voted_at      INTEGER      NOT NULL
                )"
            );
            $this->schemaReady = true;
        } catch (\PDOException $e) {
            error_log('[PollStore] ensureSchema: ' . $e->getMessage());
        }
    }

    private function mirrorVote(string $slug, int $optionIndex, string $userId): void
    {
        if ($this->pdo === null) {
            return;
        }
        $this->ensureSchema();
        try {
            $stmt = $this->pdo->prepare(
                "INSERT INTO {$this->table} (poll_slug, option_index, user_id, voted_at)
                 VALUES (:s, :o, :u, :t)"
            );
            $stmt->execute([
                ':s' => $slug,
                ':o' => $optionIndex,
                ':u' => $userId !== '' ? $userId : null,
                ':t' => time(),
            ]);
        } catch (\PDOException $e) {
            error_log('[PollStore] mirrorVote: ' . $e->getMessage());
        }
    }
}

That is a complete, production-shaped store: file is truth, DB is a best-effort audit mirror, all paths are guarded, all writes are atomic.

Reuse the shared traits where you have them. If FormSubmissionStore gets its DB plumbing from StoreDbTrait + DbConnectionSelectorTrait, prefer that so driver detection, connection selection and the sepo_store_* naming stay consistent. The standalone mirror above shows what those traits do under the hood.


6. Wire the store in

Two edits, both in the routes file that mounts /docs.

6.1 Instantiate it (pass the container PDO)

$pollStore = new \SepoEngine\Core\PollStore(
    PathHelper::basePath('content/lms'),  // or your content root
    $pdo                                   // the container 'PDO' binding
);

6.2 Add it to the docs layout closure use(...)

docs.php is require()d by the mount's layout closure. Add your store to that closure's use(...) so the partial can see it:

$layout = function (...) use (
    $enrollStore, $progressStore, $courseReviewStore,
    $formSubmissionStore,
    $pollStore                     // ← add here
) {
    require __DIR__ . '/App/views/layouts/docs.php';
};

Inside the partial you then reference $pollStore and check its type before use, exactly like the forms partial checks $formSubmissionStore.


7. Add the POST route

Because the docs mount is GET-only, votes go to a real route. Follow route-membership.php: group under /api/v2, protect with AuthMiddleware, read the body with getBodyParam, reply with $response->json.

// route-poll.php
$router->group('/api/v2', function ($r) use ($pollStore) {

    $r->post('/polls/vote', function ($request, $response) use ($pollStore) {

        $slug   = (string)$request->getBodyParam('poll');
        $option = (int)$request->getBodyParam('option');
        $userId = (string)($_SESSION['user_id'] ?? '');

        if ($slug === '') {
            return $response->json(
                ['status' => 'error', 'message' => 'Missing poll reference.'],
                400
            );
        }

        // one vote per signed-in user
        if ($userId !== '' && $pollStore->hasVoted($slug, $userId)) {
            return $response->json([
                'status' => 'ok',
                'data'   => ['already' => true, 'stats' => $pollStore->getStats($slug)],
            ]);
        }

        try {
            $pollStore->vote($slug, $option, $userId);
            return $response->json([
                'status' => 'ok',
                'data'   => ['stats' => $pollStore->getStats($slug)],
            ]);
        } catch (\Throwable $e) {
            error_log('[route-poll] ' . $e->getMessage());
            return $response->json(
                ['status' => 'error', 'message' => 'Could not record vote.'],
                500
            );
        }

    })->middleware(AuthMiddleware::class);

});

Register route-poll.php the same way your other route-*.php files are loaded, and make sure $pollStore is in scope where you register it.

CSRF: the client must send the token (see §9). On the server, honour whatever CSRF check the rest of your /api/v2 routes use — do not special-case this route.


8. Include the partial in docs.php

Add one include in the orchestrator. Order matters:

  • Content-producing partials (blog, courses, forms, polls) run before any gate partial.
  • The membership gate (require_membership) must run after all content partials, because it replaces $content with an upsell when access is denied — it can only gate content that already exists.
// docs.php — content partials
include __DIR__ . '/docsComponents/blog/aggregator.php';
include __DIR__ . '/docsComponents/courses/courses.php';
include __DIR__ . '/docsComponents/forms/forms.php';
include __DIR__ . '/docsComponents/polls/polls.php';     // ← your module

// ... then, LAST:
include __DIR__ . '/docsComponents/membership/membership.php';  // gate

Each partial early-returns when the page is not its own, so ordering only matters between content and gates, not among content partials.


9. Front matter contract (the .md authoring format)

Two page types. Author them as normal Markdown files under content/pages/.

9.1 The index page

---
title: Community Polls
polls_dir: polls # declares this an aggregate index for the "polls" dir
---
Vote on what we build next.

The partial keeps the authored body and appends the grid below it.

9.2 A single poll page

Lives at content/pages/polls/<name>.md (under the polls/ directory) or any page with poll: true:

---
title: Which feature next?
poll: true
requireLogin: true
options: Dark mode | Offline sync | Public API | Mobile app
closed: false
---
Pick one — results show after you vote.

Because options is a single front-matter value, use a pipe-separated list and explode('|', ...) in the partial (as forms does for questionOptionsN).

Docs authoring gotcha — pipes in tables. The Markdown renderer treats | as a table-cell delimiter and breaks on a literal pipe inside a cell, even in inline code. When you document a pipe-separated value, put it in a fenced code block or a description list, never inside a Markdown table cell.

Supported option/field types, when your module has typed inputs, are the same set the quiz and forms modules use: text, longtext, multiplechoice, multipleselect, numeric, date, email, phone, url. Normalise unknown types to text with a small pl_normalizeType() helper (copy sfNormalizeType).


10. Client-side conventions

Everything the client needs is on window.SepoDocsConfig, server-rendered in the docs shell. Fields you will use: api (ends in /pages), csrf, csrfHeader, mount, slug, canEdit, assetVersion.

10.1 Derive the API base

cfg.api points at the pages endpoint. Strip the /pages suffix to reach the /api/v2 root:

function pollEndpoint() {
  var cfg = window.SepoDocsConfig || {};
  var base = (cfg.api || "").replace(/\/pages\/?$/, "");
  return base + "/polls/vote";
}

10.2 Post with CSRF

function castVote(slug, option) {
  var cfg = window.SepoDocsConfig || {};
  var headers = {
    "Content-Type": "application/json",
    Accept: "application/json",
    "X-Requested-With": "XMLHttpRequest",
  };
  if (cfg.csrf) headers[cfg.csrfHeader || "X-CSRF-Token"] = cfg.csrf;

  return fetch(pollEndpoint(), {
    method: "POST",
    credentials: "same-origin",
    headers: headers,
    body: JSON.stringify({
      poll: slug,
      option: option,
      _token: cfg.csrf, // some stacks read the body token
      csrf_token: cfg.csrf,
    }),
  }).then(function (r) {
    return r.json();
  });
}

10.3 Render the aggregate index with window.Components

The reusables layer (UIComponents + window.Components) gives you a grid, search, pagination and a grid/list toggle for free — the same components the blog and forms indexes use. Server-render a JSON blob of items, then hydrate:

var renderer = new window.Components.GridRenderer({
  container: document.getElementById("polls-wrapper"),
  template: function (p) {
    return (
      '<article class="poll-card">' +
      '<h3><a href="' +
      esc(p.url) +
      '">' +
      esc(p.title) +
      "</a></h3>" +
      (p.desc ? "<p>" + esc(p.desc) + "</p>" : "") +
      "</article>"
    );
  },
  emptyMessage: "No matching polls.",
});

var pagination = new window.Components.Pagination({
  container: document.getElementById("polls-pagination"),
  itemsPerPage: 12,
  onPageChange: function (items) {
    renderer.render(items);
  },
});

new window.Components.Search({
  input: document.getElementById("polls-search-input"),
  items: polls,
  searchFields: ["title", "desc"],
  onSearch: function (results) {
    pagination.setFilteredItems(results);
  },
});

pagination.setTotalItems(polls);

Guard the whole init behind a check for window.Components and retry with a short setTimeout, exactly as the forms index does — the reusables bundle may load slightly after your inline script.


11. House rules and pitfalls

  • The docs mount is GET-only. Never self-POST a docs page — you will get a 405. Always fetch() a /api/v2 route.
  • No external dependencies. Charts, tables, everything ship in-house. If a tool is not there, build the usable subset (pure SVG/CSS/JS on the client, plain PHP on the server).
  • Guard and prefix every helper with function_exists and a unique prefix. Partials share one global function namespace; a clash is a fatal redeclare.
  • Escape all output with htmlspecialchars(..., ENT_QUOTES | ENT_HTML5), and sanitize slugs before they touch the filesystem (see §5.3).
  • Manager gating seeds from $canEdit; if you need finer control, read $_SESSION['sp_flag'] and match your ~SYS_USER / MODERATOR_USER / EDITOR_USER tokens, the same way the resource permissions matcher does.
  • DDL is idempotent (CREATE TABLE IF NOT EXISTS) and the mirror is best-effort — log PDOException, never throw. The file is the source of truth.
  • Return early untouched when the page is not yours, so other partials still render.
  • Keep pipes out of Markdown table cells in any docs you author (renderer limitation).

12. Ship checklist

  • [ ] Partial early-returns on unrelated pages (index and item both detected).
  • [ ] Index appends a grid; item replaces $content with its shell.
  • [ ] Helpers are function_exists-guarded and uniquely prefixed.
  • [ ] Store sanitizes slugs, writes atomically, and never lets the mirror throw.
  • [ ] Store instantiated with the container PDO and added to both use(...) lists (docs layout + route).
  • [ ] Partial included in docs.php before the membership gate.
  • [ ] POST route under /api/v2, AuthMiddleware, CSRF honoured, $response->json replies.
  • [ ] Client derives the API base by stripping /pages and sends the CSRF token.
  • [ ] Tested signed-out, signed-in, and as a manager; tested with the DB disconnected (file path still works).


<invoke name="present_files">
<parameter name="filepaths">["/mnt/user-data/outputs/adding-docs-modules.md"]