-
Notifications
You must be signed in to change notification settings - Fork 6
The Observer Pattern
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.
WordPress' hook system is powerful, but it has a few quirks that Underpin's observer pattern solves.
- 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.
- 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
anddeps
(dependencies). - 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 viaadd_action
. Underpin works around this by always using 2 arguments - one argument is the action ID, and the other is anAccumulator
object, which contains the resulting value, we well as a place to store arbitrary information throughout the life of the hook call. - 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 observer pattern is compromised of 4 key elements:
- A notification - This is what "notifies" the system that an event happens, kind-of like
do_action
- An attachment - This is what "attaches" to a notification, and fires when notified. Kind-of like
add_action
- Accumulator - This is a class that stores data, and gets passed to each item as a chain of attached notifications run.
- 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.
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.
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.
-
attach
is called on$some_class_instance
beforesay_hello
. This call attaches anObserver
to the event with a key ofhello
. - The
Observer
class that gets attached adds some arguments, including a name, a description, and the function that gets called when notified. - The callback has 2 arguments, the
Some_Class $instance
and anAccumulator
object, which contains all information provided in the second argument ofattach
.
The example above used a basic notification, notify
, which is like to do_action
, but there are other ways an event
can run.
- Notify - Used to let a system know that this action ran. Ideal for asynchronous tasks.
- Filter - Used to make it possible to change the value of something.
- Middleware - A special action that can only be set up when a class is first created. Used to implement middleware patterns inside classes.
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:
- this_runs_first
- this_runs_second
The example above would output:
Hello, Alex!
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:
- this_runs_first
- this_runs_second
The example above would output:
It is time to say Hello to Alex!
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:
- a string that references an instance of
Script_Middleware
(see example above). - 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.
},
] ),
],
] );
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. */]
] );
}
} );
The order in-which attached items runs depends on a few things:
- What dependencies are set in the
Observer
when-attached - The priority value set in the
Observer
Items are first-sorted by dependencies, and then sorted by priority.
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:
- this_runs_first
- this_runs_second
- this_runs_third
- this_runs_last
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:
- this_runs_first
- this_runs_second
- 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:
- action_branch_1
- action_branch_1_this_runs_first
- action_branch_1_this_runs_second
- action_branch_1_this_runs_third
- action_branch_2
- action_branch_2_this_runs_first
- action_branch_2_this_runs_second
- action_branch_2_this_runs_third
- action_branch_2_this_runs_third_sub_item
However, if for some reason action_branch_2
was removed, only these items would run:
- action_branch_1
- action_branch_1_this_runs_first
- action_branch_1_this_runs_second
- 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.