lexislav / get-station0
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
Requires
- lexislav/station0: ^0.10.3
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
- What's inside
- Content: pages
- Templates
- Blocks (the page builder)
- Template manifest: fields, blocks, restrictions
- Streams (blogs, news, listings)
- Collections (headless content)
- Media & uploads
- Field types reference
- Twig reference
- Users & roles
- Members-only access
- Site actions (forms)
- Site tasks & hooks
- Configuration
- Command line
- Deployment
- Updating Station0
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
- Open http://localhost:8080. The demo site is there.
- 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.
- 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 --- fieldsin Twig are typed: booleans aretrue/false, numbers are numbers, andimage/filevalues 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
defaultvalues. - 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 prefersnake_casenames.
Allowed and default blocks
allowedBlockslimits the palette, in the order you list. Unknown names are ignored.defaultBlockspre-inserts blocks, with their schema defaults, into a new page. Each must be part ofallowedBlocksif 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
/blogcan only use thearticletemplate (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.titleworks 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.yamlmoves 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 ownGroup: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 (
filefields): 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:warmgenerates thumbnails for all published pages in advance (the site must be running).thumbs:cleardeletes 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. $taskgives youparam(),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 withoutexec()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
- Upload the project (or
git pullit) and runcomposer install --no-dev. - Point the web server's document root at
public/. Nothing outside it should be publicly reachable. - Make
writable/andsite/content/writable by the web server (the admin writes content files and uploads). - In
.env: setDEBUG=false, the realBASE_URL, andHTTPS=trueif applicable. - Visit
/adminto create the first admin, or useuser: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