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
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) |
--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
# Export without adding IDs to files
php bin/console pw:flat:sync --mode=export
# Import without creating a database backup
php bin/console pw:flat:sync --mode=import --no-backup
When 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)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 / iDraft.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, ALL host pages are deleted before importing (fresh start)publishedAt: draft in frontmatter maps to null (unpublished).md file with YAML frontmatterredirection.csv (their .md files are deleted)index.csv, drafts in iDraft.csvmtime is synced to page.updatedAt to prevent false freshness detection on next auto runfileName 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 iDraft.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