A single-file CLI utility for syncing MODX Revolution elements — chunks, snippets, plugins, templates, and TVs — with files on disk. Keeps your elements in version control and lets you edit them in a real editor instead of the manager.
Each element type maps to a folder under _elements/. The script walks those folders and creates or updates the corresponding rows in the MODX database. By default, imported elements are registered as static elements linked back to their source files, so MODX reads the file on every parse — no reimport needed after a code change.
Three subcommands cover the full workflow:
import— files → DBexport— DB → fileswatch—importonce, then auto-reimport on file change
- MODX Revolution 3.0+
- PHP 8.0+
- CLI access to the server hosting MODX
Drop modx-elements.php into the same directory as your config.core.php (typically the MODX web root). No Composer install, no namespace setup — the script will call MODX through the existing autoloader.
php modx-elements.php export --no-overwrite
git add _elements && git commit -m "Seed elements"
php modx-elements.php watchExport pulls every element out of the DB, watch starts the dev loop. From here, editing any file in _elements/ flows into MODX automatically.
mkdir -p _elements/{chunks,snippets,plugins,templates,tvs}
# add files...
php modx-elements.php import_elements/
├── chunks/{name}.html + optional {name}.json
├── snippets/{name}.php + optional {name}.json
├── plugins/{name}.php + {name}.json (events go here)
├── templates/{name}.html + optional {name}.json
└── tvs/{name}.json (TVs are metadata-only — no code file)
The filename basename becomes the element's name (or templatename for templates). The optional sidecar JSON holds metadata that doesn't fit in the source body.
All keys are optional unless noted. Unknown keys are ignored.
{
"description": "Short summary shown in the manager",
"category": "Parent/Child/Grandchild",
"properties": [
{
"name": "propertyName",
"desc": "lexicon key or plain text",
"xtype": "textfield",
"value": "default value",
"lexicon": ""
}
],
"events": ["OnDocFormSave", "OnWebPagePrerender"],
"type": "text",
"caption": "Display Label",
"default_text": "",
"display": "default",
"input_options": "",
"templates": ["BaseTemplate"]
}| Key | Applies to | Notes |
|---|---|---|
description |
all types | Free text |
category |
all types | Slash-separated path; auto-creates missing nodes |
properties |
all types | Default property array, MODX standard format |
events |
plugins | Event names; unknown events are skipped with a warning |
type |
TVs | text, textarea, richtext, image, file, etc. |
caption |
TVs | Defaults to the filename if omitted |
default_text |
TVs | Default value |
display |
TVs | Output modifier (e.g. default, image, delim) |
input_options |
TVs | Stored as elements column — values for listbox / radio TVs |
templates |
TVs | Template names to attach the TV to |
_elements/plugins/AutoTagger.json:
{
"description": "Applies default tags to new resources",
"category": "MySite/Hooks",
"events": ["OnDocFormSave", "OnBeforeDocFormSave"],
"properties": [
{"name": "tagPrefix", "desc": "", "xtype": "textfield", "value": "auto-", "lexicon": ""}
]
}_elements/tvs/heroImage.json:
{
"type": "image",
"caption": "Hero Image",
"description": "Banner image at the top of the page",
"category": "MySite/Media",
"templates": ["BaseTemplate", "LandingTemplate"]
}Reads _elements/ and creates or updates matching rows. Existing elements are matched by name and updated in place; new ones are created.
php modx-elements.php import [--dry-run] [--no-static]| Flag | Effect |
|---|---|
--dry-run |
Report what would change without writing to the DB |
--no-static |
Copy file content into the DB instead of linking. Element behaves as a normal DB-resident element |
Plugin events are wiped and re-synced from the JSON on every import — the file is the source of truth.
Walks the DB and writes elements out to _elements/, creating missing subfolders. Categories are resolved to their full slash path. Plugin events, properties, and TV-template attachments are recorded in the sidecar.
php modx-elements.php export [--type=KIND] [--category=PATH] [--no-overwrite]| Flag | Effect |
|---|---|
--type=... |
Limit to one of chunks, snippets, plugins, templates, tvs |
--category=... |
Limit to a single category by slash path (e.g. MySite/Hooks) |
--no-overwrite |
Skip files that already exist on disk |
Runs an import once, then polls _elements/ every second and reimports any file with a changed mtime. Sidecar JSON changes are watched too, so toggling a plugin's events takes effect without a manual rerun.
php modx-elements.php watch [--no-static]Polling is deliberate — it works identically on Windows, macOS, and Linux without inotify or fswatch. Ctrl+C stops the watcher (cleanly if pcntl_signal is available).
Static mode (default). The element row in modx_site_* is flagged static = 1 and points at the source file via static_file. MODX reads the file from disk on every parse. Edits flow through immediately with no reimport.
Copy mode (--no-static). File content is copied into the DB column and the element is fully self-contained. Edits in your IDE require a reimport to take effect. Use this if the deployment target has no filesystem access to _elements/, or if you want to ship a transport package built from these elements.
The script writes the code field in both modes, so a static element still has a usable DB fallback if the file ever goes missing.
Categories are specified as slash-separated paths and auto-created on import:
"category": "MySite/Hooks/Pre-render"becomes three nested categories. On export, the full path is reconstructed by walking up parent references.
- The static
sourceis hardcoded to1(the default filesystem media source). If your elements live behind a custom Source, change the'source' => 1line inimportElement(). watchreports file deletions but does not remove DB elements. Removing live elements should be an explicit action through the manager.- Renames are seen as "new file appears, old file disappears." A renamed file produces a new element; the old one stays until you delete it manually.
- Element discovery on import uses
glob()with the configured extension. Files with unexpected extensions in the type folders (e.g. a.txtinchunks/) are silently ignored. - The script needs write access to
config.core.php's parent directory to read it, plus DB write access for whatever user MODX is configured with.
my-modx-site/
├── config.core.php
├── core/
├── manager/
├── connectors/
├── _elements/ ← managed by this script
│ ├── chunks/
│ ├── snippets/
│ ├── plugins/
│ └── templates/
├── modx-elements.php ← drop here
└── .gitignore
A reasonable .gitignore keeps the MODX core and files out while versioning _elements/:
/core/
/manager/
/connectors/
/setup/
/config.core.php
!/_elements/
Use it however you like.