Using Advanced Custom Fields with version control

ACF lets you build metaboxes, Gutenberg blocks, and more with their easy-to-use interface. These custom fields are stored in the database and then rendered in the WordPress backend for you – no code required.

This can become an issue if you’re using a modern development workflow: building locally, testing on a development/staging environment, then deploying to production.  Since you likely aren’t overwriting the production database with the development one, any metaboxes created in development will need to be re-created in production.

ACF has a built-in feature to help called Local JSON. If you create a folder in your theme named acf-json/, ACF will automatically add/update a JSON file for every group of fields. These JSON files can then be version controlled with the rest of your theme.

After pushing the code changes to production, log into the production site, go to Custom Fields in the backend, and click “Sync available” to manually sync the updated metaboxes.

Alternative Approach

I prefer having the metabox changes automatically update on production rather than having to manually sync them. 

I also prefer using a core functionality plugin for any site-specific functionality that isn’t directly theme related.  If the website owner would expect something to keep working after changing themes, it belongs in a functionality plugin. 

In my core functionality plugin, I have a acf.php file with the following features:

  1. Store the JSON files in the core functionality plugin, in a acf-json/ folder.
  2. Automatically sync new metaboxes. For performance reasons we limit the sync to new versions of the core functionality plugin, when the CORE_FUNCTIONALITY_VERSION constant has changed.
  3. Only display the ACF field editor if WP_LOCAL_DEV === true. I have this constant defined in wp-config.php locally. This prevents clients from accidentally editing or deleting their metaboxes.
  4. Register an options page. I’ve commented it out, but most sites we build leverage an options page so I can quickly turn it on.
  5. Register new blocks. It’s also commented out but easily accessible so I don’t have to keep referring to my article on building Gutenberg blocks with ACF.
* Advanced Custom Fields
* @package CoreFunctionality
* @version 2.0
* @author Bill Erickson <>
* @copyright Copyright (c) 2018, Bill Erickson
* @license GPL-2.0+
class BE_ACF_Customizations {
public function __construct() {
// Only allow fields to be edited on development
if ( ! defined( 'WP_LOCAL_DEV' ) || ! WP_LOCAL_DEV ) {
add_filter( 'acf/settings/show_admin', '__return_false' );
// Save and sync fields in functionality plugin
add_filter( 'acf/settings/save_json', array( $this, 'get_local_json_path' ) );
add_filter( 'acf/settings/load_json', array( $this, 'add_local_json_path' ) );
add_action( 'admin_init', array( $this, 'sync_fields_with_json' ) );
// Register options page
//add_action( 'init', array( $this, 'register_options_page' ) );
// Register Blocks
//add_action('acf/init', array( $this, 'register_blocks' ) );
* Define where the local JSON is saved
* @return string
public function get_local_json_path() {
return EA_DIR . '/acf-json';
* Add our path for the local JSON
* @param array $paths
* @return array
public function add_local_json_path( $paths ) {
$paths[] = EA_DIR . '/acf-json';
return $paths;
* Automatically sync any JSON field configuration.
public function sync_fields_with_json() {
if ( defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) ) {
if ( ! function_exists( 'acf_get_field_groups' ) ) {
$version = get_option( 'be_acf_json_version' );
if( defined( 'CORE_FUNCTIONALITY_VERSION' ) && version_compare( CORE_FUNCTIONALITY_VERSION, $version ) ) {
update_option( 'be_acf_json_version', CORE_FUNCTIONALITY_VERSION );
$groups = acf_get_field_groups();
if ( empty( $groups ) ) {
$sync = array();
foreach ( $groups as $group ) {
$local = acf_maybe_get( $group, 'local', false );
$modified = acf_maybe_get( $group, 'modified', 0 );
$private = acf_maybe_get( $group, 'private', false );
if ( $local !== 'json' || $private ) {
// ignore DB / PHP / private field groups
if ( ! $group['ID'] ) {
$sync[ $group['key'] ] = $group;
} elseif ( $modified && $modified > get_post_modified_time( 'U', true, $group['ID'], true ) ) {
$sync[ $group['key'] ] = $group;
if ( empty( $sync ) ) {
foreach ( $sync as $key => $v ) {
if ( acf_have_local_fields( $key ) ) {
$sync[ $key ]['fields'] = acf_get_local_fields( $key );
acf_import_field_group( $sync[ $key ] );
* Register Options Page
function register_options_page() {
if ( function_exists( 'acf_add_options_page' ) ) {
acf_add_options_page( array(
'title' => __( 'Site Options', 'core-functionality' ),
'capability' => 'manage_options',
) );
* Register Blocks
* @see
* Categories: common, formatting, layout, widgets, embed
* Dashicons:
* ACF Settings:
function register_blocks() {
if( ! function_exists('acf_register_block_type') )
acf_register_block_type( array(
'name' => 'features',
'title' => __( 'Features', 'core-functionality' ),
'render_template' => 'partials/block-features.php',
'category' => 'formatting',
'icon' => 'awards',
'mode' => 'auto',
'keywords' => array(),
new BE_ACF_Customizations();
view raw acf.php hosted with ❤ by GitHub
* Plugin Name: Core Functionality
define( 'CORE_FUNCTIONALITY_VERSION', '1.0.0' );
define( 'EA_DIR' , plugin_dir_path( __FILE__ ) );
define( 'WP_LOCAL_DEV', true );
view raw wp-config.php hosted with ❤ by GitHub

Advanced Custom Fields WordPress Development

Bill Erickson

Bill Erickson is a freelance WordPress developer and a contributing developer to the Genesis framework. For the past 14 years he has worked with attorneys, publishers, corporations, and non-profits, building custom websites tailored to their needs and goals.

Ready to upgrade your website?

I build custom WordPress websites that look great and are easy to manage.

Let's Talk

Reader Interactions


  1. Chris Brailsford says

    January 5, 2019 at 9:32 am

    Quick question Bill: Is there a reason to sync the acf-json files into the database over not? I know ACF’s documentation states “Now that the JSON file exists, ACF will load the relevant field group and field settings from this file which reduces the number of database calls during your page load!”, so they seem to suggest it being faster/more efficient to use the .json files over database calls. Just curious on your thoughts!

  2. Bryce Jacobson says

    January 31, 2019 at 5:56 pm

    Hi Bill,

    This looks awesome. I’m trying to implement this but am a bit confused on the “CORE_FUNCTIONALITY_VERSION” part. I’m not seeing how this is getting set. Is it something higher up in your Core Functionality plugin, or is it a field on an options page? Or is it simply reading the version from line 6 in the gist?

    • Bill Erickson says

      January 31, 2019 at 5:58 pm

      Yes, this is a constant defined in the main plugin file of my core functionality plugin.

      If this is the only functionality you have that’s tied to a version number, you could could add a version number variable directly to this class which might make it easier to follow.

      I’ll update the code snippet to make the external dependencies more clear.

  3. Andrew says

    February 19, 2019 at 8:10 am

    Really cool stuff Bill!

    Have you found a way to also sync the removal/deletion of fields based off the json? The auto sync is working fine apart from this.

    Thanks for the great ideas and plugin!

    • Bill Erickson says

      February 19, 2019 at 11:26 am

      I haven’t tried it, but I assumed it didn’t matter whether fields were added or deleted. It looks at the timestamp in the JSON file, and if that’s newer than the one in the database it uses the JSON file version.

Leave A Reply