Great practice for the plugin bootstrap file

13 mins
81

After a hot discussion around the vision of the plugin bootstrap file inside a tweet by Mark Jaquith, I’ve decided to show my vision for the plugin bootstrap file. I agree with a lot of points, but about these, it will be later.

Before discuss I had a draft of my plugin boilerplate for WordPress.

The code of the main file is plugin-name.php was:

<?php
// Comments for plugin.

use PluginName\Plugin;

( new Plugin() )->run();

and main class is PluginName\Plugin was:

<?php

namespace PluginName;

use PluginName\Admin\Settings;
use PluginName\Front\Front;

class Plugin {
	public function run(): void {
		is_admin()
			? $this->run_admin()
			: $this->run_front();
	}
	private function run_admin(): void {
		( new Settings() )->hooks();
	}

	private function run_front(): void {
		( new Front() )->hooks();
	}
}

The huge problem of the main class is hard dependencies.
To get rid of them in the PluginName\Plugin class, we have to use the Dependency Injection Container (DIC).

Dependency Injection Container

Installing DIC and configuring it (I prefer Symfony’s components, but you can use different DIC):

composer require symfony/dependency-injection
composer require symfony/config

Create the configuration file dependencies/services.php (but you can also use YML or XML format):

<?php

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

return function ( ContainerConfigurator $configurator ) {
	$services = $configurator->services();
	$services->set( 'settings', 'PluginName\Admin\Settings' );
	$services->set( 'front', 'PluginName\Front\Front' );
};

In simple terms, a unique slug was created for each plugin class. For PluginName\Admin\Settingssettings, and for PluginName\Front\Frontfront.

In addition, in this config, you can specify objects that need to be passed to the constructor or any other method.

To create a DIC and connect a configuration file, we need to write the following code:

<?php

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

require_once __DIR__ . '/vendor/autoload.php';

$container_builder = new ContainerBuilder();
$loader            = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) );
$loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' );

Let’s update the bootstrap file plugin-name.php and pass DIC to the PluginName\Plugin constructor:

<?php

use PluginName\Plugin;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

define( 'PLUGIN_NAME_PATH', plugin_dir_path( __FILE__ ) );

require_once PLUGIN_NAME_PATH . 'vendor/autoload.php';

$container_builder = new ContainerBuilder();
$loader            = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) );
$loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' );
$plugin_name = new Plugin( $container_builder );
$plugin_name->run();

We’ve got rid of all hard dependencies completely and now this object looks much better and will be easy as pie to test.

Plugin launch on the plugins_loaded action

Postpone the plugin launch until the plugins_loaded action. To do this, create a run_plugin_name function in which we wrap the entire plugin call and add it to the plugins_loaded action:

<?php

use PluginName\Plugin;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

define( 'PLUGIN_NAME_PATH', plugin_dir_path( __FILE__ ) );

add_action( 'plugins_loaded', 'run_plugin_name' );
function run_plugin_name() {
	require_once PLUGIN_NAME_PATH . 'vendor/autoload.php';

	$container_builder = new ContainerBuilder();
	$loader            = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) );
	$loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' );
	$plugin_name = new Plugin( $container_builder );
	$plugin_name->run();
}

This trick allows you to enable/disable the plugin with just one remove_action. This is very useful for example when calling AJAX/REST API/WP CLI. Situations are different, but we make it easy to manage the plugin in the theme or other plugins code.

Plugin launch action

Let’s go a little further. And add the plugin_name_init action after starting the plugin:

<?php

use PluginName\Plugin;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

define( 'PLUGIN_NAME_PATH', plugin_dir_path( __FILE__ ) );

function run_plugin_name() {
	require_once PLUGIN_NAME_PATH . 'vendor/autoload.php';

	$container_builder = new ContainerBuilder();
	$loader            = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) );
	$loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' );
	$plugin_name = new Plugin( $container_builder );
	$plugin_name->run();

	do_action( 'plugin_name_init', $plugin_name );
}

add_action( 'plugins_loaded', 'run_plugin_name' );

During plugin_name_init action, we have an object of the main plugin class and hooks of all other plugin objects already fully added.

Add the getter for DIC into the main class PluginName/Plugin:

<?php

// ...

class Plugin {
	// ...
	public function get_service( string $container_name ): ?object {
		return $this->container_builder->get( $container_name );
	}
	// ...
}

As a result, we can disable absolutely any actions/filters of our plugin. For example, in the PluginName\Front\Front class, we’ve added styles to queue:

<?php

namespace PluginName\Front;

class Front {

	public function hooks(): void {
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] );
	}

	public function enqueue_styles() {
		//...
	}
}

We can disable hooks using plugin_name_init action, get the main plugin object and get PluginName\Front\Front object using DIC and getter for it.

function remove_plugin_name_actions( $instance ) {
    $front = $instance->get_service( 'front' );
    if ( ! $front ) {
        return;
    }
    remove_action( 'wp_enqueue_scripts', [ $front, 'enqueue_styles' ] );
}

add_action( 'plugin_name_init', 'remove_plugin_name_actions' );

Great, isn’t it?

Using DIC is cool and great, but don’t forget that other plugins/themes may also use it and one fine day, you will run into version conflicts for your dependencies. Therefore, prefixes must be added for all third party packages.

Dependency prefixes

Mozart, which offered Mark Jaquith, is generally convenient, but he couldn’t cope with the prefixes for these packages, I hope someday it will learn this one. Therefore, I’ve used php-scoper. First, you need to install it:

composer require bamarni/composer-bin-plugin --dev
composer bin php-scoper config minimum-stability dev
composer bin php-scoper config prefer-stable true
composer bin php-scoper require --dev humbug/php-scoper

Create the scoper.inc.php configuration file:

<?php

use Isolated\Symfony\Component\Finder\Finder;

return [
	'prefix'                     => 'PluginName\\Vendor',
	'whitelist-global-constants' => false,
	'whitelist-global-classes'   => false,
	'whitelist-global-functions' => false,
	'finders'                    => [
		Finder::create()->
		files()->
		in(
			[
				'vendor/psr/container/',
				'vendor/symfony/config/',
				'vendor/symfony/filesystem/',
				'vendor/symfony/service-contracts/',
				'vendor/symfony/dependency-injection/',
			]
		)->
		name( [ '*.php' ] ),
	],
	'patchers'                   => [
		function ( string $file_path, string $prefix, string $contents ): string {
			return str_replace(
				'Symfony\\\\',
				sprintf( '%s\\\\Symfony\\\\', addslashes( $prefix ) ),
				$contents
			);
		},
	],
];
  • In the prefix key add the prefix for packages
  • In the finders key specify all packages for which you need to add prefixes
  • In the patchers key we write exactly how we should add prefixes using callbacks

When the command is run, prefixes will be added for all packages:

php-scoper add-prefix --output-dir dependencies/vendor/

Don’t forget to add the dependencies/vendor/ directive for autoloading to composer.json:

// ...
  "autoload": {
    // ...
    "classmap": [
      "dependencies/vendor/"
    ]
  },
// ...

To get php-scoper up and run quickly, add scripts to composer.json and run them on composer install/update event:

// ...
  "scripts": {
    "install-scoper": [
      "composer bin php-scoper config minimum-stability dev",
      "composer bin php-scoper config prefer-stable true",
      "composer bin php-scoper require --dev humbug/php-scoper"
    ],
    "scoper": "php-scoper add-prefix --config .scoper.inc.php --output-dir dependencies/vendor/",
    "post-install-cmd": [
      "composer install-scoper",
      "composer scoper",
      "composer dump-autoload"
    ],
    "post-update-cmd": [
      "composer install-scoper",
      "composer scoper",
      "composer dump-autoload"
    ]
  }
// ...

After that, don’t forget to replace the original packages with prefixed packages.

Results

  • There aren’t Hard Dependencies in the plugin
  • The plugin isn’t loaded immediately but on the plugins_loaded hook, which makes it easier to work with other plugins.
  • You can disable the plugin with a single call to remove_action
  • You can disable any plugin actions/filters using the plugin_name_init hook
  • Composer packages don’t conflict with other plugins/themes

How do you like this plugin launch?

Leave a Reply

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