Transform Pushword in a FlatFile CMS.
composer require pushword/flat
Globally under flat: (in config/packages/flat.yaml).
Or for multi-sites in config/packages/pushword.yaml under the app configuration.
flat:
flat_content_dir: content # default value
# Change detection cache TTL in seconds (default: 300 = 5 minutes)
change_detection_cache_ttl: 300
# Auto-export to flat files after admin modifications (default: true)
auto_export_enabled: true
# Automatically git commit 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 = 30 minutes)
lock_ttl: 1800
# Auto-lock when flat files are modified (default: true)
auto_lock_on_flat_changes: true
# Custom property names to exclude from flat file export and import (default: [])
ignored_properties: ['someTransientProp']
php bin/console pw:flat:sync [host] [options]
Options:
| Option | Description |
|---|---|
host | Optional host to sync (uses default app if not provided) |
--mode, -m | Sync direction: auto (default), import, export |
--entity | Entity type: page, media, conversation, 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 |
--no-backup | Disable automatic database backup before import |
--consume-pending | Consume pending export flag and run batched export |
Examples:
# Auto-detect: imports if flat files are newer, exports if DB is newer
php bin/console pw:flat:sync
# Force import (flat files → database)
php bin/console pw:flat:sync --mode=import
# Force export (database → flat files)
php bin/console pw:flat:sync --mode=export
# Sync only pages on a specific host
php bin/console pw:flat:sync example.tld --mode=import --entity=page
# Sync a single page (targeted sync — much faster on large multi-site setups)
php bin/console pw:flat:sync example.tld --page=about
# Sync multiple specific pages
php bin/console pw:flat:sync example.tld --page=about --page=contact
# Import without creating a database backup
php bin/console pw:flat:sync --mode=import --no-backup
# Re-stamp the `revision:` front matter on every page (see note below)
php bin/console pw:flat:sync example.tld --mode=export --force
Each exported .md ends with a revision: <hash> # read only line in its front matter — the content hash that mirrors the API's ETag / If-Match value, so an agent can read it from the file and PUT back without a preliminary GET. It is written on export and ignored on import.
A normal export skips files whose content is unchanged (and whose mtime is newer than the DB row), so pages exported before the stamp existed — or any file missing the line — are not re-stamped by a plain sync. Run a full-host force export to rewrite them:
php bin/console pw:flat:sync example.tld --mode=export --force
--force bypasses the mtime fast-path and regenerates content (now including the stamp); files already carrying the correct revision are left byte-identical. Note: --force combined with --page does not re-stamp — the targeted-export path skips the force flag (see below).
--page)When you modify a single .md file in a large multi-site setup, scanning the full content directory of every host is wasteful. Pass --page to restrict the sync to specific slugs:
php bin/console pw:flat:sync example.tld --page=about --page=contact --mode=import
Behavior differences vs. 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 during a targeted sync--force does not reset all host pages when --page is set; it only re-imports the targeted pages regardless of timestampsWhen content is saved in admin, a Messenger message is dispatched with a configurable delay (default: 120 seconds). Each new save resets the timer, so rapid edits are batched into a single export + commit.
A Symfony Messenger worker must be running to process exports:
php bin/console messenger:consume async -v
You can also consume pending exports manually via CLI:
php bin/console pw:flat:sync --consume-pending
To enable automatic git commits (and push) after export:
flat:
auto_git_commit: true
The content directory (or its parent) must be a git repository for auto-commit to work.
The lock system prevents concurrent modifications between flat files and admin interface, inspired by LibreOffice's locking mechanism.
# 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]
How it works:
pw:flat:lock for explicit control during extended editing sessionsFor CI/CD workflows and external systems, a REST API allows managing locks programmatically. Webhook locks are stricter than manual locks: they block admin saves entirely (not just a warning).
Authentication uses a Bearer token stored in the user's apiToken field.
| 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 |
Examples:
# Acquire a lock (default TTL: 1 hour)
curl -X POST https://example.com/api/flat/lock \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{"host": "example.com", "reason": "Bulk update", "ttl": 7200}'
# Release a lock
curl -X POST https://example.com/api/flat/unlock \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{"host": "example.com"}'
# Check status
curl "https://example.com/api/flat/status?host=example.com" \
-H "Authorization: Bearer {api_token}"
Key behaviors:
AccessDeniedHttpExceptionpw:flat:sync is blocked entirely when a webhook lock is activeWhen both flat files and database are modified since the last sync, a conflict occurs. The system uses a "most recent wins" strategy:
filename~conflict-{id}.md# List and clear conflict backup files
php bin/console pw:flat:conflicts:clear [host] [--dry]
Conflict backup files:
page~conflict-abc123.md (contains the losing version with a comment header)index.conflicts.csv (appends conflict details for media/conversation)Invalid YAML in a .md file (e.g. an unescaped quote: title: 'La Baltique : d'Usedom') is a common authoring mistake.
Proactive check before syncing:
php bin/console pw:flat:lint [host]
Returns exit code 0 if all files are valid, 1 if any errors are found. Each error shows the file path and line number.
During pw:flat:sync:
pw:flat:lintCommon YAML pitfalls:
| Mistake | Fix |
|---|---|
title: 'It's broken' | Use double quotes: title: "It's fine" |
title: 'A: B' unquoted colon | Already quoted — but inner single quote breaks it: title: "A: B" |
Smart quotes ' from copy-paste | Replace with straight quotes ' or " |
When pw:flat:sync runs, it follows this pipeline:
var/)var/app.db is copied to var/app.db~YYYYMMDDHHMMSS (disable with --no-backup)host argument, syncs ALL configured hosts sequentiallyauto runs freshness detection then delegates to import or exportIn auto mode (the default), the system decides whether to import or export:
For pages: scans all .md files recursively. If any file's mtime is newer than its matching page's updatedAt in the database, or if a .md file has no corresponding page, it triggers import. Otherwise it triggers export.
For media: compares SHA-1 file hashes against stored hashes in the database. If any file's hash differs from the DB, or if a file has no matching media entity, it triggers import. Otherwise it triggers export.
Non-.md files (.txt, .csv, etc.) do NOT influence page auto-detection. Only .md files are considered.
var/ when sync startspw:flat:sync is already running (PID file exists and process is alive), the command exits immediatelyfinally block after sync completesredirection.csv is loaded and 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 to reflect DB stateImportant behaviors:
index.csv is read-only during import — editing it has no effect; .md files are the source of truth*.md~) are ignored during import--force (without --page), ALL host pages are deleted before importing (fresh start); combined with --page, only the targeted pages are re-imported without resetting otherspublishedAt: draft in frontmatter maps to null (unpublished).md file with YAML frontmatterredirection.csv (their .md files are deleted)index.csv, drafts in index.draft.csvmtime is synced to page.updatedAt to prevent false freshness detection on next auto runredirectFrom)Inspired by Jekyll's redirect_from, a page can declare the old paths that should redirect to it, right next to its content — instead of standalone records in redirection.csv:
---
h1: CMS comparison
redirectFrom:
cms-comparison: 301
old/comparison: 302
---
{ oldPath: httpCode } map. A Jekyll-style bare list (- cms-comparison) is accepted on import and treated as 301. Paths are host-scoped (same host as the page)..htaccess, Caddyfile, and the HTML meta-refresh stub for GitHub Pages), exactly like redirection.csv entries.redirectFrom (no phantom redirect page is created).[x](/old-name) → <a href="/new-name">), so they target the page directly instead of relying on a 301 hop — mirroring how a renamed media is resolved by its fileNameHistory.redirection.csv still holds redirects that have no destination page: external targets, non-resolving paths, and chains.To convert a site's existing internal phantom redirects into redirectFrom (database-level, works with or without flat sync):
php bin/console pw:redirect:migrate [host] [--dry-run]
fileName for an existing ID, the file is renamed in storagefileNameHistory is updated{content_dir}/media/ are copied to storage then importedmedia/index.csv is regenerated to reflect DB statepreRemove listener.prepareFileRenames()..~lock.*, ~$*) — always skipped, never imported.fileNameHistory is updated. No new entity created.id,fileName,alt,tags,width,height,ratio,fileNameHistory,alt_en,alt_fr
1,image.jpg,Base alt,photo,800,600,1.33,old-image.jpg,English alt,French alt
2,doc.pdf,A document,document,,,,,
fileNameHistory: comma-separated previous filenames (for rename tracking)alt_* columns: localized alt texts, auto-detected by locale suffixUser sync is opt-in: it only activates when config/users.yaml exists. If the file is missing, user sync is skipped entirely.
users:
- email: admin@example.tld
roles: [ROLE_SUPER_ADMIN]
locale: en
username: Admin
config/users.yaml does not exist, no users are created, updated, or deletedRunning sync twice in a row with no changes produces zero operations:
mtime is older than page's updatedAt are skippedBefore any import operation, the SQLite database is backed up:
var/app.db~YYYYMMDDHHMMSS--no-backupvar/app.dbWhen running without --host:
php bin/console pw:ai-index [host] [exportDir]
Generate two CSV files (pages.csv and medias.csv) with metadata useful for AI tools.
Where:
host is optional (uses default app if not provided)exportDir is optional (uses flat_content_dir by default)pages.csvContains page metadata with the following columns:
slug - Page slugh1 - Page H1 titlecreatedAt - Creation date (Y-m-d H:i:s)tags - Page tagssummary - Page summary/excerptmediaUsed - Comma-separated list of media files used in the pageparentPage - Parent page slug (if any)pageLinked - Comma-separated list of page slugs linked in the contentlength - Content length in charactersmedias.csvContains media metadata with the following columns:
media - Media filenamemimeType - MIME typename - Media nameusedInPages - Comma-separated list of page slugs using this mediaBy default, the content is organized in content/{main_host}/ and images can be placed in either content/{main_host}/media/ or in the storage directory media/ (at project root). Both locations are scanned during import.
Example structure:
content/
content/homepage.md
content/kitchen-sink.md
content/other-page.md
content/en/homepage.md
content/en/kitchen-sink.md
content/media/illustration.jpg
kitchen-sink.md example:
---
h1: 'Welcome in Kitchen Sink'
locale: fr
translations:
- en/kitchen-sink
mainImage: illustration.jpg
parentPage: homepage
metaRobots: 'no-index'
name: 'Kitchen Sink'
title: 'Kitchen Sink - best google result'
tags: 'demo example'
publishedAt: '2025-01-15 10:00'
---
My Page content Yeah !
Key points:
parentPage and parent_page are equivalent)parent is automatically normalized to parentPage.md) and can be overridden with a slug property in the frontmatterhomepage can be named index.md or homepage.mdpublishedAt: draft sets the page as unpublished (null in DB)customPropertiesmainImage references a media filename (not a path)The translations property handles the bidirectional many-to-many relationship between pages for internationalization (hreflang).
Key behaviors:
translations property, existing translations in the database are preserved unchanged.translations: [] to explicitly remove all translations from a page.Examples:
# In fr/about.md - adds en/about as translation
---
translations:
- en/about
---
# In en/about.md - no translations key, existing links preserved
---
h1: About Us
---
With this setup, both pages will be linked as translations of each other after sync.
To remove a translation, you must explicitly set an empty array in both files, or remove the translation from one file while the other file doesn't have a translations key (letting the removal propagate).
Here is what happens for typical editing workflows after running pw:flat:sync --mode=import:
.md file — a new page is created in the database. It appears in index.csv (or index.draft.csv if publishedAt: draft)..md file — the page is updated in the database (the file's mtime must be newer than the page's updatedAt)..md file — the page is deleted from the database. Its row is removed from index.csv..md file — the old page is deleted and a new one is created with the slug derived from the new filename. To keep the same page, use the slug property in frontmatter instead.index.csv — nothing. index.csv is read-only during import. It is regenerated after every import to reflect the database state. Edit .md files instead.redirection.csv — redirections are imported. Pages matching a redirection slug are converted to redirects.media/ (either content/{host}/media/ or the storage directory media/ at project root) — the image is imported as a new media entity. If it exceeds 1980x1280 pixels, it is automatically resized down. A new row appears in media/index.csv after sync.fileNameHistory is updated. No new entity is created.media/index.csv on next sync.media/index.csv — change alt or tags — the media entity is updated with the new values.media/index.csv — change fileName (same ID) — the file is automatically renamed in storage to match the new name.media/index.csv — remove a row — the media entity is deleted from the database and the physical file is also deleted.media/index.csv — add a row for an existing file — the file is imported as a new media entity with the provided metadata. If the file does not exist on disk, the row is silently ignored and removed from the regenerated CSV.config/users.yaml — enables user sync (opt-in). Without this file, no users are touched.config/users.yaml — a new user is created (without password — use magic link auth to set one).config/users.yaml — disables user sync entirely. No users are created, updated, or deleted.When importing new images, flat import applies the same optimization pipeline as admin upload:
Not automatic: PDF optimization (Ghostscript compression + qpdf linearization) is not triggered during flat import. Use the commands below.
# Regenerate image cache (responsive variants + WebP) for all or updated media
php bin/console pw:image:cache
# Optimize images (lossless compression)
php bin/console pw:image:optimize
# Optimize PDFs (requires ghostscript and/or qpdf)
php bin/console pw:pdf:optimize