Skip to content

Instantly share code, notes, and snippets.

@daemondevin
Created May 8, 2026 10:35
Show Gist options
  • Select an option

  • Save daemondevin/59ec2589f32b200357a2ae8af6095974 to your computer and use it in GitHub Desktop.

Select an option

Save daemondevin/59ec2589f32b200357a2ae8af6095974 to your computer and use it in GitHub Desktop.

modx-elements

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.

What it does

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 → DB
  • export — DB → files
  • watchimport once, then auto-reimport on file change

Requirements

  • MODX Revolution 3.0+
  • PHP 8.0+
  • CLI access to the server hosting MODX

Install

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.

Quick start

Existing site → version control

php modx-elements.php export --no-overwrite
git add _elements && git commit -m "Seed elements"
php modx-elements.php watch

Export pulls every element out of the DB, watch starts the dev loop. From here, editing any file in _elements/ flows into MODX automatically.

New project from scratch

mkdir -p _elements/{chunks,snippets,plugins,templates,tvs}
# add files...
php modx-elements.php import

Folder layout

_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.

Sidecar JSON

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

Plugin example

_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": ""}
    ]
}

TV example

_elements/tvs/heroImage.json:

{
    "type":         "image",
    "caption":      "Hero Image",
    "description":  "Banner image at the top of the page",
    "category":     "MySite/Media",
    "templates":    ["BaseTemplate", "LandingTemplate"]
}

Subcommand reference

import

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.

export

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

watch

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 vs. copy mode

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

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.

Caveats

  • The static source is hardcoded to 1 (the default filesystem media source). If your elements live behind a custom Source, change the 'source' => 1 line in importElement().
  • watch reports 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 .txt in chunks/) 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.

Layout for a typical project

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/

License

Use it however you like.

<?php
/**
* MODX Revolution Element Sync Tool
*
* Bidirectional sync between source files and MODX elements, with watch mode.
* Drop next to your config.core.php and run from the command line.
*
* Subcommands
* import [--dry-run] [--no-static]
* Read _elements/ and create or update matching elements in the DB.
*
* export [--type=chunks|snippets|plugins|templates|tvs] [--category=Path/Subpath]
* [--no-overwrite]
* Walk the DB and write elements out to _elements/. Each gets its
* code file plus a JSON sidecar describing category, properties,
* events (plugins), and TV metadata.
*
* watch [--no-static]
* Run an import once, then poll _elements/ and reimport changed files
* on the fly. Ctrl+C to stop.
*
* Examples
* php modx-elements.php import
* php modx-elements.php import --dry-run
* php modx-elements.php export
* php modx-elements.php export --type=plugins --category="MySite"
* php modx-elements.php watch
*
* Folder layout (created/consumed):
* _elements/
* chunks/{name}.html (+ optional {name}.json)
* snippets/{name}.php (+ optional {name}.json)
* plugins/{name}.php (+ {name}.json with "events" array)
* templates/{name}.html (+ optional {name}.json)
* tvs/{name}.json (TVs are metadata-only)
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "Run this from the command line.\n");
exit(1);
}
// Talk to MODX
require_once __DIR__ . '/config.core.php';
require_once MODX_CORE_PATH . 'vendor/autoload.php';
use MODX\Revolution\modX;
use MODX\Revolution\modChunk;
use MODX\Revolution\modSnippet;
use MODX\Revolution\modPlugin;
use MODX\Revolution\modPluginEvent;
use MODX\Revolution\modTemplate;
use MODX\Revolution\modTemplateVar;
use MODX\Revolution\modTemplateVarTemplate;
use MODX\Revolution\modCategory;
use MODX\Revolution\modEvent;
$modx = new modX();
$modx->initialize('mgr');
// Config
$root = __DIR__ . '/_elements';
$args = parseArgs($argv);
$cmd = $args['_command'];
// Type registry — drives both directions. nameField matters: templates use
// 'templatename', everything else uses 'name'. codeField is the column that
// holds the source body (null for TVs, which are metadata-only).
$types = [
'chunks' => ['class' => modChunk::class, 'ext' => 'html', 'codeField' => 'snippet', 'nameField' => 'name'],
'snippets' => ['class' => modSnippet::class, 'ext' => 'php', 'codeField' => 'snippet', 'nameField' => 'name'],
'plugins' => ['class' => modPlugin::class, 'ext' => 'php', 'codeField' => 'plugincode', 'nameField' => 'name'],
'templates' => ['class' => modTemplate::class, 'ext' => 'html', 'codeField' => 'content', 'nameField' => 'templatename'],
'tvs' => ['class' => modTemplateVar::class, 'ext' => null, 'codeField' => null, 'nameField' => 'name'],
];
// Dispatch
switch ($cmd) {
case 'import': cmdImport($modx, $types, $root, $args); break;
case 'export': cmdExport($modx, $types, $root, $args); break;
case 'watch': cmdWatch($modx, $types, $root, $args); break;
default: printHelp(); break;
}
// Commands
function cmdImport(modX $modx, array $types, string $root, array $args): void {
if (!is_dir($root)) {
say('err', "Source folder not found: {$root}");
exit(1);
}
$useStatic = empty($args['_flags']['no-static']);
$dryRun = !empty($args['_flags']['dry-run']);
foreach ($types as $kind => $def) {
$dir = "{$root}/{$kind}";
if (!is_dir($dir)) continue;
$pattern = $def['ext'] ? "{$dir}/*.{$def['ext']}" : "{$dir}/*.json";
foreach (glob($pattern) ?: [] as $file) {
importElement($modx, $kind, $def, $file, $useStatic, $dryRun, $root);
}
}
say('info', $dryRun ? 'Dry run complete.' : 'Import complete.');
}
function cmdExport(modX $modx, array $types, string $root, array $args): void {
$onlyType = $args['_flags']['type'] ?? null;
$onlyCat = $args['_flags']['category'] ?? null;
$noOverwrite = !empty($args['_flags']['no-overwrite']);
if (!is_dir($root) && !@mkdir($root, 0755, true)) {
say('err', "Cannot create source folder: {$root}");
exit(1);
}
$catFilter = null;
if ($onlyCat) {
$catFilter = resolveCategoryId($modx, $onlyCat);
if ($catFilter === null) {
say('err', "Category not found: {$onlyCat}");
exit(1);
}
}
foreach ($types as $kind => $def) {
if ($onlyType && $onlyType !== $kind) continue;
$dir = "{$root}/{$kind}";
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$criteria = $catFilter !== null ? ['category' => $catFilter] : [];
foreach ($modx->getIterator($def['class'], $criteria) as $element) {
exportElement($modx, $kind, $def, $element, $dir, $noOverwrite);
}
}
say('info', 'Export complete.');
}
function cmdWatch(modX $modx, array $types, string $root, array $args): void {
if (!is_dir($root)) {
say('err', "Source folder not found: {$root}");
exit(1);
}
$useStatic = empty($args['_flags']['no-static']);
say('info', 'Initial import...');
cmdImport($modx, $types, $root, $args);
say('info', "Watching {$root} (Ctrl+C to stop)");
if (function_exists('pcntl_signal')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, function () {
say('info', 'Stopping watcher.');
exit(0);
});
}
$known = snapshotMtimes($root, $types);
while (true) {
sleep(1);
$current = snapshotMtimes($root, $types);
// Changed or new
foreach ($current as $file => $mtime) {
if (!isset($known[$file]) || $known[$file] !== $mtime) {
$kind = detectKind($file, $root);
if ($kind && isset($types[$kind])) {
importElement($modx, $kind, $types[$kind], $file, $useStatic, false, $root);
}
}
}
// Deletions are reported but do NOT remove DB elements (too dangerous to automate)
foreach (array_diff_key($known, $current) as $file => $_) {
say('warn', 'removed (not deleting from DB): ' . relPath($file, $root));
}
$known = $current;
}
}
function printHelp(): void {
echo <<<TXT
modx-elements.php — sync MODX elements with files
import read _elements/ -> create/update DB rows
export read DB rows -> write _elements/
watch import once, then keep watching
Run "php modx-elements.php <command>" with --help-style flags described in the
header comment of this file.
TXT;
}
// Element import
function importElement(
modX $modx,
string $kind,
array $def,
string $sourceFile,
bool $useStatic,
bool $dryRun,
string $rootDir
): void {
$name = pathinfo($sourceFile, PATHINFO_FILENAME);
$class = $def['class'];
$nameField = $def['nameField'];
$meta = readMeta($sourceFile);
$element = $modx->getObject($class, [$nameField => $name]);
$isNew = !$element;
if ($isNew) {
$element = $modx->newObject($class);
$element->set($nameField, $name);
}
$fields = [
'description' => $meta['description'] ?? '',
'category' => ensureCategory($modx, $meta['category'] ?? null),
];
if ($def['codeField']) {
$code = (string)file_get_contents($sourceFile);
$fields[$def['codeField']] = $code;
if ($useStatic) {
$fields['static'] = true;
$fields['source'] = 1; // default filesystem media source
$fields['static_file'] = relPath($sourceFile, dirname($rootDir));
}
}
if ($kind === 'tvs') {
$fields['type'] = $meta['type'] ?? 'text';
$fields['caption'] = $meta['caption'] ?? $name;
$fields['default_text'] = $meta['default_text'] ?? '';
if (isset($meta['display'])) $fields['display'] = $meta['display'];
if (isset($meta['input_options'])) $fields['elements'] = $meta['input_options'];
}
if (!empty($meta['properties'])) {
$element->setProperties($meta['properties']);
}
$element->fromArray($fields);
$action = $isNew ? 'add' : 'upd';
say($action, "{$kind}: {$name}");
if ($dryRun) return;
if (!$element->save()) {
say('err', "save failed: {$kind}/{$name}");
return;
}
if ($kind === 'plugins' && isset($meta['events'])) {
syncPluginEvents($modx, $element, (array)$meta['events']);
}
if ($kind === 'tvs' && !empty($meta['templates'])) {
attachTvToTemplates($modx, $element, (array)$meta['templates']);
}
}
function syncPluginEvents(modX $modx, modPlugin $plugin, array $eventNames): void {
foreach ($plugin->getMany('PluginEvents') as $existing) {
$existing->remove();
}
foreach ($eventNames as $name) {
if (!$modx->getObject(modEvent::class, ['name' => $name])) {
say('warn', " unknown event '{$name}', skipped");
continue;
}
$pe = $modx->newObject(modPluginEvent::class);
$pe->fromArray([
'pluginid' => $plugin->get('id'),
'event' => $name,
'priority' => 0,
], '', true, true);
$pe->save();
}
}
function attachTvToTemplates(modX $modx, modTemplateVar $tv, array $templateNames): void {
foreach ($templateNames as $tplName) {
$tpl = $modx->getObject(modTemplate::class, ['templatename' => $tplName]);
if (!$tpl) continue;
$exists = $modx->getObject(modTemplateVarTemplate::class, [
'tmplvarid' => $tv->get('id'),
'templateid' => $tpl->get('id'),
]);
if ($exists) continue;
$link = $modx->newObject(modTemplateVarTemplate::class);
$link->fromArray([
'tmplvarid' => $tv->get('id'),
'templateid' => $tpl->get('id'),
], '', true, true);
$link->save();
}
}
// Element export
function exportElement(modX $modx, string $kind, array $def, $element, string $dir, bool $noOverwrite): void {
$name = (string)$element->get($def['nameField']);
if ($name === '') return;
$meta = [];
$desc = (string)$element->get('description');
if ($desc !== '') $meta['description'] = $desc;
$catId = (int)$element->get('category');
$catPath = $catId ? categoryPath($modx, $catId) : '';
if ($catPath !== '') $meta['category'] = $catPath;
$props = $element->getProperties();
if (!empty($props)) $meta['properties'] = $props;
if ($kind === 'plugins') {
$events = [];
foreach ($element->getMany('PluginEvents') as $pe) {
$events[] = (string)$pe->get('event');
}
if ($events) $meta['events'] = $events;
}
if ($kind === 'tvs') {
$meta['type'] = (string)$element->get('type');
$meta['caption'] = (string)$element->get('caption');
$meta['default_text'] = (string)$element->get('default_text');
$display = (string)$element->get('display');
if ($display !== '') $meta['display'] = $display;
$inputOpts = (string)$element->get('elements');
if ($inputOpts !== '') $meta['input_options'] = $inputOpts;
// Which templates is it on?
$tplNames = [];
foreach ($element->getMany('TemplateVarTemplates') as $link) {
$tpl = $modx->getObject(modTemplate::class, $link->get('templateid'));
if ($tpl) $tplNames[] = (string)$tpl->get('templatename');
}
if ($tplNames) $meta['templates'] = $tplNames;
}
// Write code file (if applicable)
if ($def['codeField']) {
$codeFile = "{$dir}/{$name}.{$def['ext']}";
if (!writeFile($codeFile, (string)$element->get($def['codeField']), $noOverwrite)) {
say('skip', "exists: {$kind}/{$name}.{$def['ext']}");
} else {
say('add', "wrote: {$kind}/{$name}.{$def['ext']}");
}
}
// Write sidecar JSON (only if something to record, or for TVs always)
if (!empty($meta) || $kind === 'tvs') {
$jsonFile = "{$dir}/{$name}.json";
$json = json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (writeFile($jsonFile, $json . "\n", $noOverwrite)) {
say('add', "wrote: {$kind}/{$name}.json");
}
}
}
function categoryPath(modX $modx, int $id): string {
$parts = [];
$seen = [];
while ($id > 0 && !isset($seen[$id])) {
$seen[$id] = true;
$cat = $modx->getObject(modCategory::class, $id);
if (!$cat) break;
array_unshift($parts, (string)$cat->get('category'));
$id = (int)$cat->get('parent');
}
return implode('/', $parts);
}
function resolveCategoryId(modX $modx, string $path): ?int {
$parent = 0;
foreach (array_filter(array_map('trim', explode('/', $path))) as $name) {
$cat = $modx->getObject(modCategory::class, ['category' => $name, 'parent' => $parent]);
if (!$cat) return null;
$parent = (int)$cat->get('id');
}
return $parent ?: null;
}
function snapshotMtimes(string $root, array $types): array {
$map = [];
foreach ($types as $kind => $def) {
$dir = "{$root}/{$kind}";
if (!is_dir($dir)) continue;
$patterns = [$def['ext'] ? "{$dir}/*.{$def['ext']}" : "{$dir}/*.json"];
// Watch sidecar JSON for code-bearing types too
if ($def['ext']) $patterns[] = "{$dir}/*.json";
foreach ($patterns as $pat) {
foreach (glob($pat) ?: [] as $f) {
$map[$f] = filemtime($f);
}
}
}
return $map;
}
function detectKind(string $file, string $root): ?string {
$rel = ltrim(substr($file, strlen($root)), '/\\');
$parts = preg_split('#[/\\\\]#', $rel);
return $parts[0] ?? null;
}
function ensureCategory(modX $modx, ?string $path): int {
if (!$path) return 0;
$parent = 0;
foreach (array_filter(array_map('trim', explode('/', $path))) as $name) {
$cat = $modx->getObject(modCategory::class, ['category' => $name, 'parent' => $parent]);
if (!$cat) {
$cat = $modx->newObject(modCategory::class);
$cat->fromArray(['category' => $name, 'parent' => $parent]);
$cat->save();
say('add', "category: {$name}");
}
$parent = (int)$cat->get('id');
}
return $parent;
}
function readMeta(string $sourceFile): array {
// For TVs the source file IS the JSON; otherwise look for a sibling .json
if (str_ends_with($sourceFile, '.json')) {
$data = json_decode((string)file_get_contents($sourceFile), true);
return is_array($data) ? $data : [];
}
$jsonFile = preg_replace('/\.[^.]+$/', '.json', $sourceFile);
if (!is_file($jsonFile)) return [];
$data = json_decode((string)file_get_contents($jsonFile), true);
return is_array($data) ? $data : [];
}
function writeFile(string $path, string $content, bool $noOverwrite): bool {
if ($noOverwrite && is_file($path)) return false;
@mkdir(dirname($path), 0755, true);
return file_put_contents($path, $content) !== false;
}
function relPath(string $abs, string $base): string {
$base = rtrim($base, '/\\');
return ltrim(str_replace($base, '', $abs), '/\\');
}
function parseArgs(array $argv): array {
$out = ['_command' => $argv[1] ?? 'help', '_flags' => [], '_positional' => []];
foreach (array_slice($argv, 2) as $arg) {
if (str_starts_with($arg, '--')) {
$body = substr($arg, 2);
$eq = strpos($body, '=');
if ($eq !== false) {
$out['_flags'][substr($body, 0, $eq)] = substr($body, $eq + 1);
} else {
$out['_flags'][$body] = true;
}
} else {
$out['_positional'][] = $arg;
}
}
return $out;
}
function say(string $kind, string $msg): void {
static $colors = [
'add' => '32', // green
'upd' => '33', // yellow
'skip' => '90', // grey
'warn' => '35', // magenta
'err' => '31', // red
'info' => '36', // cyan
];
$code = $colors[$kind] ?? '0';
$tag = str_pad(strtoupper($kind), 4);
fwrite(STDOUT, "\033[{$code}m[{$tag}]\033[0m {$msg}\n");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment