WordPress hooks, method visibility and converting a callable into closure

The documentation page of Actions details what actions are and how to use them.

Probably, for simplicity's sake, they only give examples with named functions:

function wpdocs_save_posts() {
    // do something
}

add_action( 'init', 'wpdocs_save_posts' );

Those who may wonder how actions work in a class can find an example on the add_action function reference page, under the user-contributed notes.

Here's the contributed example with the note:

To use add_action() when your plugin or theme is built using classes, you need to use the array callable syntax. You would pass the function to add_action() as an array, with $this as the first element, then the name of the class method...

class WP_Docs_Class {
    public function __construct() {
        add_action( 'save_post', array( $this, 'wpdocs_save_posts' ) );
    }

    public function wpdocs_save_posts() {
        // do stuff here...
    }
}

$wpdocsclass = new WP_Docs_Class();

Hooks in the constructor

Further down the page, another contributor, Bartek, rightfully points out that this upvoted example promotes a bad practice:

I urge you, don’t attach your hook callbacks inside class’ constructor. Instead of implementing official example most upvoted in this thread, opt for decoupled solution. You have one more line of code to write, but objects become more reusable and less error-prone (consider, what would happen if you call new WP_Docs_Class() twice in your code, following Codex example).

class WP_Docs_Class {
    public function hooks() {
        add_action( 'save_post', array( $this, 'wpdocs_save_posts' ) );
    }

    public function wpdocs_save_posts() {
        // do stuff here...
    }
}

$wpdocsclass = new WP_Docs_Class();
$wpdocsclass->hooks();

To answer the rhetoical question, the wpdocs_save_posts funtion would be called twice. Depending on what we do in the wpdocs_save_posts it might be more or less problematic.

The visiblity of the callback method

There's one more important detail to unpack about how hooks are used in classes, and that's the visibility of the callback methods.

We see in the example that wpdocs_save_posts is set to public. If that's changed to private or protected, a fatal error is thrown:

Fatal error: Uncaught TypeError: call_user_func_array(): Argument #1 ($callback) must be a valid callback, cannot access private method

Typically, we give a lot of importance to the visibility of our methods.

If the method is public, that means it's callable anytime, anywhere, by anybody.

$wpdocsclass = new WP_Docs_Class();
$wpdocsclass->wpdocs_save_posts();

There's a chance that's not what we want with WordPress actions.

We might want the method to be called only by the action, but we have to keep it public because otherwise it would not work.

There's actually a way to solve this.

Converting a callable into a closure

We can wrap the callback in the Closure class' static fromCallable method.

With this, we can change the wpdocs_save_posts to private (or protected):

class WP_Docs_Class {
    public function hooks() {
        add_action( 'save_post', \Closure::fromCallable( ( array( $this, 'wpdocs_save_posts' ) ) );
    }

    private function wpdocs_save_posts() {
        // do stuff here...
    }
}

Or we can use the first class callable syntax which is avaliable since PHP 8.1:

class WP_Docs_Class {
    public function hooks() {
        add_action( 'save_post', ( array( $this, 'wpdocs_save_posts' ) )( ... );
    }

    private function wpdocs_save_posts() {
        // do stuff here...
    }
}

While we reduced the chance of others calling methods we do not want, one of the downsides appears when we want to test these.

Likely these "action" methods are the "primary" methods on the class, and if they are private they are harder but not impossible to test.