Skip to content

Modular plugin architecture

Niklas Gutberlet edited this page May 11, 2023 · 3 revisions

To better understand how PayPal Payments works, here are some details about the concepts behind it.

Basic concepts

A few key concepts are purposely over-simplified:

  • PayPal Payments implements "modularity": Every "piece of logic" of a complex plugin is considered a "module", that you can see as a small plugin on its own, and so the actual plugin is essentially a collection of multiple smaller plugins.
  • When using OOP in WordPress, one of the issues is how to access a specific instance of classes, considering for example that all the hook functions that use $this cannot be removed. A strategy often used in WP is global variables, in more modern PHP is the "registry pattern" and/or the "dependency injection container".
  • PayPal Payments implements modularity together with the "registry pattern": all modules share a single "container" where objects are stored by key, and can be retrieved by key. Each "module" does two things: first "register" services (objects) in the Container, then uses those services to do things in WordPress hooks.
  • And you'll see there how each "module" there, like for example the Button module has 2 methods: setup() where services get registered, and run() where services are retrieved and used to be attached to hooks.
  • With these concepts, we now understand that we have multiple paths for plugin customization
  • We can add new modules
  • We can modify or remove existing modules

1 - Removing logic from the plugin

  • Let's assume we want to remove a piece of logic from the plugin:

    1. Because the plugin logic is split into modules, we could remove an entire module. That way, you don't need to remove one-by-one all the hooks that the module adds: you can eradicate an entire piece of the plugin. Be careful that by removing a module, you not only remove what it does in its run() method (using services), but also remove what it does in its setup() method (registering services). This means that if another module uses services normally registered by the module you removed, that other method will not find the services it needs and break.
    2. Because the plugin uses the registry pattern, you have a way to access by key all the objects used by the plugin (and also a way to "extend" them, but that's another story) which means you'll be able, for example, to remove the hooks that use object instances. And that without the plugin to use global variables.

To remove an entire module (or adding new modules for that sake), the plugin offers a filter hook. The passed array contains instances of module objects. This means in your custom module, you can do:

use WooCommerce\PayPalCommerce\Button\ButtonModule;

add_filter(
	'woocommerce_paypal_payments_modules',
	function (array $modules) {
		$filtered = [];
		foreach ($modules as $module) {
			if (!($module instanceof ButtonModule)) {
				$filtered[] = $module;
			}
		}
		return $filtered;
	}
);

2 - Removing specific hooks

About removing specific hooks, as said above, by accessing the container we can remove all the hooks that use services. But that means we need to access the container. The plugin exposes the container here.

But even if we can access the services via the container, we can't remove those hooks because the relevant class uses closures over objects retrieved in the container. That means, at least, not without using tricky things or waiting for the WordPress core to supports removing hooks using closures.

The "structured" way to accomplish what you need is to extend the services in the container.

If you look at this hook for example and at this hook the plugin makes use of a SmartButtonInterface object that can be accessed via $container->get('button.smart-button') if you have access to the container.

That object is normally an instance of this class, but there is also a "disabled button" and if you were able to make the container return that "disabled button" instead of the normal button when $container->get('button.smart-button') is called, then you could ignore the fact you can't remove the hook: the hook will stay, the object method will be called, but it'll do nothing.

Turns out that making the container return something different for an existing key is something pretty common when using these patterns, and it is usually referred to as "extending services".

The best way to do this depends on the container implementation, and PayPal Payments uses this one: https://github.com/Dhii/containers

To extend that service in the container, you must do the following.

First create a module:

use Dhii\Container\ServiceProvider;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface;
use Interop\Container\ServiceProviderInterface;
use WooCommerce\PayPalCommerce\Button\Assets\DisabledSmartButton;

class MyButtonModule implements ModuleInterface
{
	public function setup(): ServiceProviderInterface {
		return new ServiceProvider([], [
			'button.smart-button' => function () {
				return new DisabledSmartButton();
			}
		]);
	}
	
	public function run(): void {}
}

Then add it to the plugin (using the filter shown above to remove a module):

add_filter(
	'woocommerce_paypal_payments_modules',
	function (array $modules) {
		$modules[] = new MyButtonModule(); // the class above
		
		return $modules;
	}
);

While this is a powerful way to customize anything the plugin does by giving you the possibility to extend every single object the plugin uses, it is definitively overkill to just remove a hook.

Solutions could be:

  • making sure all the hooks uses objects methods instead of closures, that way having access to the objects via the container one could remove those hooks
  • implement ad-hoc solutions. For example, changing these lines from:
if ( ! $this->can_save_vault_token() && $this->has_subscriptions() ) {
	return false;
}

to:

if ( 
	! ( ! $this->can_save_vault_token() && $this->has_subscriptions() )
	|| ! apply_filters( 'woocommerce_paypal_payments_use_button', true )
) {
	return false;
}

to prevent the button to render stuff, you could also just do:

add_filter('woocommerce_paypal_payments_use_button', '__return_false');

This is just one example of how a module could be removed without changing the plugin code directly.

3 - Adding a new module

This section has not yet been reviewed.

3 - Modifying an existing new module

This section has not yet been reviewed.

4 - Example module

This section has not yet been reviewed.