lexislav / station0
Grand zero flat file CMS — core library.
Requires
- php: ^8.2
- ext-pdo_sqlite: *
- delight-im/auth: ^9.0
- league/commonmark: ^2.4
- monolog/monolog: ^3.5
- php-di/php-di: ^7.0
- phpmailer/phpmailer: ^7.0
- slim/csrf: ^1.3
- slim/psr7: ^1.6
- slim/slim: ^4.13
- slim/twig-view: ^3.3
- symfony/yaml: ^8.0
- twig/twig: ^3.8
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
- ext-exif: Correct orientation of rotated phone photos in thumbnails
- ext-gd: Image thumbnails (|thumb Twig filter); without it originals are served
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-27 13:33:49 UTC
README
Lightweight flat-file CMS built on PHP 8.2+. Pages are stored as plain text files on disk — no database migrations, fully Git-friendly. SQLite is used only for authentication.
Stack
- Framework: Slim 4
- Templates: Twig 3
- Markdown: League/CommonMark (GFM + front matter)
- Auth: Delight\Auth (SQLite)
- DI: PHP-DI 7
Requirements
- PHP ≥ 8.2 (with
pdo_sqliteextension) - Composer
Installation
composer create-project lexislav/get-station0 mysite
cd mysite
php -S localhost:8080 -t public public/index.php
On first visit to http://localhost:8080/admin, a setup form lets you create the first admin account in the browser.
Apache deployment
If you're serving via Apache (Homebrew httpd, XAMPP, system Apache, …):
- Point your vhost
DocumentRootat the project'spublic/directory. - Allow
.htaccessoverrides for that directory and ensuremod_rewriteis loaded.
Example vhost (Homebrew httpd at /opt/homebrew/etc/httpd/extra/httpd-vhosts.conf):
<VirtualHost *:80> ServerName mysite.local DocumentRoot "/path/to/mysite/public" <Directory "/path/to/mysite/public"> AllowOverride All Require all granted </Directory> </VirtualHost>
In httpd.conf make sure this line is uncommented:
LoadModule rewrite_module lib/httpd/modules/mod_rewrite.so
Restart: brew services restart httpd.
Symptom of misconfiguration: Apache's default "Not Found — The requested URL
was not found on this server." page on /admin (or any sub-path) means the
rewrite isn't being applied — check AllowOverride and mod_rewrite.
This package
lexislav/station0 is the core library — it contains the PHP classes, admin templates, and CLI binary. It is installed into vendor/ by the skeleton project.
For a ready-to-use project skeleton, see lexislav/get-station0.
Media & uploads
Uploaded assets are stored next to the page they belong to:
site/content/pages/about/
page.txt
team-photo.jpg ← uploaded asset
Filenames are slugified on upload (Team Photo.JPG → team-photo.jpg) and
collisions resolve as team-photo-2.jpg, -3, … Allowed types: jpg, png,
gif, webp, svg (max 8 MB).
In stored content, asset references are page-relative — bare filenames:
- type: gallery images: - src: team-photo.jpg alt: The team
At render time, the bare filename is resolved against the current page
(/media/about/team-photo.jpg). Move or rename the page directory and the
references still work — they were never absolute.
To migrate any older absolute /media/{path}/file.ext references in stored
pages to the new bare-filename form:
php vendor/bin/console assets:relink # apply php vendor/bin/console assets:relink --dry-run # preview
Public asset URLs follow the page URL: /media/{page-path}/{filename}.
The root page uses /media/~/{filename}.
Thumbnails
Resize images in templates with the thumb filter. It takes the resolved
/media/... URL and returns a URL to a smaller copy; the copy is generated with
GD on first request and cached in writable/cache/thumbs/.
<img src="{{ image.src|thumb(600) }}" {# max width 600 px #} srcset="{{ image.src|thumb_srcset([400, 800, 1200]) }}" sizes="(min-width: 900px) 33vw, 100vw" loading="lazy" alt=""> {{ hero|thumb(0, 400) }} {# max height 400 px #} {{ photo|thumb(300, 300) }} {# fit inside the box #} {{ photo|thumb(300, 300, 'cover') }} {# crop to fill the box #} {{ photo|thumb(600, format='webp') }} {# convert to WebP #}
- Images are never upscaled. External URLs, SVG, GIF, documents, missing files
and sizes that are not smaller than the original come back unchanged, so the
filter is safe to apply everywhere. Without
ext-gdit returns the original. - JPEG, PNG (with transparency) and WebP keep their format. EXIF rotation of
phone photos is applied (needs
ext-exif); EXIF metadata such as GPS is dropped from the thumbnail. - URLs are signed (
/thumb/600x0/{signature}/{page-path}/{file}) so nobody can request arbitrary sizes. The signing key is created inwritable/thumbs.key(override with'thumbs' => ['secret' => ...]insite/config.php). Replacing the source image changes the URL. - Markdown images in text blocks are thumbnailed automatically:
srcis capped at 1200 px with a 2x candidate insrcset, plusloading="lazy". - The admin editor shows 160 px previews instead of the originals.
Optional settings in site/config.php:
'thumbs' => [ 'format' => 'webp', // convert all thumbnails to WebP ('original' per call keeps the format) 'markdown' => 1200, // max width of markdown images; 0 = leave them alone 'static' => true, // write thumbnails to public/thumb/ so the web server serves them 'secret' => '…', // signing key; default: generated into writable/thumbs.key ],
With static, each thumbnail is written to the path of its own URL under
public/thumb/, so after the first request the web server sends the file
without starting PHP (the stock .htaccess, nginx try_files $uri … and
php -S … public/index.php all serve existing files first). A copy stays
reachable after its source image is deleted until thumbs:clear.
php vendor/bin/console thumbs:warm # pre-generate thumbnails of all published pages (server must run) php vendor/bin/console thumbs:clear # delete generated thumbnails
Per-template block restrictions
A template can restrict which block types its pages may use, and pre-seed a new page with starter blocks. Drop an optional manifest beside the template file:
site/templates/gallery.twig ← the template
site/templates/gallery.blocks.yaml ← its block manifest (optional)
# site/templates/gallery.blocks.yaml allowedBlocks: # the "+ Add block" palette shows only these, in order - text - gallery defaultBlocks: # a NEW page of this template opens with these inserted - gallery
Both keys are optional and compose:
allowedBlocks— restricts the editor palette. No declaration ⇒ all blocks are available (the default).defaultBlocks— pre-inserts blocks into new pages only, at their schema default field values. No declaration ⇒ a single emptytextblock. Pre-seeded blocks must be a subset of the allow-list.
Unknown block names are ignored, and an allow-list that filters down to nothing
falls back to the full palette. This mirrors how a page's AllowedChildTemplates
front-matter field restricts which child templates a page accepts — here it is
the block types inside a page that are restricted, keyed by template.
Select options from a collection
A select field — in a block schema, in a list item, or in a collection
schema — can take its options from a collection instead of a static list:
# site/templates/blocks/route/schema.yaml label: Route fields: from: type: select label: From options_from: collection:points # one option per published item group_by: river # optional: item field → <optgroup> sort_by: -km # optional: item field, "-" = descending option_label: "{title} (km {km})" # optional: {title}, {slug}, {<field>} placeholder: "— choose a point —" # optional, defaults to "—"
The stored value is the item slug; resolve it in the block template with
collection_item('points', block.from). Numeric sort values accept a decimal
comma (318,5); items without the field sort last. If a stored slug no longer
exists, the editor keeps it as a "(not found)" option instead of dropping it.
Letting the editor pick the collection
options_from: collections offers the items of every collection (or
collections:banners,shared-blocks for just those, in that order), one
<optgroup> per collection. The stored value is <collection>/<slug>:
{% set item = collection_item(block.ref) %} {# one-arg form: "banners/summer-sale" #}
{% if item %}{{ render_collection_item(item) }}{% endif %}
{collection} is available in option_label / sort_by next to the item fields.
Select options from pages
Relations between pages ("article → related article") use options_from: pages:
related: type: list label: Related articles item: page: type: select label: Article options_from: pages:/blog # descendants of /blog; plain "pages" = all template: article # optional: string or list of templates sort_by: -date option_label: "{title} ({path})"
Only published pages are offered. The stored value is the page's URL path;
page(value) returns the page (or null if it is missing or not published):
{% for rel in fields.related %}
{% set p = page(rel.page) %}
{% if p %}<a href="{{ p.urlPath }}">{{ p.title }}</a> {{ page_fields(p).subtitle }}{% endif %}
{% endfor %}
Label/sort/group fields: title, slug, path, template, sort, date,
parent, parent_title, plus the page's own fields. Renaming or moving a page
does not rewrite references to it — the editor then shows the old path as
"(not found)".
Static options also accept richer forms now — {value: label} maps and
[{value, label, group}] entries — next to the plain string list.
Menu groups (admin menu tabs)
Collections and page subtrees can be grouped into their own tab in the admin
menu. For a collection, it is one key in its _collection.yaml:
# site/content/collections/products/_collection.yaml label: Products group: Shop
Every collection with group: Shop moves out of the generic Collections tab
into a Shop tab. For pages, put Group: into the front matter of the page
that roots a section — the page and everything below it join the group:
# site/content/pages/downloads/page.txt
Title: Downloads
Group: Downloads
---
The Downloads tab then shows that subtree (drag & drop works as in the
Structure tree; a stream shows all its records). The pages stay in the
Structure tree too, marked with a group badge; grouped streams leave the
Streams tab. A nested page with its own Group: starts a separate group.
Pages and collections can share a group — Group: Shop on /shop and
group: Shop on the products collection make one tab with both. A group of a
single collection links straight to its items.
When a group needs more settings, declare it centrally (optional):
# site/content/_groups.yaml (site/content/collections/_groups.yaml still works) shop: # id — `group: shop` and `group: Shop` both match label: E-shop # overrides the inline label icon: "🛒" # text/emoji, or inline <svg …> markup roles: [editor] # who sees the tab; admins always do; omit = everyone
Tabs follow the order of _groups.yaml, then inline-only groups alphabetically.
A central group without any member produces no tab. For collections, roles
is enforced server-side too: the collection's list, forms, saves and uploads
return 403 for users without the role. For pages, roles only hides the tab;
the pages remain editable through the Structure tree.
CLI (via skeleton)
php vendor/bin/console user:create <username> <email> [role] php vendor/bin/console user:reset-password <email> php vendor/bin/console cache:clear php vendor/bin/console assets:relink [--dry-run]
Package structure
admin/templates/ Admin Twig templates
bin/console CLI binary (exposed via composer bin)
config/roles.php Role definitions
src/ PHP source (namespace Station0\)
License
MIT