Skip to content

The Observer Pattern

Alex Standiford edited this page Nov 30, 2021 · 1 revision

Instead of using add_action, do_action, add_filter and apply_filters, Underpin works with a robust observer pattern built-in. This standardizes the extending process for plugins, provides more-robust priority settings, and encapsulates the action that runs in a class that can be extended.

The Problem With WordPress Hooks

WordPress' hook system is powerful, but it has a few quirks that Underpin's observer pattern solves.

  1. Filters rarely have a way to enforce a returned filter type, and instead can be set to anything even if it breaks the system. Underpin resolves this by providing a callback on filters that ensures an item returns the proper type before setting it.
  2. Both actions and filters solely rely on a priority argument to determine the order in-which items should be ran. This causes plugins to need to hash out in what order they need to fire, and can sometimes cause frustraing issues with load order. Underpin helps mitigate this by using a combination of priority and deps (dependencies).
  3. When an action is added, you have to know how many arguments are in the callback, and provide that information. This can lead to breaking changes should the number of arguments ever need to change. This also causes an issue with remove_action because you have to know the number of arguments that are used when it's registered via add_action. Underpin works around this by always using 2 arguments - one argument is the action ID, and the other is an Accumulator object, which contains the resulting value, we well as a place to store arbitrary information throughout the life of the hook call.
  4. Action keys are in the global context. This means that all hooks have to have a unique name in-order to work without colliding with other actions. This also means that any other plugin can hook into an action you created, and potentially make changes that you didn't intend. Underpin solves this by making the actions to register an action inside PHP classes, where access can be controlled, and actions can be scoped to the class.

The Anatomy of The Pattern

The observer pattern is compromised of 4 key elements:

  1. A notification - This is what "notifies" the system that an event happens, kind-of like do_action
  2. An attachment - This is what "attaches" to a notification, and fires when notified. Kind-of like add_action
  3. Accumulator - This is a class that stores data, and gets passed to each item as a chain of attached notifications run.
  4. Observer - This class is what gets "attached" to an event, and the information in this class is what actually runs when a notification is fired.

The Notification

A notification notifies the system that an event happened. This is roughly like calling apply_filters or do_action , only instead of it being called arbitrarily, and anywhere, it's called within a PHP class. For example:

use Underpin\Traits\With_Subject

class Some_Class{
  use With_Subject; // This trait contains all methods to make this class observe-able.
  
  public function say_hello( $some_arg ){
    // This "notifies" the system that this method ran, and any action attached to it will run.
    // This is kind-of like do_action
    echo 'It is time to say "hello"!';
    $this->notify( 'hello', ['argument_1' => $some_arg, 'argument_2' => 'another argument'] );
  }
}

This is a trivial example, and there's actually other types of notifications, too. See Notifications for more information on these different methods.

Attachment

When a notification fires, anything that is attached to that notification will also run. Attachments expect an Observer instance, containing the information needed to execeute an action when notified. Let's take the example above and expand on it:

$some_class_instance = new Some_Class();

$some_class_instance->attach( 'hello', new Observer( 'action_id', [
  'name'        => 'Action Name', //Used for debugging purposes
  'description' => 'Custom action that runs', //Used for debugging purposes
  'update'      => function(Some_Class $instance, Accumulator $accumulator){
    echo 'hello, ' . $accumulator->argument_1;
  },
] ); );

$some_class_instance->say_hello( 'Alex' );

The example above would output:

It is time to say hello!
hello, Alex

There's a lot going on here, so let's break down the pieces.

  1. attach is called on $some_class_instance before say_hello. This call attaches an Observer to the event with a key of hello.
  2. The Observer class that gets attached adds some arguments, including a name, a description, and the function that gets called when notified.
  3. The callback has 2 arguments, the Some_Class $instance and an Accumulator object, which contains all information provided in the second argument of attach.

Event Notification Types

The example above used a basic notification, notify, which is like to do_action, but there are other ways an event can run.

  1. Notify - Used to let a system know that this action ran. Ideal for asynchronous tasks.
  2. Filter - Used to make it possible to change the value of something.
  3. Middleware - A special action that can only be set up when a class is first created. Used to implement middleware patterns inside classes.

Notifications

Notifications simply notify observers that the event ran. Each observer runs, but nothing gets returned to the action. No params get returned from this - actions simply run.

use Underpin\Traits\With_Subject

class Some_Class{
  use With_Subject; // This trait contains all methods to make this class observe-able.
  
  public function say_a_thing( $some_arg ){
    // This "notifies" the system that this method ran, and $value will be set to whatever the Accumulator's state is when done.
    // This is kind-of like apply_filters
    $this->notify( 'what_to_say', ['argument_1' => $some_arg, 'argument_2' => 'another_argument']);
  }
}
$some_class_instance = new Some_Class();
$some_class_instance->attach('unique_action_id', new Observer('this_runs_second', [
  'update' => function($instance, Accumulator $accumulator){
    echo 'Alex!';
  }
  'deps' => ['this_runs_first'] // List of actions that must run before this. If the dependency doesn't exist, this filter does not run.
]));

$some_class_instance->attach('unique_action_id', new Observer('this_runs_first', [
  'update' => function($instance, Accumulator $accumulator){
    echo "Hello, ";
  }
]));

echo $some_class_instance->say_a_thing( $some_arg );

The items above would run in this order:

  1. this_runs_first
  2. this_runs_second

The example above would output:

Hello, Alex!

Filters

Filters are always provided with 2 params, the current class instance, and an Accumulator object. When all hooked filters finish running, the state of the Accumulator is returned. This can be changed with Accumulator::set_state inside the update action of the Observer instance. Like this:

use Underpin\Traits\With_Subject

class Some_Class{
  use With_Subject; // This trait contains all methods to make this class observe-able.
  
  public function say_a_thing( $some_arg ){
    // This "notifies" the system that this method ran, and $value will be set to whatever the Accumulator's state is when done.
    // This is kind-of like apply_filters
    $value = $this->apply_filters( 'what_to_say', new Accumulator([
      'default'        => 'hello!', // By default, what_to_say is "hello!"
      'valid_callback' => function( $state, \Underpin\Factories\Accumulator $accumulator ){
        return is_string($state); // Only allow the result to update if it is a string.
      },
      'argument_1' => $some_arg,
      'argument_2' => 'another_argument'
    ]) );
    
    echo 'It is time to say ' . $value; 
  }
}
$some_class_instance = new Some_Class();
$some_class_instance->attach('unique_action_id', new Observer('this_runs_second', [
  'update' => function($instance, Accumulator $accumulator){
    $accumulator->set_state('Hello to Alex!');
  }
  'deps' => ['this_runs_first'] // List of actions that must run before this. If the dependency doesn't exist, this filter does not run.
]));

$some_class_instance->attach('unique_action_id', new Observer('this_runs_first', [
  'update' => function($instance, Accumulator $accumulator){
    $accumulator->set_state('Hello to Kate!'); // This gets set first, but is overridden by this_runs_second
  }
]));

echo $some_class_instance->say_a_thing( $some_arg );

The items above would run in this order:

  1. this_runs_first
  2. this_runs_second

The example above would output:

It is time to say Hello to Alex!

Middleware

Unlike Filters and notifications, this pattern always runs when a loader item is registered, and only runs once. This pattern makes it possible to do a set of things when a loader item is registered. This allows you to use composition patterns to determine actions that a loader should do instead of relying solely on inheritance patterns. A good example of middleware in-action can be seen in the script loader.

// Register script
plugin_name()->scripts()->add( 'test', [
        'handle'      => 'test',
        'src'         => 'path/to/script/src',
        'name'        => 'test',
        'description' => 'The description',
        'middlewares' => [
          'Underpin_Scripts\Factories\Enqueue_Admin_Script'
        ]
] );

// Enqueue script
$script = underpin()->scripts()->get('test')->enqueue();

The above middlewares array would automatically cause the test script to be enqueued on the admin page. Multiple middlewares can be added in the array, and each one would run right after the item is added to the registry.

The middlewares array uses Underpin::make_class to create the class instances. This means that you can pass either:

  1. a string that references an instance of Script_Middleware (see example above).
  2. An array of arguments to construct an instance of Script_Middleware on-the-fly (see example below).
underpin()->scripts()->add( 'test', [
	'handle'      => 'test',
	'src'         => 'path/to/script/src',
	'name'        => 'test',
	'description' => 'The description',
	'middlewares' => [
		'Underpin_Rest_Middleware\Factories\Rest_Middleware', // Will localize script params.
		'Underpin_Scripts\Factories\Enqueue_Script',          // Will enqueue the script on the front end all the time.
         new Observer( 'custom_middleware', [                 // A custom middleware action
             'update'   => function ( $class, $accumulator ) {
                 // Do an action when this script is set-up.
             },
         ] ),
	],
] );

Using Middleware In Your Loader

The easiest way to use middleware in your loader is with the Middleware trait. Using the shortcode example above again:

class Post_Type_Shortcode extends \Underpin\Abstracts\Shortcode {
    use \Underpin\Traits\With_Middleware;
    
	public function __construct( $post_type ) {
		$this->shortcode = $post_type . '_is_the_best';

		$this->post_type = $post_type;
	}

	public function shortcode_actions() {
		echo $this->post_type . ' is the best post type';
	}
}

You could then register much like before, only now you can provide middleware actions.

add_action( 'init', function() {
	$post_types    = get_post_types( [], 'objects' );

	foreach ( $post_types as $post_type ) {
         $this->shortcodes()->add( $post_type->name . '_shortcode', [
             'class'       => 'Flare_WP\Shortcodes\Post_Type_Shortcode',
             'args'        => [ $post_type ],
             'middlewares' => [/* Add middleware references here. */]
         ] );
     }
} );

Observer Priority

The order in-which attached items runs depends on a few things:

  1. What dependencies are set in the Observer when-attached
  2. The priority value set in the Observer

Items are first-sorted by dependencies, and then sorted by priority.

Middleware, Action, Filter Sorting

Middleware, Action, and Filter each run items in ascending order. The lowest priority runs first, and set dependencies always run before the observer with the dependencies. Like so:

$class->attach( 'example', new Observer( 'this_runs_first', [/**/] ) );
$class->attach( 'example', new Observer( 'this_runs_last', [
  'deps'     => ['this_runs_first'],
  'priority' => 3
] ) );
$class->attach( 'example', new Observer( 'this_runs_third', [
  'deps'     => ['this_runs_first'],
  'priority' => 2
] ) );
$class->attach( 'example', new Observer( 'this_runs_second', [
  'deps'     => ['this_runs_first'],
  'priority' => 1
] ) );

These items would run as:

  1. this_runs_first
  2. this_runs_second
  3. this_runs_third
  4. this_runs_last

Dependencies

In-addition to the sorting functionality illustrated above, dependencies also determine if an action should run at all . If an observer item is not set, any items that depend on that observer will not run either.

Let's assume all these items are simply running with attach, and with highest priorities running first.

$class->attach( 'example', new Observer( 'this_runs_first', [/**/] ) );
$class->attach( 'example', new Observer( 'this_runs_last', [
  'deps'     => ['this_runs_first'],
  'priority' => 3
] ) );
$class->attach( 'example', new Observer( 'this_runs_third', [
  'deps'     => ['non_existent_dependency'], // Since this dependency does not exist, this does not run.
  'priority' => 2
] ) );
$class->attach( 'example', new Observer( 'this_runs_second', [
  'deps'     => ['this_runs_first'],
  'priority' => 1
] ) );

These items would run as:

  1. this_runs_first
  2. this_runs_second
  3. this_runs_last

This can also cause a chain-reaction where entire branches of observed actions do not run because a single dependency is not included:

$class->attach( 'example', new Observer( 'action_branch_1', [/**/] ) );
$class->attach( 'example', new Observer( 'action_branch_1_this_runs_last', [
  'deps'     => ['action_branch_1'],
  'priority' => 3
] ) );
$class->attach( 'example', new Observer( 'action_branch_1_this_runs_third', [
  'deps'     => ['action_branch_1'], // Since this dependency does not exist, this does not run.
  'priority' => 2
] ) );
$class->attach( 'example', new Observer( 'action_branch_1_this_runs_second', [
  'deps'     => ['action_branch_1'],
  'priority' => 1
] ) );

$class->attach( 'example', new Observer( 'action_branch_2', [/**/] ) );
$class->attach( 'example', new Observer( 'action_branch_2_this_runs_last', [
  'deps'     => ['action_branch_2'],
  'priority' => 3
] ) );
$class->attach( 'example', new Observer( 'action_branch_2_this_runs_third', [
  'deps'     => ['action_branch_2'], // Since this dependency does not exist, this does not run.
  'priority' => 2
] ) );
$class->attach( 'example', new Observer( 'action_branch_2_this_runs_third_sub_item', [
  'deps'     => ['action_branch_2_this_runs_third'], // Since this dependency does not exist, this does not run.
] ) );
$class->attach( 'example', new Observer( 'action_branch_2_this_runs_second', [
  'deps'     => ['action_branch_2'],
  'priority' => 1
] ) );

The above would run:

  1. action_branch_1
  2. action_branch_1_this_runs_first
  3. action_branch_1_this_runs_second
  4. action_branch_1_this_runs_third
  5. action_branch_2
  6. action_branch_2_this_runs_first
  7. action_branch_2_this_runs_second
  8. action_branch_2_this_runs_third
  9. action_branch_2_this_runs_third_sub_item

However, if for some reason action_branch_2 was removed, only these items would run:

  1. action_branch_1
  2. action_branch_1_this_runs_first
  3. action_branch_1_this_runs_second
  4. action_branch_1_this_runs_third

Notice this also removed action_branch_2_this_runs_third_sub_item, even though it didn't directly depend on action_branch_2. This is because the item it depends on, action_branch_2_this_runs_third does depend on action_branch_2, so by proxy action_branch_2_this_runs_third_sub_item depends on it, and does not run.