Search by

lexislav / get-station0

lexislav

Station0 CMS project skeleton — use with composer create-project.

Package info

github.com/lexislav/get-station0

Type:project

pkg:composer/lexislav/get-station0

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.9.0 2026-10-09 11:18 UTC

This package is auto-updated.

Last update: 2026-10-09 11:21:01 UTC


README

Starter kit for Station0, a lightweight flat-file CMS for PHP 8.2+.

Your pages are plain text files in site/content/. There are no database migrations and the whole site is Git-friendly. SQLite is used only for admin logins. Editors get a friendly admin with a block-based page builder, and web designers work with plain Twig templates and a few YAML files.

This repository is the project skeleton. The CMS itself (lexislav/station0) is installed into vendor/ and updated independently, so your site files are never touched by an update.

Quick start

Requirements: PHP ≥ 8.2 with the pdo_sqlite extension, and Composer.

composer create-project lexislav/get-station0 mysite
cd mysite
php -S localhost:8080 -t public public/index.php
  1. Open http://localhost:8080. The demo site is there.
  2. Open http://localhost:8080/admin. The first visit shows a setup form that creates the first admin account. Once a user exists, the form disables itself.
  3. Edit a page in the admin, save it, and reload the site.

composer create-project copies .env.example to .env for you. Start the dev server from the project root.

What's inside

mysite/
├── public/                 ← web root (index.php, .htaccess, assets/)
│   └── assets/site.css
├── site/
│   ├── config.php          ← app configuration
│   ├── content/
│   │   ├── pages/          ← the page tree (one directory per page)
│   │   └── collections/    ← headless content (banners, shared blocks…)
│   └── templates/
│       ├── layout.twig     ← base layout
│       ├── page.twig       ← one file per page template
│       ├── blog.twig, article.twig, …
│       └── blocks/         ← page-builder block types
│           ├── text/
│           └── gallery/
├── writable/               ← cache, sessions, logs, db.sqlite (created automatically)
├── vendor/                 ← Station0 core + dependencies
└── .env                    ← environment settings

Demo content shows each feature:

URL Template Shows
/ page Plain Markdown page
/history blocks Page builder: text + gallery blocks, page-local image
/history/about, /history/team page Nested pages
/work blocks Blocks with external image URLs
/blog blog A stream that lists its child articles
/blog/… article Articles with PublishedAt and Author
/studio showcase Content pulled from collections

The banner bar above the header comes from the banners collection (see layout.twig).

Content: pages

Every page is a directory under site/content/pages/. The directory name is the URL slug, and the page tree is the directory tree:

site/content/pages/
├── page.txt                  → /
├── history/
│   ├── page.txt              → /history
│   ├── 6715896b8f477dc8.png  ← an image that belongs to this page
│   └── team/
│       └── page.txt          → /history/team
└── blog/
    ├── blog.txt              → /blog          (template "blog")
    └── editing-basics/
        └── article.txt       → /blog/editing-basics

The content file is named after the page's template (page.txt, blog.txt, article.txt…), and site/templates/<template>.twig renders it.

A content file is front matter, then ---, then the body:

Title: Editing content the flat-file way
Template: article
Status: published
PublishedAt: 2026-05-10 09:00
Author: Station0 Team
Sort: 10
---

The body: Markdown, or a YAML list of blocks (see below).
Key Meaning
Title Page title (required)
Metatitle Optional <title> override
Template Template name (also implied by the file name)
Status draft / published / archived (older files: Published: true/false). Drafts are 404 on the site, archived pages 410 Gone; all stay in the admin.
PublishAt / ExpireAt Y-m-d H:i in the site timezone. A published page is scheduled until PublishAt and gone (410) from ExpireAt.
PublishedAt Y-m-d H:i. The date shown to readers (and used for sorting). In older files without PublishAt, a future date still schedules the page.
Listing listed (default) / nav-hidden (live, not in menus) / unlisted (live, URL only — not in menus or child_pages())
Cascade false = when this page is not live, its subpages stay live. By default a hidden page hides its whole subtree.
Author, Updated Informational; Updated is set on save
Sort Order among siblings (lower first; otherwise alphabetical). Drag & drop in the admin sets it.
AllowedChildTemplates Turns the page into a stream (see below)

Any other key you add (for example Subtitle: …) is kept and available in Twig as page.extra.subtitle. Keys are case-insensitive and lower-cased on read. For fields that editors should manage, use page fields.

You can edit these files by hand, commit them, or deploy them with Git. The admin reads and writes the same files.

Signed-in editors can open pages that are not public yet: they render with a "not public" bar on top. The full visibility reference lives in the library: docs/visibility.md.

Templates

Page templates are Twig files in site/templates/. They usually extend layout.twig:

{# site/templates/page.twig #}
{% extends 'layout.twig' %}

{% block title %}{{ page.metatitle ?? page.title }} · {{ app.name }}{% endblock %}

{% block body %}
<article>
    <h1>{{ page.title }}</h1>
    <div class="content">{{ content|raw }}</div>
</article>
{% endblock %}

Every page template receives:

Variable Content
page The page: title, metatitle, urlPath, slug, template, status, state, isLive, inNav, listing, publishedAt, publishAt, expireAt, author, updated, sort, extra
preview true while a signed-in editor views a page that is not public
content The rendered body as HTML (Markdown or blocks), printed with `
fields The template's page fields: typed, with image URLs resolved
app app.name, app.baseUrl, app.adminPath

Adding a template: create site/templates/<name>.twig. It then appears in the admin's Template dropdown. layout.twig is never offered as a page template.

See the Twig reference for the helper functions (child_pages(), collection(), …).

Blocks (the page builder)

The admin edits a page body as an ordered list of blocks. On disk, the body is a YAML list:

- type: text
  body: |
    # About us
    Some **Markdown**.
- type: gallery
  columns: 3
  images:
    - src: photo.jpg
      alt: Photo

A plain Markdown body also works: it's treated as a single text block.

The built-in text block is a Markdown editor. Every other block type is a directory in site/templates/blocks/ with two files:

site/templates/blocks/gallery/
├── schema.yaml     ← the fields the admin shows
└── template.twig   ← how the block renders
# schema.yaml
label: Gallery
fields:
  columns:
    type: number
    label: Columns
    default: 3
  images:
    type: list
    label: Images
    item:
      src:
        type: image
        label: Image
      alt:
        type: text
        label: Alt text
{# template.twig — block values are under `block` #}
<div class="gallery gallery--cols-{{ block.columns|default(3) }}">
    {% for image in block.images|default([]) %}
        <a href="{{ image.src }}">
            <img src="{{ image.src|thumb(800) }}"
                 srcset="{{ image.src|thumb_srcset([400, 800, 1200, 1600]) }}"
                 sizes="(min-width: 720px) 213px, 33vw"
                 alt="{{ image.alt|default('') }}" loading="lazy">
        </a>
    {% endfor %}
</div>

Adding a block type: create the directory with both files. It appears in the admin's "+ Add block" bar immediately. Image values are stored as bare file names and arrive in template.twig as full /media/… URLs.

All field types are listed in the field types reference.

Template manifest: fields, blocks, restrictions

Each page template can have an optional manifest next to it, site/templates/<template>.blocks.yaml, which shapes the editor for pages that use that template. All keys are optional:

# site/templates/product.blocks.yaml
fields:               # page fields: single values edited above the builder
  subtitle:
    type: text
    label: Subtitle
blocks: false         # hide the page builder completely
allowedBlocks:        # limit the "+ Add block" bar (in this order)
  - text
  - gallery
defaultBlocks:        # blocks pre-inserted into a NEW page
  - gallery

Without a manifest, a template gets the full block palette and a new page starts with one empty text block.

Page fields

(station0 ≥ 0.7.5) Page fields suit structured single values such as a subtitle, price, hero image, dates or a list of features. They sit on the page itself, above the builder, and templates read them directly:

# site/templates/product.blocks.yaml
fields:
  subtitle:
    type: text
    label: Subtitle
  price:
    type: number
    label: Price
  featured:
    type: boolean
    label: Featured
  hero:
    type: image
    label: Hero image
  features:
    type: list
    label: Features
    item:
      title:
        type: text
        label: Title
      text:
        type: textarea
        label: Text
{# site/templates/product.twig #}
{% extends 'layout.twig' %}
{% block body %}
<article>
    {% if fields.hero %}<img src="{{ fields.hero }}" alt="">{% endif %}
    <h1>{{ page.title }}</h1>
    <p class="lead">{{ fields.subtitle }}</p>
    {% if fields.featured %}<span class="badge">Featured</span>{% endif %}
    <p class="price">{{ fields.price }} €</p>
    <ul>
        {% for f in fields.features %}<li><strong>{{ f.title }}</strong> {{ f.text }}</li>{% endfor %}
    </ul>
    {{ content|raw }}   {# the builder, if the template keeps it #}
</article>
{% endblock %}

Three combinations are possible:

Manifest Editor shows
no fields the page builder only (default)
fields page fields and the builder
fields + blocks: false page fields only (a "form-style" page)

Details:

  • Field values are stored in the page's front matter. Multi-line text and lists are written as indented YAML, so the files stay readable:
    Subtitle: Light and fast
    Features:
      - title: Grip
        text: Sticky sole
    ---
    
  • fields in Twig are typed: booleans are true/false, numbers are numbers, and image/file values are ready-to-use /media/… URLs (also inside list items).
  • For another page, such as children in a listing, use page_fields(child):
    {% for child in child_pages(page.urlPath) %}
        {{ child.title }}: {{ page_fields(child).price }} €
    {% endfor %}
  • A new page starts from the schema default values.
  • Field names must not clash with built-in keys (title, template, published, sort, body, …); such fields are ignored. Keys are case-insensitive on disk, so prefer snake_case names.

Allowed and default blocks

  • allowedBlocks limits the palette, in the order you list. Unknown names are ignored.
  • defaultBlocks pre-inserts blocks, with their schema defaults, into a new page. Each must be part of allowedBlocks if you set it.

The editor is prepared for the template the page opens with. If you change the Template dropdown on a new page, save once to get that template's fields and palette.

Streams (blogs, news, listings)

A stream is a page that holds a list of child entries, such as a blog, news or projects. Add AllowedChildTemplates to the parent page:

Title: Blog
Template: blog
AllowedChildTemplates: article
---
  • New children of /blog can only use the article template (several templates: article, event).
  • The admin gets a Streams tab for quick access to stream entries (a stream put into a menu group shows up in its group's tab instead).
  • The parent template lists its children:
{% for child in child_pages(page.urlPath) %}
    <a href="{{ child.urlPath }}">{{ child.title }}</a>
    {% if child.publishedAt %}{{ child.publishedAt|date('j. n. Y') }}{% endif %}
{% endfor %}

child_pages() returns only live pages (published, not scheduled or expired, under live parents) in Sort order, including nav-hidden ones but not unlisted ones (child_pages(path, true) includes those too).

Collections (headless content)

Collections are content with no URL of their own, such as banners, testimonials, team members or reusable snippets. Editors manage them in the admin, and templates pull them in wherever needed.

site/content/collections/
└── banners/
    ├── _collection.yaml         ← optional schema
    └── summer-sale/
        ├── item.txt             ← same format as a page
        └── bg.jpg
# _collection.yaml
label: Banners
group: Marketing          # optional: own admin menu tab
fields:
  subtitle:
    type: text
    label: Subtitle
  cta_url:
    type: text
    label: CTA URL
  color:
    type: color
    label: Accent color

Without a schema, items are free-form (title + body).

{% for banner in collection('banners') %}          {# published items, in Sort order #}
    <div style="border-color: {{ banner.extra.color }}">
        <strong>{{ banner.title }}</strong> {{ banner.extra.subtitle }}
    </div>
{% endfor %}

{% set cta = collection_item('shared-blocks', 'intro-cta') %}
{% if cta %}{{ render_collection_item(cta) }}{% endif %}   {# renders the body #}

Collection field values are available as item.extra.<field>.

Admin menu groups

(station0 ≥ 0.8.2 for pages) A group is an own tab in the admin menu. It can hold collections, page sections, or both:

  • Collections: group: <Name> in _collection.yaml moves the collection out of the generic Collections tab into the group's tab.
  • Pages: Group: <Name> in a page's front matter puts that page and everything below it into the group's tab. The tab shows the subtree (drag & drop works as in the page tree; a stream shows all its entries). The pages stay in the page tree too, marked with a group badge. A page further down with its own Group: starts a separate group.
# site/content/pages/studio/showcase.txt
Title: Studio
Template: showcase
Group: Marketing
---

In this starter kit the Marketing tab holds the /studio page and the banners collection.

The optional site/content/_groups.yaml sets each group's label, icon, the roles that can see it, and the tab order:

marketing:            # group id = slug of the group name
  label: Marketing
  icon: "📣"
  roles: [admin]      # admins always have access

For collections, roles is enforced (other users get a 403). For pages it only hides the tab; the pages can still be edited from the page tree.

Media & uploads

Uploaded files are stored next to the page they belong to, in the page's directory, and content refers to them by bare file name (team.jpg). Moving or renaming a page takes its files along, and the references keep working.

  • Public URL: /media/<page-path>/<file> (the homepage uses /media/~/<file>). Station0 builds these URLs for you in blocks, page fields and Markdown images.
  • Images: jpg, png, gif, webp, svg (max 8 MB). Documents (file fields): pdf, office formats, archives, audio/video…
  • Collection items store their files in their own directories: /media/_collections/<collection>/<item>/<file>.
  • A new page must be saved once before files can be uploaded to it.
  • Absolute URLs (https://…, /…) are left untouched, so external images work too.

Thumbnails

(station0 ≥ 0.7.8) Don't send a 5 MB photo into a 200px gallery cell. The thumb filter turns a /media/… URL into a resized copy, which is generated on the first request and cached:

<img src="{{ image.src|thumb(600) }}">                        {# at most 600px wide #}
<img src="{{ image.src|thumb(300, 300, 'cover') }}">          {# cropped to fill 300×300 #}
<img src="{{ image.src|thumb(800) }}"
     srcset="{{ image.src|thumb_srcset([400, 800, 1200]) }}"
     sizes="(min-width: 720px) 720px, 100vw">                {# browser picks the size #}
  • Images are never enlarged. SVG, GIF and external URLs come back unchanged, so the filter is safe on any image value.

  • Markdown images in text blocks are thumbnailed automatically (at most 1200px wide), and the admin shows small previews.

  • Options in site/config.php (all optional):

    'thumbs' => [
        'format'   => 'webp', // serve thumbnails as WebP
        'markdown' => 1200,   // max width of Markdown images; 0 = originals
        'static'   => true,   // save thumbnails to public/thumb/ for the web server
    ],
  • php vendor/bin/console thumbs:warm generates thumbnails for all published pages in advance (the site must be running). thumbs:clear deletes the generated thumbnails.

Field types reference

Used in block schema.yaml, page fields: and collection _collection.yaml:

Type Editor Value in Twig
text one-line input string
textarea multi-line input string (use `
number number input (min, max optional) number
boolean checkbox true / false
select dropdown the selected value
color color picker + hex input #rrggbb
image upload button /media/… URL
file upload button (documents) /media/… URL
list repeatable rows of item: sub-fields array of rows

Common options: label, default.

A list whose items contain an image field gets drag & drop multi-upload, like the gallery block.

Select options can be static:

size:
  type: select
  options: [s, m, l]            # or {s: Small, m: Medium} or [{value, label, group}]

…or come from a collection (value = item slug):

product:
  type: select
  options_from: collection:products
  group_by: category            # optional <optgroup> by an item field
  sort_by: -price               # optional; "-" = descending
  option_label: "{title} ({price} €)"
  placeholder: "— choose —"

Twig reference

Function Returns
top_level_pages() Live first-level pages for the main navigation (skips nav-hidden / unlisted); same as nav_pages('/')
nav_pages('/services') Live children for a submenu (skips nav-hidden / unlisted)
child_pages('/blog') Live direct children of a page, sorted (skips unlisted; child_pages('/blog', true) includes them)
page('/path') A live page by path, or null
page_fields(page) Typed, resolved page fields of any page
collection('name') Live items of a collection (published, not scheduled / expired)
collection_item('name', 'slug') One live item, or null
render_collection_item(item) The item body as HTML

Globals: app.name, app.baseUrl, app.adminPath.

Rendered page and block HTML is cached in writable/cache/. The cache is cleared automatically whenever content is saved in the admin, and when a scheduled PublishAt / ExpireAt passes. After editing files by hand, run php vendor/bin/console cache:clear.

Users & roles

Role Can
admin everything, including Users and Settings
editor pages, streams and collections (except groups restricted by roles)

Create users in the admin (Users) or from the command line. Password reset by e-mail needs working SMTP settings.

Site tasks & hooks

Your own scripts — imports, syncs, exports — live in site/tasks/, one PHP file per task (the file name is the task name; files starting with _ are ignored, see site/tasks/_example.php). They run from the command line and from the admin (Tasks tab: parameter form, file upload, live output, run history).

<?php // site/tasks/import-products.php
use Station0\Service\TaskContext;

return [
    'label'  => 'Import products',
    'roles'  => ['editor'],            // who may run it in the admin; admins always may
    'params' => [
        'file'    => ['type' => 'file', 'label' => 'CSV', 'required' => true],
        'dry_run' => ['type' => 'boolean', 'label' => 'Dry run'],
    ],
    'run' => function (TaskContext $task): int {
        foreach (file($task->param('file')) as $line) {
            // … $task->collections()->save($item);
        }
        $task->success('Done.');        // info / warn / error; plain echo works too
        return 0;
    },
];
php vendor/bin/console task:list
php vendor/bin/console task:run import-products --file=products.csv --dry_run
  • Param types: text, textarea, number, boolean, select (incl. options_from), file.
  • $task gives you param(), pages(), collections(), cache(), config(), event().
  • Admin runs start in the background and the page shows the output as it comes. Configure how in site/config.php → tasks (runner, php); on hosting without exec() it falls back to running within the request.
  • A task never runs twice at once. Runs are logged in writable/logs/tasks/.

Hooks — a task can run automatically when content changes in the admin:

'on' => ['page.saved:/blog', 'collection.item.*:products'],
'run' => function (TaskContext $task) {
    $task->event('event');   // e.g. 'collection.item.saved'
    $task->event('slug');    // payload: path / collection / slug / title / created …
},

Events: page.saved, page.moved, page.deleted, collection.item.saved, collection.item.deleted. The filter after : is a page path (the page and everything below it) or a collection name; * wildcards work (page.*).

Configuration

.env (copied from .env.example):

Variable Default Meaning
BASE_URL http://localhost:8080 Public URL of the site
DEBUG true Error details and no Twig cache. Set false in production.
ADMIN_PATH /admin Where the admin lives
HTTPS false true marks the admin session cookie as secure. Set it when serving over HTTPS.
ADMIN_LOCALE cs Admin language: en or cs
ADMIN_BLOCK_COLLAPSE remember Initial block state in the editor: remember, expanded, collapsed
SITE_PATH ./site Move site/ elsewhere (absolute path)
MAIL_* SMTP for password-reset e-mails
TASKS_RUNNER auto How site tasks run in the background: auto, spawn, fastcgi, inline
TASKS_PHP PHP CLI binary for spawn (detected when empty)

The site name (name), paths and session settings are set in site/config.php.

Command line

Run from the project root:

php vendor/bin/console user:create <username> <email> [admin|editor]
php vendor/bin/console user:reset-password <email>
php vendor/bin/console cache:clear
php vendor/bin/console assets:relink --dry-run   # convert old absolute /media links to page-local names
php vendor/bin/console task:list                 # site tasks (site/tasks/)
php vendor/bin/console task:run <name> [--param=value]

Deployment

  1. Upload the project (or git pull it) and run composer install --no-dev.
  2. Point the web server's document root at public/. Nothing outside it should be publicly reachable.
  3. Make writable/ and site/content/ writable by the web server (the admin writes content files and uploads).
  4. In .env: set DEBUG=false, the real BASE_URL, and HTTPS=true if applicable.
  5. Visit /admin to create the first admin, or use user:create.

Apache: public/.htaccess already contains the rewrite rules. Enable mod_rewrite and AllowOverride All:

<VirtualHost *:80>
    ServerName mysite.local
    DocumentRoot "/path/to/mysite/public"
    <Directory "/path/to/mysite/public">
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

If Apache shows its own "Not Found" page on /admin, the rewrite isn't applied. Check mod_rewrite and AllowOverride.

nginx:

root /path/to/mysite/public;
location / {
    try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

Backups: site/ holds your content and templates, and writable/db.sqlite holds your users. Keeping site/ in Git is the easiest backup.

Updating Station0

The CMS core is a normal Composer dependency:

composer update lexislav/station0
php vendor/bin/console cache:clear

Your site/ directory is never touched. Check the Station0 changelog for new features and upgrade notes.

License

MIT