Turn Pushword into a flat-file CMS: pages, media, snippets and conversations round-trip between Markdown/CSV files and the database.
composer require pushword/flat
In config/packages/flat.yaml. Only flat_content_dir can also be set per site, in the app configuration of config/packages/pushword.yaml.
pushword_flat:
flat_content_dir: '%kernel.project_dir%/content/_host_' # default; _host_ = the site's main host
# Optional shared secret for read-only snapshot downloads
content_snapshot_key: '%env(CONTENT_SNAPSHOT_KEY)%'
# Change detection cache TTL in seconds (default: 300)
change_detection_cache_ttl: 300
# Auto-export to flat files after admin modifications (default: true)
auto_export_enabled: true
# Git commit and push content changes after export (default: false)
auto_git_commit: false
# Debounce delay in seconds before processing deferred export (default: 120)
export_debounce_delay: 120
# Editorial lock TTL in seconds (default: 1800)
lock_ttl: 1800
# Webhook lock TTL in seconds (default: 3600)
webhook_lock_default_ttl: 3600
# Auto-lock when flat files are modified (default: true)
auto_lock_on_flat_changes: true
# File basenames never imported as pages (default below)
exclude_files: ['AGENTS.md', 'CLAUDE.md', 'README.md']
# Custom property names to exclude from flat file export and import (default: [])
ignored_properties: ['someTransientProp']
# Who a page created by an import belongs to when its file names no editor
# (default: null, meaning the site's first super admin)
default_editor: editor@example.tld
# Conflict and sync error emails (default: none)
notification_email_recipients: ['admin@example.tld']
notification_email_from: noreply@example.tld
A pw:flat:sync run has no authenticated user, so the editor comes from the file. An export writes it there as an email:
editedBy: editor@example.tld
createdBy: editor@example.tld
On import those resolve back to the matching user. An email no user answers to is ignored — the page keeps whichever editor it already had.
A file naming nobody is attributed, at creation only, to default_editor or failing that the site’s first super admin. Later syncs of that file leave the editor alone (editMessage already records that the edit came from pw:flat:sync). With no user to resolve, the page stays unattributed.
Set content_snapshot_key to let an automated client download the current Markdown mirror without a Pushword user. Keep the secret in an environment variable and send it in the dedicated header:
curl -H "X-Pushword-Snapshot-Key: $CONTENT_SNAPSHOT_KEY" \
"https://example.com/api/content/snapshot.tar.gz?host=example.com" \
--output snapshot.tar.gz
Omit host to download every configured site. The key is accepted only by GET /api/content/snapshot.tar.gz; it cannot authenticate any other route or write content. Editor Bearer tokens are accepted too.
A HEAD request (curl -I) checks a key: same status as GET, without re-exporting the mirror.
php bin/console pw:flat:sync [host] [options]
| Option | Description |
|---|---|
host | Host to sync; omit to sync every configured host |
--mode, -m | Sync direction: auto (default), import, export |
--entity | page, media, conversation, snippet, user, all (default) |
--page | Slug(s) to sync — repeatable, implies --entity=page (see targeted sync below) |
--force, -f | Force overwrite even if files are newer than DB |
--backup | Back up SQLite before import (server databases require their native tools) |
--consume-pending | Consume pending export flag and run batched export |
--format | auto (default), agent (JSON) or text — see agent output |
# Auto-detect: imports if flat files are newer, exports if DB is newer
php bin/console pw:flat:sync
# Force a direction
php bin/console pw:flat:sync --mode=import
php bin/console pw:flat:sync --mode=export
# Only pages on a specific host
php bin/console pw:flat:sync example.tld --mode=import --entity=page
# Import after creating an SQLite backup
php bin/console pw:flat:sync --mode=import --backup
pw:flat:watch polls the content directory and syncs on change (--interval, default 0.5 s; --mode=import to never export). With --live-reload, pages served in debug mode reload in the browser after each sync.
Each exported .md carries a revision: <hash> # read only front matter line — the API’s ETag / If-Match value, so an agent can PUT the file back without a preliminary GET. It is written on export and ignored on import.
A normal export skips unchanged files, so a file missing the line is not re-stamped by a plain sync. Force a full-host export to rewrite them (files already correct stay byte-identical):
php bin/console pw:flat:sync example.tld --mode=export --force
--force combined with --page does not re-stamp.
--page)Restrict the sync to specific slugs — much faster on a large multi-site setup:
php bin/console pw:flat:sync example.tld --page=about --page=contact --mode=import
Differences from a full sync:
deleteMissingPages is skipped: pages absent from the filter are never deleted, even if their .md file is missingredirection.csv) are not re-imported--force re-imports only the targeted pages regardless of timestamps; it does not reset the hostAn admin save dispatches a Messenger message delayed by export_debounce_delay. Each new save resets the timer, so rapid edits are batched into a single export (and commit). A worker must be running:
php bin/console messenger:consume async -v
Or consume pending exports manually:
php bin/console pw:flat:sync --consume-pending
With auto_git_commit: true, each batched export is committed, pulled and pushed. The content directory (or its parent) must be a git repository.
The lock prevents concurrent modifications between flat files and the admin.
# Acquire a lock (shows warning in admin)
php bin/console pw:flat:lock [host] [--ttl=1800] [--reason="Editing flat files"]
# Release the lock
php bin/console pw:flat:unlock [host]
auto_lock_on_flat_changes)pw:flat:lock, for extended editing sessionslock_ttl, 30 minutes by defaultFor CI/CD and external systems. Webhook locks are stricter than manual locks: they block admin saves entirely. Authentication uses the user’s apiToken as a Bearer token.
| Endpoint | Method | Description |
|---|---|---|
/api/flat/lock | POST | Acquire a webhook lock |
/api/flat/unlock | POST | Release a webhook lock |
/api/flat/status | GET | Check lock status |
webhook_lock_default_ttl (1 hour)AccessDeniedHttpExceptionpw:flat:sync is blocked entirely while a webhook lock is activeRequest examples, CI pipelines and the status response: Git workflow.
When a page’s file and its database row were both modified since the last sync, the most recent wins:
page~conflict-{id}.md, with a comment headernotification_email_recipients# List and clear conflict backup files
php bin/console pw:flat:conflicts:clear [host] [--dry]
Invalid YAML in a .md file (e.g. an unescaped quote: title: 'La Baltique : d'Usedom') is a common authoring mistake. Check before syncing:
php bin/console pw:flat:lint [host]
Exit code 0 if all files are valid, 1 otherwise; each error shows the file path and line.
During pw:flat:sync, a file with invalid YAML is skipped with its error printed, its DB page is not deleted, and a final warning counts the skipped files.
| Mistake | Fix |
|---|---|
title: 'It's broken' | Use double quotes: title: "It's fine" |
title: A: B (unquoted colon) | Quote the value: title: 'A: B' |
Smart quotes ' from copy-paste | Replace with straight quotes ' or " |
var/; a stale one is cleaned up)host argument, every configured host is synced sequentially, each with its own content directory, lock and sync stateauto runs freshness detection then delegates to import or exportPages: if any .md file’s mtime is newer than its page’s updatedAt, or a .md file has no page, the sync imports; otherwise it exports. Other files (.txt, .csv…) are ignored.
Media: if any file’s SHA-1 hash differs from the stored hash, or a file has no media entity, the sync imports; otherwise it exports.
redirection.csv is imported before pages.md files are parsed (YAML frontmatter + body)parentPage, translations, extendedPage are resolved after all pages exist.md file AND no matching redirection.csv row are deletedindex.csv / index.draft.csv are regenerated from the databaseindex.csv is read-only — .md files are the source of truth*.md~) are ignored--force (without --page), ALL host pages are deleted before importingpublishedAt and translations keep their explicit reset syntax.publishedAt: draft maps to null (unpublished).md file with YAML frontmatterredirection.csv (their .md files are deleted)index.csv, drafts in index.draft.csvmtime is set to page.updatedAt so the next auto run does not see it as newerredirectFrom)Like Jekyll’s redirect_from, a page declares the old paths that redirect to it:
---
h1: CMS comparison
redirectFrom:
cms-comparison: 301
old/comparison: 302
---
{ oldPath: httpCode } map. A bare list (- cms-comparison) is accepted on import as 301. Paths are scoped to the page’s host..htaccess, Caddyfile, meta-refresh stubs for GitHub Pages), like redirection.csv entries. Each matches its exact path, trailing slash optional: old never redirects old/child.redirectFrom.[x](/old-name) → <a href="/new-name">), with no 301 hop.redirection.csv keeps the redirects with no destination page: external targets, non-resolving paths, and chains.Convert a site’s existing internal redirect pages into redirectFrom (database-level, with or without flat sync):
php bin/console pw:redirect:migrate [host] [--dry-run]
media.csv, read from the content base dir (shared by all hosts)fileName is NOT in the CSV are deleted, with their file (only when the CSV holds at least one row){content_dir}/media/ are copied to storage then importedmedia.csv is regenerated from the databaseWhile importing a file:
fileNameHistory is updatedalt, tags, alt_* and custom properties are updated when they differ.~lock.*, ~$*) — always skippedmedia.csv lives in the content base dir (next to the per-host directories): media are global, not owned by a host.
fileName,alt,tags,width,height,ratio,fileNameHistory,updatedAt,alt_en,alt_fr
image.jpg,Base alt,photo,800,600,1.33,old-image.jpg,2025-01-31 14:02:11,English alt,French alt
doc.pdf,A document,document,,,,,2025-01-30 09:11:40,,
fileName identifies the row — there is no id column; a duplicate row is skipped (first wins)alt, tags, alt_* (localized alts, detected by locale suffix) and any extra column (stored as a custom property) are imported backwidth, height, ratio, fileNameHistory and updatedAt are read-only: derived from the file, never importedUser sync is opt-in: it runs only when config/users.yaml exists (with pw:flat:sync, or alone with pw:flat:user-sync).
users:
- email: admin@example.tld
roles: [ROLE_SUPER_ADMIN]
locale: en
username: Admin
config/users.yaml to disable user sync: no user is created, updated or deletedRunning sync twice in a row with no changes produces zero operations: page import skips files older than updatedAt, page export skips unchanged content, media import skips matching hashes, and the regenerated media.csv is identical.
Pass --backup to back up the SQLite database before an import:
var/app.db~YYYYMMDDHHMMSS; the ten most recent are keptvar/app.dbphp bin/console pw:backup --clean keeps only the newest backup immediatelyThe option fails before importing on PostgreSQL or MariaDB: back up with the database server’s own tools, then sync without --backup.
An import matches pages on host and slug, so files imported under a host the database does not know yet become new pages, with new ids, while the old host’s rows stay behind. Rename in the database first:
host column from the old host to the new one in page and in every other table carrying one (snippets, conversation messages, quiz results, social posts, the version log…), plus the newsletter’s main_host, optin_host and JSON hosts columns.config/packages/pushword.yaml and rename its content directory (content/<old host>/ by default) and its template overrides (templates/<old host>/), if any. On a multi-host site, also rewrite the <old host>/<slug> entries in the other hosts' translations front matter: an import that cannot resolve one drops the link.var/flat-sync/<host>*.json (dots become underscores): a stale state decides the direction of the next sync.pw:flat:sync <new host>.A static build then writes to a new directory, since the default static_dir is static/{main_host}: the old one stays behind.
vendor/bin/pushword-deployThe bundle ships a site-agnostic deploy script. Per-site specifics — remote, SSH options, excludes, local and remote command chains — live in a deploy.conf at the site root, found upward from wherever you run it:
vendor/bin/pushword-deploy pull # prod -> local
vendor/bin/pushword-deploy push # full deploy: local prep, rsync, remote build
vendor/bin/pushword-deploy publish # content/ + media/ only, no composer/build
vendor/bin/pushword-deploy pull-db # SQLite only: fetch prod var/app.db (backs up local first)
# -n / --dry-run everywhere; SQLite only: --ship-db for the one-time rc802 migration
The push always excludes var/app.db* and var/flat-sync/, whatever the configured excludes say: production owns its database (messages, quiz results, newsletter contacts, admin edits between two pulls) and each machine owns its sync state. --ship-db (confirmation required) sends the database file explicitly — only for the one-time uuid migration.
pull-db and --ship-db are SQLite-only. On PostgreSQL or MariaDB, pull, push and publish leave the server database untouched; use its native dump/restore tools.
With DELETE=1, the push first probes (--dry-run, same excludes) what --delete would remove on production. Files that exist only there are usually prod-side work not yet pulled, so the push lists them and asks for confirmation before removing anything. rsync has no conflict detection — the last machine to sync wins — so this probe is what makes --delete safe to keep on.
DELETE=1 applies to pull too, with no probe: the pull removes every local file production lacks, outside PULL_EXCLUDES. A site keeping local-only files in the synced tree (notes, scratch databases) must list them in PULL_EXCLUDES or stay at DELETE=0. At DELETE=0, a page deleted locally keeps its .md on production, so production never deletes it: remove the file there by hand.
Minimal deploy.conf:
REMOTE="user@server"
REMOTE_PATH="/home/user/mysite.com"
# SSH_OPTS="-p 5022" # DELETE=1 for rsync --delete
# PULL_EXCLUDES / PUSH_EXCLUDES # arrays, sensible defaults provided
# PRE_PUSH=('php bin/console pw:flat:sync')
REMOTE_DEPLOY='composer update && php bin/console doctrine:schema:update --force && bin/console cache:clear && php bin/console pw:flat:sync && php bin/console pw:static'
# PUBLISH_PATHS=('content' 'media') # REMOTE_PUBLISH, POST_DEPLOY_LOCAL
publish runs pw:flat:sync && pw:static --incremental remotely by default: the import moves the render epoch for anything listing-relevant, and the incremental build regenerates just that — including pruning the pages the sync deleted. Keep a plain pw:static at the end of REMOTE_DEPLOY: after cache:clear incremental would rebuild everything anyway, and the full build’s lint-before-swap protects the new code.
POST_DEPLOY_LOCAL (typically an opcache-reset loop over the PHP hosts) runs even when the push dies mid-remote-chain, since a deploy failing after composer update has already rebuilt the container. Only a push aborted at a confirmation prompt — before anything touched production — skips it. publish never runs it.
The default excludes keep every generated output out: public/assets/ (yarn build), public/bundles/ (assets install) and public/media/ (pw:image:cache) are rebuilt by the remote chain, like static/. A site that does not rebuild one of them server-side overrides PUSH_EXCLUDES without it.
pw:ai-index writes pages.csv and medias.csv for AI tools, into flat_content_dir by default. See AI content index.
With the default flat_content_dir, a site’s pages live in content/{main_host}/. Images go in content/{main_host}/media/ or in the storage directory media/ at the project root; both are scanned during import.
content/example.tld/homepage.md
content/example.tld/kitchen-sink.md
content/example.tld/en/homepage.md
content/example.tld/en/kitchen-sink.md
content/example.tld/media/illustration.jpg
kitchen-sink.md:
---
h1: 'Welcome in Kitchen Sink'
locale: fr
translations:
- en/kitchen-sink
mainImage: illustration.jpg
parentPage: homepage
metaRobots: 'noindex'
name: 'Kitchen Sink'
title: 'Kitchen Sink - best google result'
tags: 'demo example'
publishedAt: '2025-01-15 10:00'
---
My Page content Yeah !
parentPage = parent_page); parent is normalized to parentPage.md, unless a slug property overrides it; links to pages use slugsindex.md or homepage.mdpublishedAt: draft sets the page as unpublished (null in DB)customPropertiesmainImage references a media filename (not a path)The export writes publishedAt and holdPublicationAt in the editorial timezone, with its offset: publishedAt: '2026-09-30 16:00+02:00'. The offset pins the instant, so a file that goes out and comes back never shifts.
publishedAt: 2026-09-30 reads like publishedAt: '2026-09-30'.2026-10-25 02:30 in Paris) is its first occurrence, summer time: add +01:00 for the second. An hour the spring change skips moves forward by an hour.The editorial timezone defaults to the server’s (PHP’s date.timezone, UTC on most hosts). Set it to read and write dates on your editors' clock — the admin’s date fields, its publish-now and schedule buttons and its list columns follow it too:
pushword:
editorial_timezone: Europe/Paris
The database stays in the server timezone: leave date.timezone alone. On an existing site, set the key last: pw:flat:sync to take in pending file edits, then pw:flat:sync --mode=export --force so every date carries its offset. A date left without one shifts on its next import.
translations links pages across locales, in both directions:
translations key = no change: existing links are preserved.translations: [] removes all links of that page.# fr/about.md — links en/about
---
translations:
- en/about
---
# en/about.md — no translations key, so the link above stands
---
h1: About Us
---
To remove a translation, set translations: [] in both files, or drop it from one file while the other has no translations key.
What happens on pw:flat:sync --mode=import:
.md file — a page is created and listed in index.csv (or index.draft.csv if publishedAt: draft)..md file — the page is updated (the file’s mtime must be newer than the page’s updatedAt)..md file — the page is deleted from the database..md file — the old page is deleted and a new one created with the new slug. To keep the page, set the slug property instead.index.csv — nothing: it is regenerated from the database. Edit .md files instead.redirection.csv — redirections are imported; pages matching a redirection slug become redirects.media/ (content/{host}/media/ or the storage media/) — imported as a new media and added to media.csv.fileNameHistory is updated.media.csv.alt, tags, an alt_* or a custom column in media.csv — the media is updated, though the file never changed. This is the flat-file way to fix an alt text.fileName in media.csv — do not: the row no longer matches a file, and the media still carrying the old name is deleted together with its file. Rename the file on disk instead.media.csv — the media and its file are deleted.New images get the same pipeline as an admin upload:
PDF optimization (Ghostscript + qpdf) is not triggered by flat import. Run it manually:
php bin/console pw:image:cache # responsive variants + WebP, all or updated media
php bin/console pw:image:optimize # lossless image compression
php bin/console pw:pdf:optimize # requires ghostscript and/or qpdf