Skip to content

About

A flat file, Markdown CMS in PHP with blocks structure.

Topics

Resources

Stars

51 stars

Watchers

7 watching

Forks

Latest commit

 

History

665 Commits

Folders and files

Repository files navigation

Reboot CMS

The Agent-Friendly CMS.

A flat file CMS in PHP, inspired by Pico, Redaxo, Kirby and Craft CMS.

Reboot CMS is a minimal CMS without a database. A page is a list of blocks, and every block is a small form whose fields a YAML blueprint names. Editors fill in fields, the templates own the layout. Records like seminars or blog posts live apart from the pages as data. Pages written in Markdown still work, for long texts and those who like it.

Edit a page

Why another CMS?

I developed Reboot CMS because I couldn't find a CMS that works with flat files but allows easy use of blocks. Since version 4 a block is a small form, so editors fill in fields and the design stays as the designer made it.

Reboot CMS is very small and the pages are delivered extremely fast. My website shaack.com, built with Reboot CMS, has a PageSpeed Insights performance score of 100.

Working with AI agents

Reboot CMS works well with AI coding assistants, because its architecture maps onto what an agent can reliably reason about:

  • Flat files, no database. Pages are short YAML files or Markdown, data entries are YAML files as well. Content is edited directly as text, there are no migrations and no admin API to learn.
  • File equals URL. The page for /about is always site/pages/about.yml (or about.md), so an agent locates content without crawling routes.
  • Small, transparent core. The CMS classes (Reboot, Site, Page, Block, Collection, Request) are short enough to fit a model's context window.
  • Blocks are plain PHP and YAML. A block type is a folder in site/blocks/ with a PHP template and a blueprint that names its fields, with no framework or build step in between.
  • Everything is text under Git. Every change is a reviewable diff.

The repository includes an AGENTS.md that orients AI agents working on a Reboot CMS project.

Websites using Reboot CMS

Minimum System Requirements

  • PHP 8.1 or higher
  • Web Server: Apache with mod_rewrite (or compatible server)
  • No database required — all content is stored as flat files

Install

Clone or download the Reboot CMS repository:

git clone https://github.com/shaack/reboot-cms.git my-site

Quick Start

Run the CMS in a Podman container with Apache and PHP, the same setup as a typical hosting:

./run.sh

The site is available at http://localhost:21007, the admin at http://localhost:21007/admin. The script also starts Mailpit, a mail catcher, at http://localhost:25007, so every mail the CMS sends, for example a login link, shows up there instead of leaving your machine. On the first start the script writes local/config.local.yml, which points the CMS at Mailpit and raises the log level. That file is for your machine only and ignored by Git, see Configuration. Other ports: ./run.sh 3000 3025 3080 for HTTP, SMTP and the Mailpit UI.

Docker / Podman

cd compose && podman compose up -d

The site is available at http://localhost:8080.

Production Setup

Point your web server's document root to the web/ directory. The CMS should work out of the box.

On first visit to /admin, you will be prompted to create an admin account.

Directory Structure

  • core/src/ — Core CMS classes (Reboot, Site, Page, Block, Collection, Request, AddOn)
  • core/addons/ — AddOns shipped with Reboot CMS (CodeHighlighting, EmailObfuscation, LanguageRedirect, ProtectedAreas, SecureDownloads)
  • site/ — Site content: pages/, blocks/, data/, blueprints/, addons/, template.php, config.yml
  • web/ — Document root (index.php, .htaccess)
  • local/ — Local environment config (config.yml, .htpasswd) — not in git
  • core/admin/ — Admin interface (itself a Reboot CMS site)
  • web/admin/vendor/ — The browser libraries of the admin (Bootstrap, the Markdown editor), copied from node_modules/ by web/admin/build-vendor.sh. Only this folder is committed and shipped, npm install in web/admin/ is needed just to update those libraries.

Configuration

  • site/config.yml — Site-wide settings: addon registration, navbar with brand and structure, and multilingual settings (see Multilingual sites)
  • local/config.yml — Settings of the installation: log level, editor, mail. The file ships with the CMS, so an installation runs out of the box, and is edited on the server.
  • local/config.local.yml — Optional, settings for one machine only, laid over config.yml. Sections are merged key by key, so a mail section here replaces only the keys it names, lists are replaced as a whole. The file is ignored by Git, run.sh writes one for development, and the health page shows which keys it overrides.

The shipped local/config.yml:

# Logging: 0 = debug, 1 = info, 2 = error
logLevel: 2

# Print PHP notices, warnings and fatals into the page.
# Omit it and the log level decides: on in debug mode (0), off otherwise.
# Keep it off in production, a diagnostic message in the page body discloses the server path.
# displayErrors: false

# Admin editor settings
editor:
  font: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
  fontSize: '0.95rem'
  lineHeight: '1.4'
  tabSize: 4
  wordWrap: true
  tools:
    - Headings
    - "|"
    - Bold
    - Italic
    - Strikethrough
    - "|"
    - UnorderedList
    - OrderedList
    - "|"
    - InsertLink
    - InsertImage

# Page history: number of snapshots to keep per page
history:
  maxVersions: 50

# Mail (optional): lets AddOns send mail, see "Sending mail" below
mail:
  transport: smtp           # smtp, mail (PHP mail()) or log (nothing is sent)
  host: mail.example.com
  port: 465
  encryption: ssl           # ssl (465), tls (STARTTLS, 587) or none
  username: noreply@example.com
  password: "secret"
  from: noreply@example.com
  fromName: "Example Inc."

Sending mail

Reboot CMS sends no mail by itself, but AddOns do, for example login links or form submissions. The mail section of local/config.yml configures how. The recommended transport is smtp with a mailbox of your hosting, because mail from an authenticated mailbox passes SPF and DKIM checks and lands in the inbox. mail uses PHP's mail() without a mailbox, log writes the mail to the log instead of sending it, which is handy during development. The credentials belong to the server, not to the site, which is why they live in local/ and not in site/config.yml.

The admin's System Health page shows the mail configuration and sends a test mail on request. Sending is done by PHPMailer.

Available editor tools: Headings, Bold, Italic, Strikethrough, UnorderedList, OrderedList, InsertLink, InsertImage. Use "|" for a separator. The admin tools (InsertPageLink, InsertMedia, InsertBlock) are always appended automatically.

Template

The file site/template.php is the main HTML template. It receives three variables:

  • $site — the Site object
  • $page — the Page object
  • $request — the Request object

Call $page->render($request) to render the page content. Afterwards $page->meta('title', 'My Site') returns a value of the YAML front matter, escaped for HTML, with an optional default. Pass false as the third parameter to get a string as written, for JSON, a URL or HTML you put into the front matter on purpose. Values that are no strings, like hide-nav: true, come back unchanged. $page->getConfig() returns the whole front matter as an array, unescaped.

<?php $content = $page->render($request); ?>
<title><?= $page->meta('title', 'My Site') ?></title>
<meta name="description" content="<?= $page->meta('description', '') ?>">

Documentation

Page

Folder: /site/pages

A Page is a structured page, a YAML file of blocks with fields (see Structured pages), a Markdown file that can contain Blocks, or a PHP file. PHP pages receive the same variables as the template: $site, $page, and $request.

Pages are auto-routed on web-requests, .md before .yml before .php:

  • index.yml (or .md, .php) will be shown on requesting /
  • NAME.yml (or .md, .php) will be shown on requesting /NAME
  • FOLDER/index.yml (or .md, .php) will be shown on requesting /FOLDER
  • FOLDER/NAME.yml (or .md, .php) will be shown on requesting /FOLDER/NAME
  • 404.yml (or .md, .php) will be used as a custom 404 error page
  • the entries of a collection whose blueprint has url, see Entries as pages

Example for a Markdown Page with Blocks:

---
title: Reboot CMS
description: Reboot CMS is a flat file CMS, with the support of blocks.
author: shaack.com
---

<!-- intro -->

# Reboot CMS

A flat file, markdown CMS with blocks

---

The main idea is to have a **minimal CMS** without needing a database, but with the support of blocks.

---

[Learn more](documentation)

<!-- image-text -->

## The "image-text" block

The block above is an "intro" block. This one is an "image-text" block. You can define multiple blocks as you need.
[Reboot CMS documentation](documentation)

Shipped with this CMS are some default block types, but it is easy to create your own, if you know some PHP.

---

![alt text](media/dummy.svg "Title Text")

<!-- features -->

### Just files

A page is a text file, and its path is its URL.

---

### Blocks

Every part of the block is one column.

---

### Fast

No database, no framework, no queries.

This Page contains 3 Block types, "intro", "image-text" and "features". The page site/pages/blocks.md of the demo shows each of them with the Markdown that produces it.

Blocks can be configured in the block comment. With <!-- image-text: {image-position: left} --> the image-text block shows the image on the left side in desktop view.

Markdown files without blocks will render to a flat Markdown page like in every other flat file CMS, as one text block. With block: post in the frontmatter another block renders it, and that block gets the frontmatter as its config. The posts of the blog of the demo are such pages, site/pages/blog/2026-11-08-reboot-cms-4.md, the block site/blocks/post.php shows their title, date and links:

---
title: Fast pages without tuning
date: 2026-10-01
description: Speed is a feature visitors notice before they read a word.
block: post
---

A visitor decides within a second or two whether a page is worth waiting for.

You can define metadata for the page on top of the file in YAML Front Matter syntax. A date like 2026-10-01 stays text, $page->meta('date') returns it as written.

Lists of pages

$site->pages("blog") lists the pages of a folder in site/pages/, with the fields of their frontmatter and the queries of a collection, see Data. The block blog-list of the demo shows the newest posts with it:

<?php foreach ($site->pages("blog")->sortBy("date", "desc")->limit(5) as $post) { ?>
    <h2><a href="<?= $post->getUrl() ?>"><?= $post->get("title") ?></a></h2>
    <time><?= $post->date("date", "F j, Y") ?></time>
<?php } ?>

The list holds the Markdown and structured pages of the folder, not those of its subfolders, not its index and no page with draft: true. Text with an address belongs here, a blog or news. Records that show up on several pages are data, see Data.

Multilingual sites

A site is in one language until site/config.yml names several. The first segment of a URL then selects the language, /de/about is the page "about" in German, a path without a language prefix is in the default language.

Configuration

Declare the available languages in site/config.yml:

languages: [en, de]
defaultLanguage: en

languages lists every language code that may appear as a URL prefix. defaultLanguage is the language of paths without a prefix, it is optional and defaults to the first entry of languages.

Markdown pages, a file per language

The versions of a Markdown page lie next to each other. The file without a language is the default language, every other language adds its code before the extension:

site/pages/
  index.md        index.de.md      <- /  and /de/
  about.md        about.de.md      <- /about  and /de/about
  contact.md                       <- /contact, and /de/contact as long as there is no contact.de.md

/de/about renders about.de.md. A page without a file of its own for a language shows the one of the default language, so a site can be translated page by page. The frontmatter of about.de.md holds what it translates, the rest comes from about.md, a date for example is written once. The default language may be named as well, about.en.md. A version has no address of its own, /about.de is no page. A 404.de.md is the "not found" page in German.

The older layout with a folder per language, site/pages/de/about.md, still works but is deprecated, the log names the file to rename. A file next to the page wins over the folder.

Structured pages, all languages in one file

A structured page keeps every language in its one YAML file. Its blocks and the items of its lists are the same in all languages, only the values of the text fields differ:

title: {en: About us, de: Über uns}
blocks:
  - type: image-text
    text:
      en: "## Pages are forms"
      de: "## Seiten sind Formulare"
    image: media/team.jpg
    image-position: left

The fields of the types text, textarea, markdown, image and url hold a value per language. A field whose value is the same in all languages says so in the blueprint of its block type with shared: true:

fields:
  text:  {type: markdown, required: true}
  image: {type: image, shared: true}

The other types, number, date, boolean, select and entry, are the same in all languages anyway. A value without languages, as on a site in one language, holds for all of them, so an existing page needs no migration. A language without a value shows the one of the default language. Templates need no change, $block->get("text") returns the value of the language of the page.

Links in a translation

The links of a page are relative to <base href>, the root of the site. On a page in another language than the default one, Reboot CMS puts the language in front of every relative link to a page, [Blog](blog) on /de/about leads to /de/blog. A translation keeps the links of the original. Links to files in web/, media/photo.jpg for example, links with a scheme and links that start with a language stay as they are.

Data, all languages in one entry

The entries of a collection are YAML files and follow the same rule as structured pages. A text field holds a value per language, the other fields and the fields with shared: true have one for all:

title: {en: Markdown for Editors, de: Markdown für Redakteure}
date: 2026-12-05
location: Bremen
price: 190
body:
  en: |-
    A hands-on day for everyone who writes the content of a website.
  de: |-
    Ein Praxistag für alle, die die Inhalte einer Website schreiben.

$seminar->get("title") returns the title in the language of the page being rendered, a language without a value shows the default one. An entry that is a page of its own, /seminars/<slug>, has the same slug in every language, /de/seminars/<slug>. Data that is the same in every language, names and addresses for example, says shared: true next to its fields, then none of them holds a value per language, like site/blueprints/sites.yml of the demo. Queries read the default language, where() and sortBy() give the same order in every language. $site->getLanguage() names the language of the page being rendered.

Editing in the admin

On a multilingual site the editor of a page and of an entry of data has a language switcher above it. For a structured page it shows the inputs of one language, the preview and the address next to the title follow it, the blocks stay the same. A field of a language that has no value yet has a dashed border and shows the default language as its placeholder. A shared field has one input and a globe next to its label, a change applies to all languages. The language chosen is kept for the next page.

For a Markdown page the switcher opens the file of the language. A language without a file is shown dashed, a click creates it as a copy of the open page, ready to translate. The page list shows a page once, with its languages next to it, and opens the version in the language chosen last. Renaming or moving a page takes its versions in the other languages along.

Translating a site

Translate in the admin field by field, or let a coding agent do it in the files. For a structured page it adds the value of each text field in the new language, heading: {en: Welcome, de: Willkommen}, and leaves the blocks, the lists and the fields with shared: true as they are. For a Markdown page it writes about.de.md next to about.md. The AGENTS.md of the repository tells the agent so. The homepage of the demo was translated that way, see site/pages/index.yml.

Detecting the language

The Request object detects the language from the first path segment:

  • $request->getLanguage(): the current language code (e.g. "de"), or the default language for unprefixed paths
  • $request->getPathWithoutLanguage(): the path with the language prefix stripped (e.g. /de/contact becomes /contact), handy for language switchers and hreflang tags

The Site object knows the languages and builds the addresses:

  • $site->getLanguages(), $site->getDefaultLanguage() and $site->isMultilingual()
  • $site->languagePath($path, $language): a path in a language, /contact in German is /de/contact, the default language has no prefix
  • $site->languageUrls($request): the address of the current page in every language, ["en" => "/about", "de" => "/de/about"]
  • $page->getLanguage(): the language the page was rendered in
<?php foreach ($site->languageUrls($request) as $language => $url) { ?>
    <link rel="alternate" hreflang="<?= $language ?>" href="<?= $url ?>">
<?php } ?>

Multilingual navigation

The navigation of the demo has one structure for all languages in site/config.yml. Each entry is a path with its label, a label per language as in the fields of structured pages, or one label for all of them:

navbar:
  brand: Reboot CMS
  structure:
    /: {en: Home, de: Start}
    /seminars: {en: Seminars, de: Seminare}
    /blog: Blog
    https://github.com/shaack/reboot-cms: GitHub

The template takes the label of the language with $site->localize() and puts the path into the language of the page with $site->languagePath(), so a visitor on /de/ stays in German:

$language = $request->getLanguage();
foreach ($site->getConfig()['navbar']['structure'] as $path => $label) {
    $href = str_starts_with($path, "http") ? $path : $site->getWebPath() . $site->languagePath($path, $language);
    echo '<a href="' . $href . '">' . $site->localize($label, $language) . '</a>';
}

The older form with the label first, Home: /, still works in the template of the demo, it recognizes an entry by whether it starts with a path or a URL.

The language switcher of the demo links the current page in every language and marks the one shown:

<?php foreach ($site->languageUrls($request) as $code => $url) { ?>
    <a href="<?= $url ?>" hreflang="<?= $code ?>"
       <?= $code === $request->getLanguage() ? 'class="active" aria-current="true"' : '' ?>><?= strtoupper($code) ?></a>
<?php } ?>

With the LanguageRedirect addon (see below), append a ?setlang=xx marker to the links of the switcher, so the addon registers the choice:

<?php foreach ($site->languageUrls($request) as $language => $url) { ?>
    <a href="<?= $url ?>?setlang=<?= $language ?>"><?= strtoupper($language) ?></a>
<?php } ?>

Set the <html> language attribute from the detected language as well:

<html lang="<?= $request->getLanguage() ?>">

Redirecting to the visitor's language

The built-in LanguageRedirect AddOn (see its README) sends visitors to the language version that matches their preference. Enable it in site/config.yml:

addons: [ LanguageRedirect ]

On the homepage it redirects the visitor to their preferred language. An explicit choice stored in the lang cookie wins, otherwise the browser's Accept-Language header decides. Visitors whose preference is the defaultLanguage are not redirected, and the decision is taken on every visit, so a visitor whose browser prefers a non-default language consistently lands on that language.

The lang cookie records only an explicit choice, which is why switcher links must carry the ?setlang=xx marker shown above. When the addon sees ?setlang, it stores the choice and redirects to the clean URL, so switching to the default language keeps the unprefixed homepage reachable instead of bouncing back.

Block

Folder: /site/blocks

Files in site/blocks/ whose name starts with an underscore, for example _helpers.php, are not blocks but helpers the block templates require_once. The admin skips them when it collects block examples, so shared functions can live there without being declared twice.

A block template is also rendered once with empty content when the admin collects the examples for the "Insert Block" menu, so every field may be missing. nodeHtml() takes that in stride, an empty list or null gives an empty string. If a template still throws, for example by indexing into a node list, the block is replaced by an error box with the message and the rest of the page, editor included, keeps working. The error is logged as well.

A Block describes how a block is rendered. Blocks are written in PHP.

The code for the "image-text" Block which was used in the page above, looks like this:

<?php
// read the configuration
$imagePosition = @$block->getConfig()["image-position"];
?>
<section class="block block-image-text">
    <div class="container-fluid">
        <div class="row">
            <div class="col-md-6 <?= $imagePosition === "left" ? "order-md-1" : "" ?>">
                <!-- all text from part 1 (xpath statement) -->
                <?= $block->nodeHtml($block->xpath("/*[part(1)]")) ?>
            </div>
            <div class="col-md-6">
                <!-- using attributes of the image in part 2 -->
                <img class="img-fluid" src="<?= $block->nodeHtml($block->xpath("//img[part(2)]/@src")) ?>"
                     alt="<?= $block->nodeHtml($block->xpath("//img[part(2)]/@alt")) ?>"
                     title="<?= $block->nodeHtml($block->xpath("//img[part(2)]/@title")) ?>"/>
            </div>
        </div>
    </div>
</section>

Elements in the markdown are queried and used as values for the block. The query syntax is Xpath with the addition of the part(n) function.

A query returns a single node when it matches exactly one element, and a list otherwise. A query on any element, like /*[part(2)], always returns a list, because it asks for a section of the content. $block->nodeHtml() renders a list with all its tags, so a section keeps its paragraphs, lists and links. A single element renders with what is inside it, tags included, which suits a template like <h2><?= $block->nodeHtml($heading) ?></h2>. A text node or an attribute gives its text, escaped for HTML, so it can go between tags and into an attribute value.

The blocks of the demo share the helper site/blocks/_helpers.php. Its blockClasses($block) writes the classes of the section, block block-<name>, and adds block-inverse for a block written as <!-- gallery: {inverse: true} -->. The stylesheet turns such a block into a white band over the full width, the intro, which is white already, into a dark one.

Use $block->content() to get the full block content as rendered HTML (without XPath querying). This is useful for simple blocks like "text":

<section class="block block-text">
    <div class="container-fluid">
        <?= $block->content() ?>
    </div>
</section>

Schema validation

$block->xpath() accepts an optional second parameter — an array of props for validation:

Prop Type Description
required bool Field must match at least once
description string Human-readable name, shown in validation warnings
min int Minimum number of matches required (for lists)
max int Maximum number of matches allowed (for lists)

When a required field is missing or a minimum count is not met, the CMS logs an error and — in debug mode (logLevel: 0) — shows a warning banner on the page and a toast with the expected markdown structure in the admin editor.

Example: the "intro" block with validation:

<?php /** @var \Shaack\Reboot\Block $block */ ?>
<section class="block block-intro">
    <div class="container-fluid">
        <div class="card border-0 bg-gradient">
            <div class="card-body">
                <div class="p-xl-5 p-md-4 p-3">
                    <h1 class="display-4"><?= $block->nodeHtml($block->xpath("/h1[part(1)]/text()", ["required" => true, "description" => "Intro heading (h1)"])) ?></h1>
                    <p class="lead"><?= $block->nodeHtml($block->xpath("/p[part(1)]/text()")) ?></p>
                    <hr class="my-4">
                    <div class="mb-4">
                        <?= $block->nodeHtml($block->xpath("/*[part(2)]")) ?>
                    </div>
                    <p>
                        <a class="btn btn-primary btn-lg"
                           href="<?= $block->nodeHtml($block->xpath("//a[part(3)]/@href", ["required" => true, "description" => "Call-to-action link"])) ?>"
                           role="button"><?= $block->nodeHtml($block->xpath("//a[part(3)]/text()")) ?></a>
                    </p>
                </div>
            </div>
        </div>
    </div>
</section>

For list fields (like the "gallery" block), use min to require a minimum number of matches:

$images = $block->xpath("//li/img", ["min" => 1, "description" => "Gallery images"]);

Data

Pages hold text and layout. Records with fixed fields, like seminars, events or team members, live apart from the pages in site/data/. A page decides where they show up, with a block or a PHP page that queries them.

A collection is a folder site/data/<name>/ with one YAML file per entry, like a structured page. A longer text is a field of the type markdown, written as a | block. The file name is the slug of the entry, a date in front keeps the folder in order.

# site/data/seminars/2027-03-12-photo-basics.yml
title: Photo Basics
date: 2027-03-12
location: Bremen
price: 290
body: |-
  A day on aperture, exposure and composition.

A blueprint site/blueprints/<name>.yml describes the fields. A collection without a blueprint stays empty and logs an error.

title: Seminars
sort: date            # or "date desc"
fields:
  title:    {type: text, required: true}
  date:     {type: date, required: true}
  location: {type: text}
  price:    {type: number, label: "Price (€)", min: 0}
  level:    {type: select, options: [Beginner, Advanced], default: Beginner}
  body:     {type: markdown, label: Description}
Prop Description
type text (default), textarea, markdown, number, date, boolean, select, image, url, list, entry, blocks
label Name shown in messages and forms, defaults to the field name
required The field must have a value
default Value used when the entry has none
options The allowed values of a select
min, max Limits of a number, or the number of items of a list
help A hint shown below the field in the admin
fields The fields of each item of a list
collection The collection an entry field chooses from
allow The block types a blocks field offers, by default all

Three types hold more than one value. A list repeats its own fields, for cards or a gallery:

images:
  type: list
  min: 1
  fields:
    image:   {type: image, required: true}
    caption: {type: text}

An entry names an entry of a collection, the admin shows a select of them: seminar: {type: entry, collection: seminars}. A blocks field is a list of blocks, see Structured pages.

columns: [date, title, price] next to fields sets the columns of the table in the admin, by default the first four fields that are no textarea or markdown. On a multilingual site shared: true next to fields gives every field one value for all languages.

A date is written as 2027-03-12 and read as that text, so dates compare and sort as text. An entry that does not match its blueprint is left out of the website and its errors are logged. An entry with draft: true is left out as well.

Query a collection in a block, a PHP page or the template:

<?php
$next = $site->collection("seminars")
    ->where("date", ">=", "today")
    ->sortBy("date")
    ->limit(3);
foreach ($next as $seminar) { ?>
    <article>
        <time><?= $seminar->date("date", "d.m.Y") ?></time>
        <h3><?= $seminar->get("title") ?></h3>
        <?= $seminar->html("body") ?>
    </article>
<?php } ?>
  • $site->collection($name) reads a collection once per request, sorted as the blueprint says.
  • where($field, $operator, $value) with =, !=, <, <=, >, >=, contains and in, "today" stands for the current date. filter(fn($entry) => ...) takes any condition.
  • sortBy($field, "asc"|"desc"), limit($count, $offset), first(), find($slug), count(). Every query returns a new collection.
  • $entry->get($field) returns a field escaped for HTML, get($field, $default, false) as written. html($field) renders a Markdown field, date($field, $format) formats a date, items($field) returns the items of a list, each with the same methods, entry($field) the entry an entry field names, getSlug() returns the file name without .yml, getUrl() its address if the entries are pages.

Entries as pages

With url in its blueprint, every entry of a collection is a page of its own. The seminars of the demo work like that: site/blueprints/seminars.yml has url: seminars, so site/data/seminars/2027-01-23-building-blocks.yml is the page /seminars/2027-01-23-building-blocks.

title: Seminars
sort: date
url: seminars
fields:
  title: {type: text, required: true}
  date:  {type: date, required: true}
  body:  {type: markdown}

The template site/blueprints/<name>.php, next to the blueprint, renders such a page inside site/template.php. It gets the entry as $entry, besides $site, $page and $request. The scalar fields of the entry are the config of the page, so $page->meta('title') reads its title. Drafts and entries that do not fit their blueprint are no pages, a file in site/pages/ of the same address wins. A list of the entries links to them with $entry->getUrl(), like the blocks seminar-teaser and seminar-spotlight of the demo, getUrl() is in the language of the page being rendered.

Structured pages

A structured page is a YAML file in site/pages/, site/pages/about.yml for /about. It holds no Markdown but a list of blocks, and each block holds the fields its block type names. In the admin it is a form, a panel per block, so editors fill in fields and cannot break the layout. Structured pages are the default since Reboot CMS 4, the pages of the demo are structured pages, site/pages/index.yml for example.

title: About us
description: Who we are
blocks:
  - type: intro
    heading: Welcome
    image: /media/team.jpg
    text: |-
      We build **websites**.
  - type: features
    disabled: true
    items:
      - title: Fast
      - title: Small

A block type for structured pages is a folder in site/blocks/ with the template and the blueprint, both named like the folder. It may share its name with the block of Markdown pages:

site/blocks/
  intro.php           # the block of Markdown pages, a single file
  intro/              # the block of structured pages
    intro.php         # the template
    intro.yml         # the blueprint, its fields

The blueprint:

title: Intro
fields:
  heading: {type: text, required: true}
  text:    {type: markdown}
  image:   {type: image}
  inverse: {type: boolean}
<section class="block block-intro">
    <h1><?= $block->get("heading") ?></h1>
    <?= $block->html("text") ?>
    <?php if ($block->get("image")) { ?><img src="<?= $block->get("image") ?>" alt=""><?php } ?>
</section>

The template reads the fields like an entry: get(), html(), date(), items() and entry(). The fields are the config of the block as well, so inverse: true and disabled: true work as in a Markdown page. The values next to blocks, here title and description, are the config of the page, $page->meta('title') in the template reads them like frontmatter.

The fields of the page itself are a title, a description and the blocks. site/blueprints/page.yml replaces them with your own, it needs one field of the type blocks. A block type with a blueprint is offered in structured pages only. With markdown: true in its blueprint it is offered in Markdown pages as well, its template then reads the Markdown when $block->getBlueprint() is null, like seminar-teaser/seminar-teaser.php of the demo. A Markdown block may live in a folder as well, site/blocks/<name>/<name>.php. A file and a folder may share a name: Markdown pages and blocks with markdown take the file site/blocks/<name>.php, blocks with fields the folder. A block type without its own template takes the other one. A Markdown page and a structured page of the same name are one address, the Markdown page wins.

A structured page can hold the blocks of Markdown pages too. Such a block has markdown instead of fields, the other values are its config, and it renders with its Markdown template as in a Markdown page:

  - type: image-text
    image-position: left
    markdown: |-
      ## A heading

      Some text.

      ---

      ![Alt text](media/dummy.svg)

Converting a Markdown page

A Markdown page can be converted into a structured page, in the admin or with Shaack\Reboot\AddOns\Admin\PageConverter. The frontmatter becomes the page fields, every value is kept. A block becomes a form block if a block type says in its blueprint how to read it, otherwise it becomes a Markdown block of the structured page and looks as before. Nothing is lost.

# site/blocks/intro/intro.yml
convert:
  from: intro                         # takes over the Markdown blocks `intro`
  fields:
    heading: "/h1[part(1)]"           # XPath as in the Markdown template, its text
    text: {part: 2}                   # the Markdown of part 2
    button-link: "//a[part(3)]/@href"

A list field takes an item per node or per part: {each: "//li/img", fields: {image: "@src", caption: "@alt"}} reads relative to each node, {each: part, fields: {title: "//h3", text: {part: 1, strip-heading: true}}} reads each part like a block of its own, strip-heading leaves out its heading line. The config of the Markdown block, inverse: true for example, stays with the form block. In the demo the form blocks text, image-text, gallery and features take over the Markdown blocks of the same names. The Markdown intro stays a Markdown block, its text between lead and button has no field in the form block intro. A site from Reboot CMS 3 with other names, hero for example, sets from: hero. {part: 0} reads the whole block, separators included.

Admin interface

You find the admin interface at /admin.

If no users exist yet (e.g. on a fresh installation), the admin interface will automatically show a setup page where you can create the first admin account. After that, you can log in with the credentials you created.

In the admin interface you can edit pages as forms or in Markdown with a live preview, edit data, manage media files, set the site configuration, manage users and roles, and update the CMS.

Edit a page

The "Pages" section lists all pages of the site in a tree reflecting the folder hierarchy in site/pages/. A structured page opens as a form, see Structured pages below. A Markdown page, marked with the Markdown sign in the list, opens in the Markdown editor described here.

A live preview panel, opened and closed with the second button next to the title, shows the rendered page side-by-side with the editor (available on screens lg and wider). The preview updates automatically as you type. A click in the preview puts the cursor on the line it shows. Open page opens the saved page in a new tab.

Edit a Markdown page

Besides the usual formatting, the toolbar inserts a link to another page of the site, an image from the media folder and a block. The block menu lists every block type of the site and inserts its example Markdown at the cursor, so the structure a block expects is already in place.

Insert a block

The editor tracks page history — every save creates a snapshot. You can view previous versions and restore them from the History dialog. The number of snapshots kept per page is configurable via history.maxVersions in local/config.yml.

Page history

Data

The menu entry "Data" lists the collections of site/data/, one per blueprint, see Data. A collection opens as a table of its entries, a click on a row opens the entry as a form made from the blueprint: a date picker for a date, a list for a select, a switch for a boolean, the media browser for an image and the Markdown editor for a markdown field. Required fields are marked, and the fields that do not fit the blueprint are named after saving. Such an entry is saved anyway, marked in the table and left out of the website until it is fixed. Draft keeps an entry off the website. A new entry gets its file name from its first date and text field, 2027-03-12-photo-basics.yml, and keeps it. Every save and delete keeps the previous version in local/history/data/. Editors can use the page like Pages and Media.

Data

Structured pages

A right click in the page list offers New page, which creates a structured page, and New Markdown page. A new folder starts with a structured index page. A structured page opens as a form: the title and the description, then a panel per block. A panel folds with a click on its name, Collapse all below the form folds all of them, the switch turns the block off and on, the arrows move it, the cross removes it. Add block below the panels adds one of the block types with a blueprint. A list field adds, moves and removes its items the same way. Add block › Markdown blocks offers the blocks of Markdown pages as well, they keep a Markdown field instead of fields. The preview shows the page with the changes next to the form, before saving, and scrolls to the field being edited. A click on a block in the preview scrolls the form to its panel. Open page opens the saved page in a new tab. Fields that do not fit their blueprint are marked after saving. Saving keeps the previous version in the history, … › History shows and restores it like for a Markdown page. Values the blueprints do not know stay in the file.

Add a block

… › Convert to structured page below a Markdown page, or in its menu in the page list, converts it. It shows first what becomes of each block and the YAML, Convert then replaces the Markdown file by the YAML file at the same address. The Markdown file is kept in the history of the page, … › History shows it as a Markdown version, and restoring it turns the page back into a Markdown page.

Media

The "Media" section lets you manage files in the web/media/ directory. You can upload files, create folders, and delete files or empty folders. Media files are accessible at /media/ in the browser and can be referenced in your markdown pages.

Media

Secure Downloads

The "Secure Downloads" section hands out single files through cryptic, short lived links that are additionally protected by a six digit password. It is meant for sending a document to one recipient, not for public files. Those belong in "Media".

Upload a file and Reboot CMS creates a link plus a password:

https://example.com/downloads/3ef938314c4d6cfe.pdf
Password: 692072

The real file name never appears in the link. The extension is cosmetic, the name is only sent in the Content-Disposition header, so the recipient still saves the file as Passwords.pdf. Send the link by mail and the password through another channel, then neither message alone is enough to open the file.

The list shows for every download whether it was fetched already, when, and from which anonymized IP address. The optional note you enter when uploading is shown to the recipient above the password form, so they know what they are about to download without the file name being revealed.

Secure Downloads

A link expires one week after the upload, or one hour after the first download, whichever comes first. Within that hour the file may be fetched again, so an interrupted transfer does not ruin the link. After expiry the file is deleted from the server automatically; the entry stays in the list for 30 days, so the log remains readable. Ten wrong passwords lock a link for good, which is what keeps six digits from being guessable.

Files live in local/downloads/, outside the web root and behind local/.htaccess, so nothing is ever served by the web server directly. Only the SecureDownloads addon delivers a file, and only after the password was accepted. The password is stored in plain text next to the file, which is what makes it readable in the admin list later.

The whole feature is the built-in SecureDownloads AddOn (see its README). Add it to site/config.yml:

addons: [ SecureDownloads ]

This enables the delivery of the files and mounts the "Secure Downloads" section in the admin. Without the AddOn there is neither a menu entry nor an admin page.

The defaults need no configuration. All values can be changed per site:

secureDownloads:
  path: /downloads        # URL prefix of the links
  linkLifetime: 604800    # seconds a fresh link stays valid (one week)
  downloadWindow: 3600    # seconds after the first download (one hour)
  logRetention: 2592000   # seconds an expired entry stays in the list (30 days)
  texts:                  # override the built-in texts, per language
    de:
      title: "Datei herunterladen"
      intro: "Bitte geben Sie das Passwort ein, das Sie per SMS erhalten haben."

The download page is self contained and does not use the site template, so a link looks the same in every project. Its texts ship in English and German and follow the request language on multilingual sites, so /de/downloads/3ef938314c4d6cfe.pdf speaks German.

The upload size is limited by PHP, not by Reboot CMS. The current limits are shown next to the upload field; raise upload_max_filesize and post_max_size in php.ini for larger files.

Site configuration

In the site configuration, you can store global values of the site, like the navigation structure or the content of header elements. The site configuration is written in YAML.

Site configuration

User management

The "Users" page in the admin interface allows you to manage accounts directly from the browser. You can:

  • Add users — create new accounts with a username, password, and role
  • Change passwords — update the password for any existing user
  • Change roles — switch a user between the Admin, Editor, and Disabled roles
  • Delete users — remove accounts (you cannot delete your own account)

Admins have full access to all admin pages. Editors can only access the page editor and media manager. Disabled users keep their account but cannot log in; an open session is ended on the next request.

Usernames may contain letters, numbers, and underscores (max 64 characters). Passwords must be at least 8 characters. Credentials are stored as APR1-MD5 hashes in local/.htpasswd, roles in local/roles.yml.

Stay signed in

The login form offers "Stay signed in for 2 weeks". Without it the login lasts as long as the browser session. With it, Reboot CMS sets a second cookie with a random token that restores the session for two weeks, for example after a restart of the browser. Only a hash of the token is stored, in local/remember-tokens.json, and every token is good for one use, the next visit replaces it. Logging out, changing the password, disabling or deleting the account forgets the tokens of that user.

Users

You can also manage users via the command line:

cd local
htpasswd .htpasswd admin

Backup

The "Backup" page writes a zip archive of the content, that is site/ with pages, blocks and configuration, plus the uploaded files in web/media/. The CMS itself (core/, vendor/, web/admin/) is not included, it comes from the release, and neither is local/ with the configuration, the accounts and the archives themselves.

An archive is named after the site and the moment it was written, for example Backup_example.com_2026-10-19_1148.zip. The page lists the existing archives with size and date, and offers download and delete for each. The files live in local/backups/, outside the document root, so they are only reachable through the admin page and never over a direct URL.

The archive is written with PharData from the core extension phar, which the updater needs anyway, so the optional extension zip is not required. With zlib the entries are deflated, without it they are stored uncompressed. Backups therefore also work in the development container of run.sh.

A backup restores by unpacking the archive over the project root, the paths inside match the project layout.

Backup

System health

The "System Health" page checks the installation: the PHP version and the required extensions, URL rewriting, whether local/ and site/pages/ are writable and local/ and core/ are protected from the web, whether admin accounts exist, the registered AddOns and the local configuration. A problem is marked red with a hint how to fix it. The page also shows the mail settings and sends a test mail on request.

System health

Update

The "Update" page in the admin interface shows the currently installed version and checks for available updates from the GitHub repository.

You can switch between two branches of the installed major version:

  • distrib-4 (stable) — versioned releases, compared by version number
  • main-4 (unstable) — latest development commits, compared by commit SHA

Every major version has its own pair of branches, Reboot CMS 3 lives in distrib and main. An update never installs another major version. When the next one is published, the page says so, links to its upgrade notes, for Reboot CMS 4 docs/upgrade-to-4.md, and names what the new version reads differently in your site. Upgrade to Reboot CMS … then installs it on request. It makes a backup of the content first and keeps the replaced core/, web/admin/ and vendor/ in local/previous-version/. After the upgrade the page offers the way back to the previous version, until you delete it.

When an update is available, you can apply it directly from the admin interface. The updater downloads the latest tarball and replaces core/, web/admin/, and vendor/. Your site content (site/), local configuration (local/), and entry point (web/index.php) are not affected.

It is recommended to make a backup of the project folder before updating.

Update

Built-in AddOns

Some features of Reboot CMS are not part of the core but ship as AddOns in core/addons/. They are off by default and switched on per site by listing them under addons in site/config.yml:

addons: [ LanguageRedirect, EmailObfuscation, SecureDownloads, ProtectedAreas, CodeHighlighting ]

Everything an AddOn needs is in its folder, including its admin pages, so an AddOn that is not listed leaves no trace in the admin. Because core/ is replaced by the updater, the built-in AddOns are updated together with the CMS. A project can still override one by placing an AddOn of the same name in site/addons/.

Each AddOn folder has a README.md with the complete documentation of that AddOn.

LanguageRedirect

Sends visitors of a multilingual site to the language version matching their preference. On the homepage an explicit choice stored in the lang cookie wins, otherwise the browser's Accept-Language header decides, and visitors who prefer the default language are not redirected. A language switcher appends ?setlang=xx to register the visitor's choice. The AddOn does nothing on a site with a single language.

See the LanguageRedirect README and Multilingual sites.

EmailObfuscation

Hides e-mail addresses from harvesters. After a page has been rendered, every mailto: link and every address in the visible text is replaced by a small inline script that writes the original markup back into the page. Visitors see the address as before, a crawler without JavaScript sees only base64. Nothing to configure.

See the EmailObfuscation README.

CodeHighlighting

Colors the code blocks written in Markdown between three backticks, on the server, so visitors need no JavaScript. It knows no grammar, only what most languages share: comments, strings, numbers, keywords, function calls and variables. The language after the backticks, ```php for example, decides which comments count and whether the rules for HTML or CSS apply. The colors are CSS variables like --code-keyword, which the style sheet of the site can set.

See the CodeHighlighting README.

SecureDownloads

Hands out single files through cryptic, short lived links that are additionally protected by a six digit password, for sending a document to one recipient. Files are uploaded in the admin section "Secure Downloads", which the AddOn brings along. A link expires one week after the upload or one hour after the first download, ten wrong passwords lock it, and expired files are deleted automatically. Files live in local/downloads/, outside the web root.

See the SecureDownloads README and the admin section Secure Downloads. The AddOn was called Downloads before version 3.15, sites that still list that name must switch to SecureDownloads.

ProtectedAreas

Puts parts of the site behind a login. A protected area is a path prefix such as /customers, and every page below it is served only to a logged in user whose account lists a matching path, for example /customers/acme. Accounts are e-mail addresses. The AddOn brings a login page rendered inside the site template, a logout URL, a limit on failed logins and the admin section "Protected Areas" for the accounts. If the site can send mail, visitors can also request a login link by mail instead of typing a password, and stay logged in for 30 days. What the protected pages contain is up to the site, so a customer area is just a folder of Markdown pages that you edit like any other.

addons: [ ProtectedAreas ]
protectedAreas:
  areas: [ /customers ]

See the ProtectedAreas README.

Protected Areas

AddOns

In Reboot CMS you can extend the functionality of your site with AddOns. AddOns hook into the request lifecycle to add authentication, modify rendered content, inject headers, track analytics, or implement any custom logic. An AddOn can also bring its own pages to the admin, so a feature like Secure Downloads is one self-contained unit.

Creating an AddOn

An AddOn is a folder in site/addons/, named after its class. The class file sits at the top level and extends AddOn, everything else is optional:

site/addons/MyAddOn/
  MyAddOn.php          # the AddOn class, namespace Shaack\Reboot\AddOns
  src/                 # helper classes, namespace Shaack\Reboot\AddOns\MyAddOn\, autoloaded
  admin/               # admin pages, mounted at /admin/addons/myaddon (see below)
<?php
// site/addons/MyAddOn/MyAddOn.php

namespace Shaack\Reboot\AddOns;

use Shaack\Logger;
use Shaack\Reboot\AddOn;
use Shaack\Reboot\Request;

class MyAddOn extends AddOn
{
    protected function init()
    {
        // Called once when the AddOn is loaded
        Logger::info("MyAddOn initialized");
    }

    public function preRender(Request $request): bool
    {
        // Called before page rendering
        // Return true to continue, false to stop (e.g. after a redirect)
        return true;
    }

    public function postRender(Request $request, string $content): string
    {
        // Called after page rendering, can modify the HTML output
        return $content;
    }
}

A helper class in src/Util/Helper.php is available as Shaack\Reboot\AddOns\MyAddOn\Util\Helper without any registration.

The former single file format, site/addons/MyAddOn.php with the class in the namespace Shaack\Reboot, still works but is deprecated. Such AddOns cannot bring admin pages or helper classes, and the admin's System Health page lists them. To migrate, move the file into a folder of the same name and change the namespace to Shaack\Reboot\AddOns.

Registering AddOns

Register your AddOns in site/config.yml. They are loaded and executed in the order listed:

addons: [ MyAddOn, AnotherAddOn ]

Using the built-in AddOns

The AddOns shipped in core/addons/ need no folder in site/addons/, just list them in addons. They are described in Built-in AddOns, each has a README.md in its folder, which is the convention for your own AddOns as well. An AddOn of the same name in site/addons/ takes precedence over the built-in one, so you can still override it per site.

Admin Pages

An AddOn adds pages to the admin by putting PHP files into its admin/ folder. The folder is mounted at the AddOn's admin path, which is its lower case name by default:

site/addons/MyAddOn/admin/index.php      → /admin/addons/myaddon
site/addons/MyAddOn/admin/log.php        → /admin/addons/myaddon/log
site/addons/MyAddOn/admin/api/list.php   → /admin/addons/myaddon/api/list

An admin/index.php also adds an entry named after the AddOn to the admin menu "AddOns (n)", which lists every registered AddOn. An AddOn without an admin page is listed there too and links to an info page at /admin/addons/<name>, the same address its admin page would have, which shows the README.md of its folder, so every AddOn should bring one. Pages and menu entry exist only while the AddOn is listed in site/config.yml, so disabling an AddOn removes its admin section as well. The pages are rendered inside the admin template with $reboot, $site (the admin site), $page and $request available, like the built-in admin pages. Start every page with the admin role check, and reach your AddOn through the admin's view of the site:

<?php
use Shaack\Reboot\AddOns\Admin\AdminHelper;

if (!AdminHelper::requireAdmin($site, $reboot)) return;

$myAddOn = $site->getAddOn("Admin")->getDefaultSite()->getAddOn("MyAddOn");

Three methods let you deviate from the convention:

  • getAdminPath(): string — the mount point, /addons/myaddon by default. Until version 3.18 it was /myaddon, the admin redirects such old addresses.
  • getAdminPages(): array — the pages as path => PHP file, by default the content of admin/.
  • getAdminMenu(): array — the navigation entries as label => path, placed in the menu "AddOns (n)". By default one entry for admin/index.php. Return [] to keep a page out of the menu, or a nested array to extend a dropdown: ['Admin' => ['Statistics' => '/addons/myaddon']] adds the page to the "Admin" menu. AddOn entries are shown to admins only and never override a built-in entry of the same name.

See core/addons/SecureDownloads/ for a complete AddOn with a frontend part, a helper class and an admin page.

Available Properties

Inside your AddOn, you have access to:

  • $this->reboot — the Reboot instance (base paths, config, redirects)
  • $this->site — the Site instance (site config, paths, other addons)
  • $this->dir — the folder of the AddOn, null for the deprecated single file format
  • $this->reboot->getMailer() — the Mailer, configured in local/config.yml (see Sending mail). Check isConfigured() if mail is optional for your AddOn, send() throws a RuntimeException when a mail cannot be handed over:
$mailer = $this->reboot->getMailer();
if ($mailer->isConfigured()) {
    $mailer->send('alice@example.com', 'Your login link', "Open this link: $url", ['toName' => 'Alice']);
}

Lifecycle Hooks

init(): void

Called once after construction of the AddOn. Use this to initialize data, read configurations, or start sessions.

preRender(Request $request): bool

Called on every request before rendering the page. Use it to:

  • Control access — check authentication and redirect unauthorized users
  • Modify request handling — perform redirects based on request path or parameters

Return true to continue rendering the page, or false to stop (e.g. after calling $this->reboot->redirect()).

postRender(Request $request, string $content): string

Called after the page is rendered, before the output is sent to the browser. Use it to:

  • Modify HTML output — inject scripts, stylesheets, or meta tags
  • Add tracking — append analytics snippets
  • Transform content — search and replace patterns in the rendered HTML

Returns the (possibly modified) content string.

getPages(): array

Pages the AddOn adds to its own site, as path => PHP file, for example ['/sitemap.xml' => $this->dir . '/pages/sitemap.php']. A page is rendered like a PHP page in site/pages/, and a file in site/pages/ of the same path wins. Admin pages use the admin/ folder instead, see Admin Pages.

Accessing AddOns from Pages

You can access a registered AddOn from any page template using:

$myAddOn = $site->getAddOn("MyAddOn");

Examples

See the included ExampleAddOn for a basic implementation and SecureDownloads for a complete one. The admin interface is itself a Reboot CMS site and uses AddOns too: Authentication for login session handling and Admin for the bridge to the managed site, which also mounts the admin pages of its AddOns.

Credits

The admin interface uses Bootstrap and the editor cm-md-editor. All icons in the admin, also the inline SVGs in its PHP and JavaScript, are from Bootstrap Icons, MIT License. Markdown is rendered with Parsedown, the configuration is read with Symfony YAML, and mail is sent with PHPMailer.

About

A flat file, Markdown CMS in PHP with blocks structure.

Topics

Resources

Stars

51 stars

Watchers

7 watching

Forks

Releases

Packages

Used by

Contributors

Languages