6 Feb

A Closer Look at Applying Filter Variations in ACF

By Elliot Condon

WordPress actions and filters are wonderful. They allow plugin, theme and core developers to offer large amounts of customization with very little effort.

For a long time, ACF has sprinkled these actions and filters throughout the various functions which handle the loading and saving of custom fields. For example, you can hook into the “acf/load_value” filter to modify a value, or the “acf/load_field” filter to modify a field.

This is all well and good, but can come at a cost. Hooking into “acf/load_value” means you are hooking into every value, and it’s a similar story for “acf/load_field”. So, if you have hundreds of fields but only need to customize one, your callback will be called pointlessly 99 times.

The solution to this problem is nothing new, and that is to provide additional specific filters to allow access to specific situations. For example, hooking into “acf/load_value/name=hero_image” will allow you to customize the value for, you guessed it, the “hero_image” field.

Recently, I discovered a neat way to apply these specific filters. What I came up with was around 50 lines of code and a concept I like to call “filter variations” (which also works for actions too 😉).

It’s a neat solution to a unique problem, and I would like to share with you my little journey in creative problem solving plus some tips on how to stay focused when writing new systems.

A quick refreshment 🍸

Before we go jumping into how this new thing works, let’s first have a look at ACF’s previous solution for specific filters. Let’s not forget that these filters have been around for a while already.

Here’s a simplified version of the previous acf_get_value() function which is responsible for loading the value of a given field. Note the multiple apply_filters() calls.

function acf_get_value( $post_id, $field ) {
    
    // Load meta value.
    $value = acf_get_metadata( $post_id, $field['name'] );

    /**
     * Filters the $value after it has been loaded.
     *
     * @date    28/09/13
     * @since   5.0.0
     *
     * @param   mixed $value The value loaded.
     * @param   string $post_id The post ID for this value.
     * @param   array $field The field array.
     */
    $value = apply_filters( "acf/load_value/type={$field['type']}",     $value, $post_id, $field );
    $value = apply_filters( "acf/load_value/name={$field['name']}",     $value, $post_id, $field );
    $value = apply_filters( "acf/load_value/key={$field['key']}",       $value, $post_id, $field );
    $value = apply_filters( "acf/load_value",                           $value, $post_id, $field );
    
    // return
    return $value;
}

So this is how we have done it for years. The value is passed through various filters which are generated using the field’s type, name and key before finally being passed through the generic “acf/load_value” filter.

Not bad, but not great. This approach gets the job done, but leaves much to be desired. How can I hook in before “acf/load_value/type={$field[‘type’]}” without knowing the field’s type? And what’s up with all the repetition? Bloated code much?

One small step

My first thought was to remove the bloat. That was easy, just take out the repetition as a variable and loop over the variations. The end result looked something like this.

foreach( array('type', 'name', 'key', '') as $k ) {
    $sufix = $k ? "/$k={$field[$k]}" : '';
    $value = apply_filters( "acf/load_value$sufix", $value, $post_id, $field );
}

I mean, it works, but, 🤢🤮🤮.

No matter how creative I got with those 4 lines of code, I still faced the ongoing issue that no one could hook in before “acf/load_value/type={$field[‘type’]}” without knowing the field’s type.

Wanting to solve this issue once and for all, I extracted the 3 “variations” out into their own function and hooked that function into the generic filter. This completely solved the issue mentioned above, and made the acf_get_value() code easier to read! Win win 🎉.

function acf_get_value( $post_id, $field ) {
    
    // Load meta value.
    $value = acf_get_metadata( $post_id, $field['name'] );

    /**
     * Filters the $value after it has been loaded.
     *
     * @date    28/09/13
     * @since   5.0.0
     *
     * @param   mixed $value The value loaded.
     * @param   string $post_id The post ID for this value.
     * @param   array $field The field array.
     */
    $value = apply_filters( "acf/load_value", $value, $post_id, $field );
    
    // return
    return $value;
}

// Add variations to filter.
add_filter( "acf/load_value", "_acf_apply_load_value_filters", 10, 3 );

// Apply variations to filter.
function _acf_apply_load_value_filters( $value, $post_id, $field ) {
    $value = apply_filters( "acf/load_value/type={$field['type']}",     $value, $post_id, $field );
    $value = apply_filters( "acf/load_value/name={$field['name']}",     $value, $post_id, $field );
    $value = apply_filters( "acf/load_value/key={$field['key']}",       $value, $post_id, $field );
    return $value;
}

I liked what this solution had to offer, but didn’t appreciate the bloat it would add. Keep in mind that we are not just talking about one or two filters. I would have to roll this out into 20+ actions and filters throughout the plugin.

Designing a new system

This is where things got interesting. I took the idea above and considered how it could be automated. For this to work, I would need a way to define the “blueprint” for each filter variation.

Each blueprint would need to contain the filter name (“acf/load_value”), the variations to apply (“type”, “name”, “key”), and an identifier to find $field within the callback’s parameters. The last one was important because the location of $field within the callback parameters could change from filter to filter.

I would also need a generic callback function that used these blueprints to apply the variations.

🤔🤔🤔…

When designing systems with a few moving parts, I like to start at the end and work my way backwards. It might sound counter-intuitive, but I find that it helps me stay focused.

In this case, I started with what I wanted the final acf_get_value() function to look like, and this is what I ended up with.

function acf_get_value( $post_id, $field ) {
    
    // Load meta value.
    $value = acf_get_metadata( $post_id, $field['name'] );

    /**
     * Filters the $value after it has been loaded.
     *
     * @date    28/09/13
     * @since   5.0.0
     *
     * @param   mixed $value The value loaded.
     * @param   string $post_id The post ID for this value.
     * @param   array $field The field array.
     */
    $value = apply_filters( "acf/load_value", $value, $post_id, $field );
    
    // return
    return $value;
}

// Register variation.
acf_add_filter_variations( 'acf/load_value', array('type', 'name', 'key'), 2 );

This ticked a lot of boxes for me. It kept the filter variations blueprint in a human readable format and was easily accessible near the filter itself.

The “magic” acf_add_filter_variations() would take the filter name to hook into, the variations to apply, and the index at which the $field parameter could be found (needed to generate those variation filters).

Here’s a visualization of all the moving parts.

Stop thinking and start writing

I mentioned before how I like to work backwards when designing a new system. I practice a similar philosophy when it comes to writing code too. This is because I have a bad habit of getting lost searching for a “perfect solution” when I don’t have boundaries to work between.

Knowing how the acf_add_filter_variations() function will be called is a tactic I use to counter this problem. Similar to how a puzzle seems so much easier once you have the edges to work from.

So, I started with the acf_add_filter_variations() function. All this needed to do was store the blueprint and add the generic callback.

Recently, I designed a data storage system in ACF to handle this exact situation and I’ll be sure to write about it next!

The generic callback is where the magic happens. It finds the current filter being run and any blueprints for that filter. Next, it finds the $field parameter and finally loops over and applies the variations!

And here is a what I ended up writing.

// Register store.
acf_register_store('hook-variations');

/**
 * acf_add_filter_variations
 *
 * Registers variations for the given filter.
 *
 * @date    26/1/19
 * @since   5.7.11
 *
 * @param   string $filter The filter name.
 * @param   array $variations An array variation keys.
 * @param   int $index The param index to find variation values.
 * @return  void
 */
function acf_add_filter_variations( $filter = '', $variations = array(), $index = 0 ) {
    
    // Store replacement data.
    acf_get_store('hook-variations')->set( $filter, array(
        'type'          => 'filter',
        'variations'    => $variations,
        'index'         => $index,
    ));
    
    // Add generic handler.
    // Use a priotiry of 10, and accepted args of 10 (ignored by WP).
    add_filter( $filter, '_acf_apply_hook_variations', 10, 10 );
}

/**
 * acf_add_action_variations
 *
 * Registers variations for the given action.
 *
 * @date    26/1/19
 * @since   5.7.11
 *
 * @param   string $action The action name.
 * @param   array $variations An array variation keys.
 * @param   int $index The param index to find variation values.
 * @return  void
 */
function acf_add_action_variations( $action = '', $variations = array(), $index = 0 ) {
    
    // Store replacement data.
    acf_get_store('hook-variations')->set( $action, array(
        'type'          => 'action',
        'variations'    => $variations,
        'index'         => $index,
    ));
    
    // Add generic handler.
    // Use a priotiry of 10, and accepted args of 10 (ignored by WP).
    add_action( $action, '_acf_apply_hook_variations', 10, 10 );
}

/**
 * _acf_apply_hook_variations
 *
 * Applys hook variations during apply_filters() or do_action().
 *
 * @date    25/1/19
 * @since   5.7.11
 *
 * @param   mixed
 * @return  mixed
 */
function _acf_apply_hook_variations() {
    
    // Get current filter.
    $filter = current_filter();
    
    // Get args provided.
    $args = func_get_args();
    
    // Get variation information.
    $variations = acf_get_store('hook-variations')->get( $filter );
    extract( $variations );
    
    // Find field in args using index.
    $field = $args[ $index ];
    
    // Loop over variations and apply filters.
    foreach( $variations as $variation ) {
        
        // Get value from field.
        // First look for "backup" value ("_name", "_key").
        if( isset($field[ "_$variation" ]) ) {
            $value = $field[ "_$variation" ];
        } elseif( isset($field[ $variation ]) ) {
            $value = $field[ $variation ];
        } else {
            continue;
        }
        
        // Apply filters.
        if( $type === 'filter' ) {
            $args[0] = apply_filters_ref_array( "$filter/$variation=$value", $args );
        
        // Or do action.
        } else {
            do_action_ref_array( "$filter/$variation=$value", $args );
        }
    }
    
    // Return first arg.
    return $args[0];
}

And it totally works! Developers can now hook in before or after the “variations” are applied by using a priority less than or greater than 10. Developers can also hook in using the variation they wish. Here’s a little test to demonstrate.

// Apply filter variations.
acf_add_filter_variations( 'test_filter', array('type', 'name', 'key'), 1 );

// Hook in before variations.
add_filter('test_filter', function( $value, $field ){
    echo 'Before All';
    return $value;
}, 9);

// Hook in after variations.
add_filter('test_filter', function( $value, $field ){
    echo 'After All';
    return $value;
}, 11);

// Hook in during variation.
add_filter('test_filter/key=field_123456', function( $value, $field ){
    echo 'During Specific';
    return $value;
});

// Get field "field_123456". 
$field = acf_get_field('field_123456');

// Apply filter
$value = apply_filters( 'test_filter', "Some value", $field );
Before All
During Specific
After All

Wrapping up 🎁

I love building these systems. They represent a fun challenge that is just the right size to sink your teeth into without getting frustrated.

I got a chance to dig deeper into the WP hook system and found some really interesting functions like current_filter() and apply_filters_ref_array(). I’m always fascinated with the programmatic gems that lie in WP core.

I also got another chance to make use of our acf_register_store() function which I’ll write about soon.

As a result, I can now remove a lot of duplicated code throughout the ACF plugin and provide more functionality to developers. For those who peek through the ACF core code, you will start to see these new “variations” functions roll out over the coming versions.

But what I enjoy most about a system like this is knowing there is now a structured process for doing a specific task. I have written the instruction guide – so to speak – for a future Elliot when I next add a new field filter!

✌️

About the Author