Skip to main content

Local JSON and Version Control Workflow

Local JSON turns ACF field-group changes from hidden database state into versioned, reviewable, and deployable artifacts.

Learning Focus

In this lesson, you will build a production-ready Local JSON workflow that saves ACF Pro schemas to code, loads them deterministically at runtime, and validates parity with WP-CLI before release. This matters because schema drift is one of the most expensive ACF failure modes in real projects.

Concept Overview

Most ACF incidents are not caused by templates. They are caused by schema state that silently differs between local, staging, and production. When field groups exist only in the database, developers cannot review schema changes in pull requests, CI cannot validate them, and rollback becomes guesswork.

Local JSON solves this by serializing field groups into JSON files (acf-json/*.json) that travel with your code. In practice, you use acf/settings/save_json to control where schema files are written and acf/settings/load_json to control where ACF reads schema from on bootstrap. With this model, schema changes can be reviewed like PHP changes.

For ACF Pro teams, Local JSON is even more important because Pro-heavy structures (Repeater, Flexible Content, Clone, Gallery, Options pages) are often mission-critical and deeply nested. One stale field key can break rendering paths, data migrations, and API responses in multiple places. The safest approach is deterministic load paths plus CLI verification for every environment.

Core Idea

Treat ACF schema as code, not runtime admin state: write to Local JSON, review in Git, validate with WP-CLI, then deploy.

Variants you should recognize:

  • Single-path Local JSON: one canonical acf-json directory for all groups.
  • Segmented save paths: route selected groups to subfolders using keyed save filters.
  • Multi-path load: combine theme path + mu-plugin path for shared enterprise schemas.
  • JSON-first runtime: load from committed JSON as authority and avoid ad-hoc admin edits on production.

Why It Matters

ApproachWhat HappensImpact in Production
Local JSON committed with every schema changeReviewers can inspect exact key/name/setting diffs in PRsFewer surprise regressions after deploy; faster approvals
Local JSON path configured but not validated in CLIFiles exist, but runtime may still register wrong group setIncidents where templates expect fields that are not registered
Environment-specific untracked edits in wp-adminProduction diverges silently from GitRollbacks fail because repo no longer reflects runtime schema
Segmented paths with explicit load orderShared and app-specific groups can coexist predictablyCleaner multi-team ownership and fewer key collisions
No schema ownership policy (wrong pattern)Duplicate keys and stale JSON are merged into mainRuntime corruption, missing values, broken render loops, and costly hotfixes

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
acf/settings/save_jsonadd_filter('acf/settings/save_json', callable $callback): voidSet base path where ACF writes JSON filesCallback returns absolute directory path string
acf/settings/save_json/key={group_key}add_filter('acf/settings/save_json/key=group_xxx', callable $callback): voidRoute one specific field group to a dedicated folderUseful for domain ownership (service, staff, commerce)
acf/settings/load_jsonadd_filter('acf/settings/load_json', callable $callback): voidDefine one or many paths ACF will scan for JSON groupsReturn array of directories in deterministic order
acf_get_field_groups()acf_get_field_groups(array $filter = []): arrayRead runtime-registered field groupsBest CLI smoke check for schema parity
acf_get_fields()`acf_get_fields(intstringarray $parent): array`
wp evalwp eval '<php-code>'Execute runtime checks in WP contextUse in CI and deploy hooks to validate ACF state
wp post meta getwp post meta get <post-id> <meta-key>Read raw meta values tied to field namesConfirms data persistence independent of template logic
have_rows()`have_rows(string $selector, intstring $post_id = false): bool`Iterate Repeater/Flexible Content rows
ACF Pro Required

This workflow often validates Repeater and Flexible Content schemas (have_rows(), get_sub_field(), layout definitions), which require ACF Pro.

Practical Use Cases

Use Case 1 — Route schema by domain and verify deterministic load paths

A healthcare product team splits field ownership by domain: service pages are maintained by one squad and staff directory fields by another. They need one codebase with separate JSON subfolders and predictable runtime loading.

  1. Create a bootstrap file for Local JSON save/load filters.
  2. Define a default save path for all field groups.
  3. Add keyed save-path filters for group_service_settings and group_staff_directory.
  4. Define explicit load paths in a stable order.
  5. Verify save/load paths and directory content with WP-CLI and shell commands.
wp-content/themes/clinic-pro/inc/acf/local-json-bootstrap.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
$basePath = get_stylesheet_directory() . '/acf-json';
$servicePath = $basePath . '/service';
$staffPath = $basePath . '/staff';

foreach ([$basePath, $servicePath, $staffPath] as $path) {
if (!is_dir($path)) {
wp_mkdir_p($path);
}
}
});

add_filter('acf/settings/save_json', function (string $path): string {
return get_stylesheet_directory() . '/acf-json';
});

add_filter('acf/settings/save_json/key=group_service_settings', function (string $path): string {
$target = get_stylesheet_directory() . '/acf-json/service';

if (!is_dir($target)) {
wp_mkdir_p($target);
}

return $target;
});

add_filter('acf/settings/save_json/key=group_staff_directory', function (string $path): string {
$target = get_stylesheet_directory() . '/acf-json/staff';

if (!is_dir($target)) {
wp_mkdir_p($target);
}

return $target;
});

add_filter('acf/settings/load_json', function (array $paths): array {
$basePath = get_stylesheet_directory() . '/acf-json';

return [
$basePath,
$basePath . '/service',
$basePath . '/staff',
];
});
terminal: command
wp eval 'echo apply_filters("acf/settings/save_json", "") . PHP_EOL;'
wp eval '$paths = apply_filters("acf/settings/load_json", []); print_r($paths);'
ls -1 wp-content/themes/clinic-pro/acf-json
terminal: output
/var/www/html/wp-content/themes/clinic-pro/acf-json
Array
(
[0] => /var/www/html/wp-content/themes/clinic-pro/acf-json
[1] => /var/www/html/wp-content/themes/clinic-pro/acf-json/service
[2] => /var/www/html/wp-content/themes/clinic-pro/acf-json/staff
)
group_global_branding.json
service
staff
warning

If load paths are correct but field groups still look stale, check file ownership and write permissions on acf-json/. ACF may fail to write updates silently when web user permissions are wrong.

Use Case 2 — Add runtime drift monitoring and expose it to WP-CLI

An operations team wants a deploy-time drift report: compare JSON field-group keys in Git with runtime registered keys, then store the report in an option that CI and release scripts can inspect.

  1. Add an acf/init callback that scans committed JSON files.
  2. Parse each JSON file and extract key values safely.
  3. Collect runtime keys from acf_get_field_groups().
  4. Compute two diff sets: missing in runtime and missing in JSON.
  5. Store a structured report in an option.
  6. Verify report content through WP-CLI.
wp-content/themes/clinic-pro/inc/acf/schema-drift-monitor.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
$root = get_stylesheet_directory() . '/acf-json';
$jsonKeys = [];
$errors = [];

if (is_dir($root)) {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);

/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->getExtension() !== 'json') {
continue;
}

$raw = file_get_contents($file->getPathname());
$decoded = json_decode((string) $raw, true);

if (!is_array($decoded) || empty($decoded['key'])) {
$errors[] = 'invalid_json:' . $file->getFilename();
continue;
}

$jsonKeys[] = (string) $decoded['key'];
}
} else {
$errors[] = 'missing_dir:' . $root;
}

$runtimeGroups = acf_get_field_groups();
$runtimeKeys = array_values(array_filter(array_map(
static fn (array $group): string => (string) ($group['key'] ?? ''),
$runtimeGroups
)));

$report = [
'json_count' => count($jsonKeys),
'runtime_count' => count($runtimeKeys),
'missing_in_runtime' => array_values(array_diff($jsonKeys, $runtimeKeys)),
'missing_in_json' => array_values(array_diff($runtimeKeys, $jsonKeys)),
'errors' => $errors,
'generated_at' => gmdate('c'),
];

update_option('acf_schema_drift_report', $report, false);
});
terminal: command
wp eval 'do_action("acf/init"); print_r(get_option("acf_schema_drift_report"));'
wp eval 'echo "runtime_groups=" . count(acf_get_field_groups()) . PHP_EOL;'
ls -1 wp-content/themes/clinic-pro/acf-json/service
terminal: output
Array
(
[json_count] => 14
[runtime_count] => 14
[missing_in_runtime] => Array
(
)

[missing_in_json] => Array
(
)

[errors] => Array
(
)

[generated_at] => 2026-02-23T10:32:18+00:00
)
runtime_groups=14
group_service_settings.json
group_service_faq.json
note

This pattern gives CI an objective schema health signal (acf_schema_drift_report) instead of relying on a human to eyeball admin screens.

Use Case 3 — Edge case: malformed JSON breaks boot-time schema loading

A hotfix commit introduces malformed JSON (<<<<<<< HEAD merge marker) into one field-group file. A naive loader crashes or silently imports null arrays, while a robust loader logs and skips bad files without corrupting runtime state.

❌ Fragile Pattern

wp-content/mu-plugins/acf-json-loader-fragile.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
$files = glob(get_stylesheet_directory() . '/acf-json/*.json');

foreach ($files as $file) {
$decoded = json_decode((string) file_get_contents($file), true);
acf_add_local_field_group($decoded);
}
});

✅ Robust Pattern

wp-content/mu-plugins/acf-json-loader-robust.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
$files = glob(get_stylesheet_directory() . '/acf-json/*.json');
$imported = [];
$skipped = [];

foreach ($files as $file) {
$raw = file_get_contents($file);

if ($raw === false || trim($raw) === '') {
$skipped[] = basename($file) . ':empty';
continue;
}

$decoded = json_decode($raw, true);

if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
$skipped[] = basename($file) . ':json_error';
continue;
}

if (empty($decoded['key']) || empty($decoded['title']) || !isset($decoded['fields'])) {
$skipped[] = basename($file) . ':missing_required_keys';
continue;
}

acf_add_local_field_group($decoded);
$imported[] = (string) $decoded['key'];
}

$report = [
'imported_count' => count($imported),
'skipped_count' => count($skipped),
'skipped' => $skipped,
'generated_at' => gmdate('c'),
];

update_option('acf_json_loader_report', $report, false);
error_log('[acf-json-loader] ' . wp_json_encode($report));
});
terminal: command
wp eval 'do_action("acf/init"); print_r(get_option("acf_json_loader_report"));'
wp eval 'echo "groups=" . count(acf_get_field_groups()) . PHP_EOL;'
terminal: output
Array
(
[imported_count] => 13
[skipped_count] => 1
[skipped] => Array
(
[0] => group_service_settings.json:json_error
)

[generated_at] => 2026-02-23T10:38:07+00:00
)
groups=13
terminal: command
wp eval 'echo file_get_contents(get_stylesheet_directory() . "/acf-json/group_service_settings.json") . PHP_EOL;' | head -n 2
terminal: output
{
<<<<<<< HEAD
warning

Never auto-import JSON files without structural validation. A single malformed file can invalidate assumptions in templates, migrations, and release smoke tests.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Saving JSON to default plugin pathTeam did not define acf/settings/save_jsonSchema files are missing from repo and not reviewedAdd add_filter('acf/settings/save_json', fn (): string => get_stylesheet_directory() . '/acf-json');
Multiple load paths with unknown precedenceNo explicit ownership modelOlder group definitions override new ones unpredictablyVerify with wp eval '$p=apply_filters("acf/settings/load_json",[]); print_r($p);'
Field-group keys duplicated across JSON filesManual copy/paste without key governanceRandom group overrides, missing fields, and data mismatchDetect with wp eval 'print_r(array_count_values(array_column(acf_get_field_groups(),"key")));'
JSON files merged with conflict markersIncomplete conflict resolutionParser errors or silent skips during loadGuard loader using json_last_error() and run wp eval 'do_action("acf/init"); print_r(get_option("acf_json_loader_report"));'
Runtime checks skipped in deploymentNo CLI gate in pipelineBroken schema reaches production unnoticedAdd deploy gate: wp eval 'echo count(acf_get_field_groups()) . PHP_EOL;'
Writing schema changes in production admin onlyNo JSON-first disciplineProduction and Git state diverge immediatelyEnforce review of git diff -- wp-content/themes/clinic-pro/acf-json/ in PR process
Deep Dive: Why Duplicate Field Keys Are Harder to Debug Than It Looks

Duplicate field-group keys rarely throw obvious fatal errors. Instead, ACF may register whichever version loads later in path order, so one environment appears correct while another silently uses an older schema. This creates "it works on staging" failures where template code is valid but field definitions differ. The bug is non-obvious because both groups can share titles and labels, making admin inspection misleading. The fastest detection path is to check runtime key frequencies and compare them with JSON keys in Git.

wp eval '$groups=acf_get_field_groups(); $keys=array_column($groups,"key"); print_r(array_filter(array_count_values($keys), fn($n)=>$n>1));'

Best Practices

  1. Make Local JSON path explicit in code: add_filter('acf/settings/save_json', fn (): string => get_stylesheet_directory() . '/acf-json');
  2. Verify load paths on every environment: wp eval '$paths=apply_filters("acf/settings/load_json",[]); print_r($paths);'
  3. Gate release on runtime group count: wp eval 'echo "groups=" . count(acf_get_field_groups()) . PHP_EOL;'
  4. Detect malformed JSON before deploy: php -r '$f=glob("wp-content/themes/clinic-pro/acf-json/*.json"); foreach($f as $x){json_decode(file_get_contents($x),true); if(json_last_error()){echo basename($x)."\n";}}'
  5. Track key uniqueness in CI: wp eval '$k=array_column(acf_get_field_groups(),"key"); print_r(array_filter(array_count_values($k), fn($n)=>$n>1));'
  6. Keep one ownership map for grouped schemas: wp eval 'echo apply_filters("acf/settings/save_json","") . PHP_EOL;'
  7. Cross-check schema and data after migration: wp post meta get 1204 service_headline && wp eval 'var_export(get_field("service_headline",1204));'

Hands-On Practice

Exercise 1: Bootstrap canonical Local JSON paths

Create wp-content/themes/clinic-pro/inc/acf/local-json-bootstrap.php with the filter code from Use Case 1, then run:

wp eval 'echo apply_filters("acf/settings/save_json", "") . PHP_EOL;'
wp eval '$paths=apply_filters("acf/settings/load_json",[]); echo count($paths) . PHP_EOL;'

After completing this exercise, running the commands should return:

/var/www/html/wp-content/themes/clinic-pro/acf-json
3

Exercise 2: Generate and verify a test page field value

Create a test page and set one field value:

wp post create --post_title="ACF Local JSON Test" --post_status=publish --post_type=page
wp eval '$id = (int) get_page_by_title("ACF Local JSON Test", OBJECT, "page")->ID; update_field("service_headline", "Local JSON Verified", $id); echo $id . PHP_EOL;'
wp eval '$id = (int) get_page_by_title("ACF Local JSON Test", OBJECT, "page")->ID; echo get_field("service_headline", $id) . PHP_EOL;'

After completing this exercise, running the final command should return:

Local JSON Verified

Exercise 3: Add and inspect schema drift report

Create wp-content/themes/clinic-pro/inc/acf/schema-drift-monitor.php using Use Case 2 code, then run:

wp eval 'do_action("acf/init"); print_r(get_option("acf_schema_drift_report"));'

After completing this exercise, the report should include:

[json_count] =>
[runtime_count] =>
[missing_in_runtime] => Array
[missing_in_json] => Array

Exercise 4: Simulate malformed JSON and verify robust skip behavior

Introduce an invalid JSON marker in one file, then run:

wp eval 'do_action("acf/init"); print_r(get_option("acf_json_loader_report"));'

After completing this exercise, the output should show one skipped file:

[skipped_count] => 1
[0] => group_service_settings.json:json_error

Exercise 5: Run a pre-release schema integrity sweep

Run this release gate sequence:

wp plugin list --status=active | grep advanced-custom-fields-pro
wp eval 'echo "groups=" . count(acf_get_field_groups()) . PHP_EOL;'
ls -1 wp-content/themes/clinic-pro/acf-json/*.json | wc -l
wp eval 'print_r(get_option("acf_schema_drift_report"));'

After completing this exercise, expected output pattern:

advanced-custom-fields-pro active
groups=14
14
Array
(
[missing_in_runtime] => Array
(
)
)

CLI Reference

CommandPurposeReal Example Output
wp eval 'echo apply_filters("acf/settings/save_json", "") . PHP_EOL;'Confirm active save path/var/www/html/wp-content/themes/clinic-pro/acf-json
wp eval '$p=apply_filters("acf/settings/load_json",[]); print_r($p);'Inspect load-path orderArray ( [0] => .../acf-json [1] => .../service [2] => .../staff )
wp eval 'echo count(acf_get_field_groups()) . PHP_EOL;'Check runtime group count14
wp eval 'print_r(array_column(acf_get_field_groups(), "key"));'List runtime group keysArray ( [0] => group_service_settings ... )
wp post meta get 1204 service_headlineRead raw stored meta for one postLocal JSON Verified
wp eval 'var_export(get_field("service_headline", 1204));'Validate formatted ACF retrieval'Local JSON Verified'
ls -1 wp-content/themes/clinic-pro/acf-json/*.jsonList committed schema filesgroup_service_settings.json
wp eval 'do_action("acf/init"); print_r(get_option("acf_schema_drift_report"));'Read drift monitor reportArray ( [missing_in_runtime] => Array ( ) ... )

What's Next

tip

Revisit this lesson when you add or rename field groups during a release and need to prove schema parity before deploying to production.