Search by

lexislav / get-station0

lexislav

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

Package info

github.com/lexislav/get-station0

Language:Twig

Type:project

pkg:composer/lexislav/get-station0

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.10.3 2026-10-27 09:10 UTC

README

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

Your pages are plain text files in site/content/. There are no database migrations and the whole site is Git-friendly. A small SQLite file holds what isn't content: user accounts, member sign-ins, rate limits and — with the redirects plugin — the 404 log (see What's in the database). 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.4.1 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/, callout/, quote/
│           └── features/, person/, related/, shared/
├── writable/               ← cache, sessions, logs, db.sqlite (created automatically)
├── vendor/                 ← Station0 core + dependencies
└── .env                    ← environment settings

Demo content explains itself: every page describes the feature it is built with, and /guide walks through all of them in order.

URL Template Shows
/ page Plain Markdown page, page-local image, a map of the demo
/guide guide A stream of topic pages, one per feature; lists them with page_fields()
/guide/visibility/… page Draft, scheduled, expired, archived, unlisted, nav-hidden pages, Cascade: false
/guide/media topic Page-local images, thumb / thumb_srcset, Markdown images
/work blocks The page builder — one block of every type
/shop, /shop/… shop, product Page fields of every type, a stream, Group: Shop
/blog, /blog/… blog, article A stream; PublishedAt, Author, a perex field, a scheduled article, categories (blog-categories collection, /blog/categories)
/studio showcase Content pulled from collections (people, shared-blocks, categories)
/members page Access: members — sign-in at /login (login.twig)
/contact contact A site action form (site/actions/contact.php), blocks: false

Also: the banner bar above the header (banners collection), two site tasks (content-report, the hook log-blog-saves), and the redirects plugin with example rules in site/content/_redirects.yaml (try /history, /old-offer).

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                  → /
├── guide/
│   ├── guide.txt             → /guide         (template "guide")
│   └── media/
│       ├── topic.txt         → /guide/media   (template "topic")
│       └── lake.jpg          ← an image that belongs to this page
└── 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
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 admin 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>.

Computed titles

(station0 ≥ 0.10) Instead of typing every item's title, a collection can build it from its fields. Any text around the placeholders is kept:

# _collection.yaml
label: People
title: "{name} {surname}"            # the title input is filled in and locked
fields:
  name:    { type: text, label: Name }
  surname: { type: text, label: Surname }
title:
  from: "{brand} – {model} ({year})"
  input: editable                    # readonly (default) | editable | hidden
input What the editor sees
readonly The title fills itself in while the fields are typed, and can't be changed.
hidden No title input, only a line showing the result.
editable The title fills itself in until the editor types their own. A typed title is kept even when the fields change later; clearing it (or the Use built title button) makes it follow the fields again.
  • Placeholders are field names, plus {slug} and {collection}.
  • If a field is empty, the separator next to it is dropped: {brand} - {model} without a model gives Škoda, and an empty ({year}) disappears.
  • A select field gives its stored value (the item slug or page path), an image its filename.
  • The title is saved into the item like a typed one, so item.title works everywhere as before. New items get their slug from it.
  • Existing items get the built title on their next save, or all at once with php vendor/bin/console collections:retitle [name] [--dry-run].

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
Group: Marketing
---

In this starter kit the Marketing tab holds the /studio page and the banners and shared-blocks collections; the Shop tab holds the /shop subtree and the categories 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
action_state('name') State / flash messages of a site action — read once per render
Filter Returns
src|thumb(w, h = 0, fit = 'contain'), src|thumb_srcset([400, 800]) Resized image URL / srcset (see Thumbnails)
text|markdown Markdown → HTML, like a text block: page-local images resolved, thumbnails (station0 ≥ 0.10.3). Pass a page or collection item to resolve images against it: item.extra.intro|markdown(item). Alias markdown_to_html.

Use |markdown for rich text in your own block types: a textarea field printed with {{ block.body|markdown }}. A block template that throws renders as <!-- block:<type> render error --> and the error is logged to writable/logs/app.log.

Page templates get page, content (print with |raw), fields (typed page fields) and preview (an editor viewing a page that isn't live). Globals: app (name, baseUrl, adminPath), csrf (for POST forms), visitor and access (see Members-only access).

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

Three fixed roles; an account has exactly one:

Role Can
admin everything, including Users and Settings
editor all pages, streams, collections and uploads (except collections in groups restricted by roles), tasks that list editor, exact redirect rules
member no admin access — signs in on the public site to see members-only pages

The only finer controls are roles on admin menu groups and on tasks, and admin.roles in site/config.php (who may enter the admin, default admin + editor). There are no per-page permissions and no approval workflow: any editor can publish, move or delete any page.

Create users in the admin (Users, admins only) or with php vendor/bin/console user:create <username> <email> [admin|editor|member]. Accounts can be created and deleted, not edited. You can't delete your own account or the last admin. Password reset by e-mail needs working SMTP settings. The demo's /guide/users-and-roles page has the full permission table.

Members-only access

(station0 ≥ 0.9) Off by default. Gate one section or the whole site in site/config.php:

'access' => [
    'mode'      => 'public',    // 'members' = the whole site needs a sign-in
    'public'    => ['/'],       // always public in members mode; `*` = prefix
    'redirect'  => '/login',    // where anonymous visitors of gated pages go
    'loginPath' => '/login',    // member sign-in page (template site/templates/login.twig)
],

Access: members in a page's front matter gates it and its subpages (Access: public opens a page of a closed site). Its media files are gated too. Members sign in at loginPath with a password or a one-time link by e-mail; admins and editors signed into the admin count as members. For anonymous visitors, top_level_pages(), child_pages() and page() leave gated pages out (pass true as the last argument for teaser lists). Templates get visitor (authenticated, member, staff, label, email) and access (loginPath, logoutPath, …). The demo's /members page and site/templates/login.twig show it working.

Site actions (forms)

(station0 ≥ 0.9) Your own request handlers — one PHP file per action in site/actions/ (files starting with _ are ignored; see site/actions/_example.php and the demo's contact.php):

return [
    'path'    => '/contact/send',
    'methods' => ['POST'],
    'handler' => function (ActionContext $ctx) {
        if (!$ctx->throttle()->hit('contact:' . $ctx->ip(), 5, 3600)['allowed']) {
            $ctx->state()->flash('error', 'throttled');
            return $ctx->back();
        }
        $ctx->state()->flash('sent', true);
        return $ctx->redirect('/contact');
    },
];

Every POST form needs the CSRF fields (csrf.nameKey / csrf.name, csrf.valueKey / csrf.value). The template reads the result with action_state('contact'). The handler also gets input(), mail(), log(), pages(), collections(), visitor() and members(), and 'access' => 'members' limits an action to signed-in visitors.

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
TIMEZONE Europe/Prague Site timezone for PublishAt, ExpireAt and other front-matter dates
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 resets, member sign-in links and your own actions' e-mail
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, session settings, access and plugins are set in site/config.php.

Plugins

(station0 ≥ 0.10, experimental) Optional features are turned on in site/config.php. A plugin that is not listed is never loaded:

"plugins" => [
    "redirects" => [],                            // bundled with Station0
    \Acme\Station0Sitemap\Plugin::class => [],   // from a Composer package
],

Redirects (station0 ≥ 0.10.1): "redirects" => [] turns on the bundled redirect manager. When an editor renames a page's slug or moves it in the tree, the old URL and every URL below it get a 301 to the new place automatically. You can add your own rules in site/content/_redirects.yaml or in the admin's Redirects tab. The tab also has a log of 404s (stored in SQLite) with filters and a suggested target for each path (station0 ≥ 0.10.2), so fixing broken links is one click. Each page's edit form lists the old URLs that lead to it. Details: docs/redirects.md.

A short name loads a plugin bundled with Station0. Plugins can add admin tabs, routes, console commands, and panels on the page edit form. php vendor/bin/console plugins:list shows what is enabled. Details: docs/plugins.md.

Command line

Run from the project root:

php vendor/bin/console user:create <username> <email> [admin|editor|member]   # role defaults to 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 thumbs:warm [baseUrl]      # pre-generate thumbnails (site must be running)
php vendor/bin/console thumbs:clear              # delete generated thumbnails
php vendor/bin/console collections:retitle [name] [--dry-run]   # rebuild computed collection titles (station0 ≥ 0.10)
php vendor/bin/console task:list                 # site tasks (site/tasks/)
php vendor/bin/console task:run <name> [--param=value]
php vendor/bin/console plugins:list              # enabled plugins, their commands (station0 ≥ 0.10)
php vendor/bin/console redirects:list            # redirects plugin: rules, test a URL, 404 log
php vendor/bin/console redirects:test /old/path
php vendor/bin/console redirects:404

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.4-fpm.sock;
}

Security headers: the admin (/admin/…) already sends headers that stop other sites from framing it (X-Frame-Options, Content-Security-Policy: frame-ancestors, plus Referrer-Policy and X-Content-Type-Options). Public pages get none from Station0. Add the ones you want in the web server, for example on Apache with mod_headers:

Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"

Backups: site/ holds your content, templates and redirect rules — keeping it in Git is the easiest backup. Back up writable/db.sqlite too: it holds the accounts.

What's in the database

Content never goes into the database. writable/db.sqlite (created automatically, needs pdo_sqlite) holds only runtime data:

Data Tables Since
Admin, editor and member accounts, password resets, login throttling users* always
Member sign-ins (pass cookies) and one-time sign-in links station0_passes, station0_login_links 0.9
Rate limits for sign-ins and site actions station0_throttle 0.9
Which plugin migrations have run station0_migrations 0.10
404 log and redirect hit counts (redirects plugin, only when enabled) station0_404, station0_redirect_hits 0.10.1

Losing it means re-creating accounts, and members sign in again; the content is unaffected.

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