Local JSON and Version Control Workflow
Local JSON turns ACF field-group changes from hidden database state into versioned, reviewable, and deployable artifacts.
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.
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-jsondirectory 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
| Approach | What Happens | Impact in Production |
|---|---|---|
| Local JSON committed with every schema change | Reviewers can inspect exact key/name/setting diffs in PRs | Fewer surprise regressions after deploy; faster approvals |
| Local JSON path configured but not validated in CLI | Files exist, but runtime may still register wrong group set | Incidents where templates expect fields that are not registered |
| Environment-specific untracked edits in wp-admin | Production diverges silently from Git | Rollbacks fail because repo no longer reflects runtime schema |
| Segmented paths with explicit load order | Shared and app-specific groups can coexist predictably | Cleaner multi-team ownership and fewer key collisions |
| No schema ownership policy (wrong pattern) | Duplicate keys and stale JSON are merged into main | Runtime corruption, missing values, broken render loops, and costly hotfixes |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/settings/save_json | add_filter('acf/settings/save_json', callable $callback): void | Set base path where ACF writes JSON files | Callback returns absolute directory path string |
acf/settings/save_json/key={group_key} | add_filter('acf/settings/save_json/key=group_xxx', callable $callback): void | Route one specific field group to a dedicated folder | Useful for domain ownership (service, staff, commerce) |
acf/settings/load_json | add_filter('acf/settings/load_json', callable $callback): void | Define one or many paths ACF will scan for JSON groups | Return array of directories in deterministic order |
acf_get_field_groups() | acf_get_field_groups(array $filter = []): array | Read runtime-registered field groups | Best CLI smoke check for schema parity |
acf_get_fields() | `acf_get_fields(int | string | array $parent): array` |
wp eval | wp eval '<php-code>' | Execute runtime checks in WP context | Use in CI and deploy hooks to validate ACF state |
wp post meta get | wp post meta get <post-id> <meta-key> | Read raw meta values tied to field names | Confirms data persistence independent of template logic |
have_rows() | `have_rows(string $selector, int | string $post_id = false): bool` | Iterate Repeater/Flexible Content rows |
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.
- Create a bootstrap file for Local JSON save/load filters.
- Define a default save path for all field groups.
- Add keyed save-path filters for
group_service_settingsandgroup_staff_directory. - Define explicit load paths in a stable order.
- Verify save/load paths and directory content with WP-CLI and shell commands.
<?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',
];
});
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
/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
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.
- Add an
acf/initcallback that scans committed JSON files. - Parse each JSON file and extract
keyvalues safely. - Collect runtime keys from
acf_get_field_groups(). - Compute two diff sets: missing in runtime and missing in JSON.
- Store a structured report in an option.
- Verify report content through WP-CLI.
<?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);
});
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
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
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
<?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
<?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));
});
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;'
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
wp eval 'echo file_get_contents(get_stylesheet_directory() . "/acf-json/group_service_settings.json") . PHP_EOL;' | head -n 2
{
<<<<<<< HEAD
Never auto-import JSON files without structural validation. A single malformed file can invalidate assumptions in templates, migrations, and release smoke tests.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Saving JSON to default plugin path | Team did not define acf/settings/save_json | Schema files are missing from repo and not reviewed | Add add_filter('acf/settings/save_json', fn (): string => get_stylesheet_directory() . '/acf-json'); |
| Multiple load paths with unknown precedence | No explicit ownership model | Older group definitions override new ones unpredictably | Verify with wp eval '$p=apply_filters("acf/settings/load_json",[]); print_r($p);' |
| Field-group keys duplicated across JSON files | Manual copy/paste without key governance | Random group overrides, missing fields, and data mismatch | Detect with wp eval 'print_r(array_count_values(array_column(acf_get_field_groups(),"key")));' |
| JSON files merged with conflict markers | Incomplete conflict resolution | Parser errors or silent skips during load | Guard 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 deployment | No CLI gate in pipeline | Broken schema reaches production unnoticed | Add deploy gate: wp eval 'echo count(acf_get_field_groups()) . PHP_EOL;' |
| Writing schema changes in production admin only | No JSON-first discipline | Production and Git state diverge immediately | Enforce 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
- Make Local JSON path explicit in code:
add_filter('acf/settings/save_json', fn (): string => get_stylesheet_directory() . '/acf-json'); - Verify load paths on every environment:
wp eval '$paths=apply_filters("acf/settings/load_json",[]); print_r($paths);' - Gate release on runtime group count:
wp eval 'echo "groups=" . count(acf_get_field_groups()) . PHP_EOL;' - 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";}}' - 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));' - Keep one ownership map for grouped schemas:
wp eval 'echo apply_filters("acf/settings/save_json","") . PHP_EOL;' - 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
| Command | Purpose | Real 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 order | Array ( [0] => .../acf-json [1] => .../service [2] => .../staff ) |
wp eval 'echo count(acf_get_field_groups()) . PHP_EOL;' | Check runtime group count | 14 |
wp eval 'print_r(array_column(acf_get_field_groups(), "key"));' | List runtime group keys | Array ( [0] => group_service_settings ... ) |
wp post meta get 1204 service_headline | Read raw stored meta for one post | Local 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/*.json | List committed schema files | group_service_settings.json |
wp eval 'do_action("acf/init"); print_r(get_option("acf_schema_drift_report"));' | Read drift monitor report | Array ( [missing_in_runtime] => Array ( ) ... ) |
What's Next
- Continue to Programmatic Registration and PHP Exports.
- Return to Module 5 Overview for the full operations sequence.
- Related lesson: Installing ACF and Preparing Environments.
- Related lesson: Understanding ACF Hook Lifecycle and Priorities.
Revisit this lesson when you add or rename field groups during a release and need to prove schema parity before deploying to production.