Actions and Filters: The Difference That Trips Everyone Up

A filter is asked for a value and an action is not. Why returning something from an action does nothing, why forgetting to return from a filter wipes your data.

WordPress actions vs filters showing side effects, returned values, and hook callback behavior

Both are registered with a function name. Both are called with a hook name and a callback. The two registration functions sit next to each other in every tutorial. And then one day you return a value from a callback, nothing happens, and there is no error to tell you why.

The rule is one sentence. A filter is asked for a value and must hand one back. An action is not asked for anything and whatever it returns is thrown away.

Everything else follows from that.

What the two calls actually do

On the WordPress side, the two are not really different mechanisms. They are two different questions asked of the same registry.

An action fires with do_action:

do_action( 'save_post', $post_id, $post, $update );

WordPress calls every registered callback in turn, passes them the arguments, and ignores the return values entirely. The point of the call is the side effect: you wrote a row, cleared a cache, sent a notification.

A filter fires with apply_filters:

$title = apply_filters( 'the_title', $title, $post_id );

WordPress calls every registered callback in turn, and each one’s return value becomes the first argument to the next one. The final callback’s return value is what the caller gets. The point of the call is the value.

That chaining is the whole design. A filter is a pipeline, and your callback is one stage in it.

Failure one: returning from an action

add_action( 'save_post', function ( $post_id ) {
    return sanitize_text_field( $_POST['my_field'] );  // goes nowhere
} );

This is not a syntax error and it is not a warning. do_action discards the return value, so the code runs, returns, and changes nothing. If you meant to store that value, store it:

add_action( 'save_post', function ( $post_id ) {
    if ( ! isset( $_POST['my_field'] ) ) {
        return;
    }
    update_post_meta( $post_id, 'my_field', sanitize_text_field( $_POST['my_field'] ) );
} );

Note the bare return in the guard clause. That one is fine: it is exiting the callback early, not handing a value back. A bare return in an action means stop; a return $something in an action means you have misread the hook.

Failure two: not returning from a filter

This is the expensive one, because it destroys data rather than doing nothing.

add_filter( 'the_content', function ( $content ) {
    $content .= '<p>Read more of our work.</p>';
    // no return
} );

The callback returns null. That null is passed to the next filter in the chain, and eventually to the caller. Every post on the site renders empty. The content is not deleted from the database, but every visitor sees a blank page body, and nothing in any log says why.

If your callback is registered with add_filter, every path out of it returns a value. Including the paths where you decided not to change anything:

add_filter( 'the_content', function ( $content ) {
    if ( ! is_singular( 'post' ) ) {
        return $content;          // unchanged, but returned
    }
    return $content . '<p>Read more of our work.</p>';
} );

An early return; with no value in a filter is the same bug as no return at all.

Failure three: the callback that takes the wrong number of arguments

Both registration functions take a fourth argument, the accepted argument count, and it defaults to 1.

add_filter( 'the_title', 'my_title', 10 );        // callback gets $title only
add_filter( 'the_title', 'my_title', 10, 2 );     // callback gets $title, $post_id

If your callback signature declares two parameters and you did not raise the count, PHP will be called with one argument and will complain about the missing one, or will silently use a default if you gave one. The symptom is a function that behaves as though the second parameter is always empty.

The reverse also bites: raising the count above what the hook actually passes gets you an argument that does not exist. Check what the do_action or apply_filters call site passes before you set the number.

Which one is a given hook?

Two reliable tests, in order of speed.

The name is a weak hint. Hooks that read as events (init, save_post, wp_footer, admin_notices) are usually actions. Hooks that read as nouns (the_title, the_content, upload_mimes, body_class) are usually filters. This is a convention, not a rule, and there are enough exceptions that it is a starting point only.

The call site is definitive. Search core for the hook name. If you find do_action( 'x' ) it is an action; if you find apply_filters( 'x', $value ) it is a filter, and the second argument tells you exactly what type you are expected to return. That five-second search settles it every time, and it also shows you which arguments are available, which answers the argument-count question in the same look.

A hook can also be both, under the same name, in different places. That is rare and it is always deliberate. The call site still settles it.

Why the debugging is so unpleasant

Neither failure raises anything. A discarded return value is legal PHP. A null flowing down a filter chain is legal PHP. You get wrong output with a clean error log, which is the same shape as any other silent failure, and it responds to the same technique: assert on the value at each stage rather than looking for a crash, exactly as described in debugging code that runs but gives the wrong result.

There is one more variable that makes the same code behave differently on two installs, and it is not the action-versus-filter question at all: it is when your callback ran relative to everything else, which depends on the stage you hooked into and on priority. If your filter is correct and still sees stale data, the problem is timing rather than type, and the map of what exists at each stage is laid out in the WordPress execution order.

The short version

  • add_action for a side effect. Return nothing, or return; to bail out early.
  • add_filter for a value. Return on every path, including the do-nothing path.
  • Set the fourth argument when your callback needs more than one parameter.
  • When unsure, find the do_action or apply_filters call site. It answers the type and the arguments together.
Written by

Shah Alom

Leave a Reply

Your email address will not be published. Required fields are marked *