Making an WordPress plugin that is extensible by using PHP classes (r)

Aug 5, 2024

-sidebar-toc>        -language-notice>

  1. In installing hooks (actions and filters) for extensions plugins that inject their own functionality
  2. Through offering PHP classes that extension plugins are able to inherit

The first method relies more on documentation, detailing available hooks and their usage. The second method, by contrast, has ready-to-use code to extend the functionality, eliminating the requirement for detailed documentation. This is beneficial because writing documentation along with the code may hinder the management of plugins and distribution.

We'll look at some strategies for getting this done, but with the goal to create an ecosystem of integrations around the WordPress plugin.

Defining basic PHP classes in the WordPress plugin

Let's look at how this will be implemented within the open source Gato GraphQL plugin.

AbstractPlugin class:

AbstractPlugin can be described as a plug-in which is compatible with the Gato GraphQL plugin and its extensions:

abstract class AbstractPlugin implements PluginInterface
 
 protected string $pluginBaseName;
 protected string $pluginSlug;
 protected string $pluginName;
 
 public function __construct(
 protected string $pluginFile,
 protected string $pluginVersion,
 ?string $pluginName,
 ) 
 $this->pluginBaseName = plugin_basename($pluginFile);
 $this->pluginSlug = dirname($this->pluginBaseName);
 $this->pluginName = $pluginName ? ? $this->pluginBaseName;
 
 
 public function getPluginName(): string
 
 return $this->pluginName;
 
 
 public function getPluginBaseName(): string
 
 return $this->pluginBaseName;
 
 
 public function getPluginSlug(): string
 
 return $this->pluginSlug;
 
 
 public function getPluginFile(): string
 
 return $this->pluginFile;
 
 
 public function getPluginVersion(): string
 
 return $this->pluginVersion;
 
 
 public function getPluginDir(): string
 
 return dirname($this->pluginFile);
 
 
 public function getPluginURL(): string
 
 return plugin_dir_url($this->pluginFile);
 
 
 // ...
 

AbstractMainPlugin class:

AbstractMainPlugin expands AbstractPlugin in order to be a representation of the main plugin:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
 
 public function __construct(
 string $pluginFile,
 string $pluginVersion,
 ?string $pluginName,
 protected MainPluginInitializationConfigurationInterface $pluginInitializationConfiguration,
 ) 
 parent::__construct(
 $pluginFile,
 $pluginVersion,
 $pluginName,
 );
 
 
 // ...
 
 

AbstractExtension class:

In the same way, AbstractExtension expands AbstractPlugin to be an extension plugin:

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
 
 public function __construct(
 string $pluginFile,
 string $pluginVersion,
 ?string $pluginName,
 protected ?ExtensionInitializationConfigurationInterface $extensionInitializationConfiguration,
 ) 
 parent::__construct(
 $pluginFile,
 $pluginVersion,
 $pluginName,
 );
 
 
 // ...
 
 

Notice the fact that AbstractExtension is part of the main plugin, providing capabilities to register and start an extension. But, it's solely used by extensions, not by the main plugin itself.

AbstractPlugin is an AbstractPlugin class comprises the shared initialization method that is invoked at different times. These methods are defined at the ancestor level but can be invoked by inheriting classes according to their livespan.

The primary plugin and its extensions start by running the initialization method in the associated class that is invoked within the core WordPress plugin's file.

As an example, in Gato GraphQL, this is performed in gatographql.php:

$pluginFile = __FILE__;
 $pluginVersion = '2.4.0';
 $pluginName = __('Gato GraphQL', 'gatographql');
 PluginApp::getMainPluginManager()->register(new Plugin(
 $pluginFile,
 $pluginVersion,
 $pluginName
 ))->setup();
 

setup method:

On the level of the ancestor, the setup includes the logic common between the plugin and its extensions, for example, unregistering them after the plugin is deactivated. The method does not have to be final and can be modified by the inheriting classes to add their functionality:

abstract class AbstractPlugin implements PluginInterface
 
 // ...
 
 public function setup(): void
 
 register_deactivation_hook(
 $this->getPluginFile(),
 $this->deactivate(...)
 );
 
 
 public function deactivate(): void
 
 $this->removePluginVersion();
 
 
 private function removePluginVersion(): void
 
 $pluginVersions = get_option('gatographql-plugin-versions', []);
 unset($pluginVersions[$this->pluginBaseName]);
 update_option('gatographql-plugin-versions', $pluginVersions);
 
 
 

Main plugin's setup method:

The primary plugin's set-up method initiates the entire life cycle of the plugin. The plugin's main method executes its functionality through methods like the initialization, configureComponents, configure, and start and also triggers action hooks to extensions:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
 
 public function setup(): void
 
 parent::setup();
 
 add_action('plugins_loaded', function (): void
 
 // 1. Initialize main plugin
 $this->initialize();
 
 // 2. Initialize extensions
 do_action('gatographql:initializeExtension');
 
 // 3. Configure main plugin components
 $this->configureComponents();
 
 // 4. Configure extension components
 do_action('gatographql:configureExtensionComponents');
 
 // 5. Configure main plugin
 $this->configure();
 
 // 6. Configure extension
 do_action('gatographql:configureExtension');
 
 // 7. Boot main plugin
 $this->boot();
 
 // 8. Boot extension
 do_action('gatographql:bootExtension');
 
 
 // ...
 
 
 // ...
 

Extension setup method

AbstractExtension class AbstractExtension class executes its logic on the corresponding hooks:

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
 
 // ...
 
 final public function setup(): void
 
 parent::setup();
 
 add_action('plugins_loaded', function (): void
 
 // 2. Initialize extensions
 add_action(
 'gatographql:initializeExtension',
 $this->initialize(...)
 );
 
 // 4. Configure extension components
 add_action(
 'gatographql:configureExtensionComponents',
 $this->configureComponents(...)
 );
 
 // 6. Configure extension
 add_action(
 'gatographql:configureExtension',
 $this->configure(...)
 );
 
 // 8. Boot extension
 add_action(
 'gatographql:bootExtension',
 $this->boot(...)
 );
 , 20);
 
 

Methods to initialize, configureComponents, configure, and start are all common to the primary plugin as well as extensions and may have the same logic. The logic shared by these methods is stored within the AbstractPlugin class.

For example, the configure method configures the plugin or extensions, calling callPluginInitializationConfiguration, which has different implementations for the main plugin and extensions and is defined as abstract and getModuleClassConfiguration, which provides a default behavior but can be overridden if needed:

abstract class AbstractPlugin implements PluginInterface
 
 // ...
 
 public function configure(): void
 
 $this->callPluginInitializationConfiguration();
 
 $appLoader = App::getAppLoader();
 $appLoader->addModuleClassConfiguration($this->getModuleClassConfiguration());
 
 
 abstract protected function callPluginInitializationConfiguration(): void;
 
 /**
 * @return array,mixed> [key]: Module class, [value]: Configuration
 */
 public function getModuleClassConfiguration(): array
 
 return [];
 
 
 

The main plugin provides its implementation for callPluginInitializationConfiguration:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
 
 // ...
 
 protected function callPluginInitializationConfiguration(): void
 
 $this->pluginInitializationConfiguration->initialize();
 
 
 

The extension class, too, offers its own implementation

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
 
 // ...
 
 protected function callPluginInitializationConfiguration(): void
 
 $this->extensionInitializationConfiguration?->initialize();
 
 
 

Methods initiate, configureComponents and the boot are determined at the level of the ancestor. They are able to be rewritten through inheriting classes

abstract class AbstractPlugin implements PluginInterface
 
 // ...
 
 public function initialize(): void
 
 $moduleClasses = $this->getModuleClassesToInitialize();
 App::getAppLoader()->addModuleClassesToInitialize($moduleClasses);
 
 
 /**
 * @return array> List of `Module` class to initialize
 */
 abstract protected function getModuleClassesToInitialize(): array;
 
 public function configureComponents(): void
 
 $classNamespace = ClassHelpers::getClassPSR4Namespace(get_called_class());
 $moduleClass = $classNamespace . '\\Module';
 App::getModule($moduleClass)->setPluginFolder(dirname($this->pluginFile));
 
 
 public function boot(): void
 
 // By default, do nothing
 
 

All methods can be overridden by AbstractMainPlugin or AbstractExtension to extend them with the functionality of your choice.

The main plugin the configuration method also removes any caching of the WordPress instances when the plugin or one extension is activated or deactivated:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
 
 public function setup(): void
 
 parent::setup();
 
 // ...
 
 // Main-plugin specific methods
 add_action(
 'activate_plugin',
 function (string $pluginFile): void 
 $this->maybeRegenerateContainerWhenPluginActivatedOrDeactivated($pluginFile);
 
 );
 add_action(
 'deactivate_plugin',
 function (string $pluginFile): void 
 $this->maybeRegenerateContainerWhenPluginActivatedOrDeactivated($pluginFile);
 
 );
 
 
 public function maybeRegenerateContainerWhenPluginActivatedOrDeactivated(string $pluginFile): void
 
 // Removed code for simplicity
 
 
 // ...
 
 

In the same way, the deactivate method removes caching and boots executes additional actions hooks that are specific to the plugin, but only for:

abstract class AbstractMainPlugin extends AbstractPlugin implements MainPluginInterface
 
 public function deactivate(): void
 
 parent::deactivate();
 
 $this->removeTimestamps();
 
 
 protected function removeTimestamps(): void
 
 $userSettingsManager = UserSettingsManagerFacade::getInstance();
 $userSettingsManager->removeTimestamps();
 
 
 public function boot(): void
 
 parent::boot();
 
 add_filter(
 'admin_body_class',
 function (string $classes): string 
 $extensions = PluginApp::getExtensionManager()->getExtensions();
 $commercialExtensionActivatedLicenseObjectProperties = SettingsHelpers::getCommercialExtensionActivatedLicenseObjectProperties();
 foreach ($extensions as $extension) 
 $extensionCommercialExtensionActivatedLicenseObjectProperties = $commercialExtensionActivatedLicenseObjectProperties[$extension->getPluginSlug()] ? ? null;
 if ($extensionCommercialExtensionActivatedLicenseObjectProperties === null) 
 continue;
 
 return $classes . ' is-gatographql-customer';
 
 return $classes;
 
 );
 
 

Invalidating and declaring the version dependency

Because the extension is derived from the PHP class provided in the extension, it's essential to verify that the proper Version of the plugin has been installed. Failure to check this may cause problems that can result in the downfall of the website.

In this case, for example, if AbstractExtension class is updated with significant changes that break the code and is released as an upgrade version 4.0.0 from the previous 3.4.0, loading an extension without first checking the version may result in a PHP error, preventing WordPress from loading.

To avoid this it is necessary for the extension to confirm that the installed plugin has version 3.x.x. If the version 4.0.0 is installed this extension will be disabled, thus preventing mistakes.

This extension is able to perform this verification using the following logic, executed on the plugins_loaded hook (since the main plugin is loaded at this point) in the extension's main plugin file. This logic accesses extensions using the extension manager class that is part of the core extension plugin. It manages extensions

Code >/**
* Make and configure the extension*/
Add_action(
"plugins_loaded",
function (): void 
/*** Extension's name and the version. *
* Use an extension suffix that is stable as it is accepted by Composer. */
 $extensionVersion = '1.1.0';$extensionName is __('Gato GraphQL - Extension Template');
 
*** The minimum version required from Gato GraphQL is 1.1.0. Gato GraphQL plugin
 * to activate the extension. */
 $gatoGraphQLPluginVersionConstraint = '^1.0';
 
 /**
 * Validate Gato GraphQL is active
 */
 if (!class_exists(\GatoGraphQL\GatoGraphQL\Plugin::class)) 
 add_action('admin_notices', function () use ($extensionName) 
 printf(
 '%s',
 sprintf(
 __('Plugin %s is not installed or activated. If it is not activated, the plugin %s won't be loaded. '),
 __('Gato GraphQL'),
 $extensionName
 )
 );
 );
 return;
 
 
 $extensionManager = \GatoGraphQL\GatoGraphQL\PluginApp::getExtensionManager();
 if (!$extensionManager->assertIsValid(
 GatoGraphQLExtension::class,
 $extensionVersion,
 $extensionName,
 $gatoGraphQLPluginVersionConstraint
 )) 
 return;
 
 
 // Load Composer's autoloader
 require_once(__DIR__ . '/vendor/autoload.php');
 
 // Create and set-up the extension instance
 $extensionManager->register(new GatoGraphQLExtension(
 __FILE__,
 $extensionVersion,
 $extensionName,
 ))->setup();
 
 );

Note how the extension declares a dependency on version constraint ^1.0 of the principal plugin (using Composer's version constraints). Thus, when version 2.0.0 of Gato GraphQL is installed and the extension is not activated, it will be enabled.

The version constraint is validated via the ExtensionManager::assertIsValid method, which calls Semver::satisfies (provided by the composer/semver package):

use Composer\Semver\Semver;
 
 class ExtensionManager extends AbstractPluginManager
 
 /**
 * Validate that the required version of the Gato GraphQL for WP plugin is installed. *
 * If the assertion fails, it prints an error on the WP admin and returns false
 *
 * @param string

Testing integrations against an WordPress server

To automate testing during CI/CD, we need to access the web server via a network to the CI/CD server. Solutions like InstaWP allow you to create Sandbox sites using WordPress to be used as a sandbox.

name: Integration tests (InstaWP)
 on:
 workflow_run:
 workflows: [Generate plugins]
 types:
 - completed
 
 jobs:
 provide_data:
 if: $ github.event.workflow_run.conclusion == 'success' 
 name: Retrieve the GitHub Action artifact URLs to install in InstaWP
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4
 
 - uses: shivammathur/setup-php@v2
 with:
 php-version: 8.1
 coverage: none
 env:
 COMPOSER_TOKEN: $ secrets.GITHUB_TOKEN 
 
 - uses: "ramsey/composer-install@v2"
 
 - name: Retrieve artifact URLs from GitHub workflow
 uses: actions/github-script@v6
 id: artifact-url
 with:
 script: |
 const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts(
 owner: context.repo.owner,
 repo: context.repo.repo,
 run_id: context.payload.workflow_run.id,
 );
 const artifactURLs = allArtifacts.data.artifacts.map((artifact) => 
 return artifact.url.replace('https://api.github.com/repos', 'https://nightly.link') + '.zip'
 ).concat([
 "https://downloads.wordpress.org/plugin/gatographql.latest-stable.zip"
 ]);
 return artifactURLs.join(',');
 result-encoding: string
 
 - name: Artifact URL for InstaWP
 run: echo "Artifact URL for InstaWP - $ steps.artifact-url.outputs.result "
 shell: bash
 
 outputs:
 artifact_url: $ steps.artifact-url.outputs.result 
 
 process:
 needs: provide_data
 name: Launch InstaWP site from template 'integration-tests' and execute integration tests against it
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4
 
 - uses: shivammathur/setup-php@v2
 with:
 php-version: 8.1
 coverage: none
 env:
 COMPOSER_TOKEN: $ secrets.GITHUB_TOKEN 
 
 - uses: "ramsey/composer-install@v2"
 
 - name: Create InstaWP instance
 uses: instawp/wordpress-testing-automation@main
 id: create-instawp
 with:
 GITHUB_TOKEN: $ secrets.GITHUB_TOKEN 
 INSTAWP_TOKEN: $ secrets.INSTAWP_TOKEN 
 INSTAWP_TEMPLATE_SLUG: "integration-tests"
 REPO_ID: 25
 INSTAWP_ACTION: create-site-template
 ARTIFACT_URL: $ needs.provide_data.outputs.artifact_url 
 
 - name: InstaWP instance URL
 run: echo "InstaWP instance URL - $ steps.create-instawp.outputs.instawp_url "
 shell: bash
 
 - name: Extract InstaWP domain
 id: extract-instawp-domain 
 run: |
 instawp_domain="$(echo "$ steps.create-instawp.outputs.instawp_url " | sed -e s#https://##)"
 echo "instawp-domain=$(echo $instawp_domain)" >> $GITHUB_OUTPUT
 
 - name: Run tests
 run: |
 INTEGRATION_TESTS_WEBSERVER_DOMAIN=$ steps.extract-instawp-domain.outputs.instawp-domain  \
 INTEGRATION_TESTS_AUTHENTICATED_ADMIN_USER_USERNAME=$ steps.create-instawp.outputs.iwp_wp_username  \
 INTEGRATION_TESTS_AUTHENTICATED_ADMIN_USER_PASSWORD=$ steps.create-instawp.outputs.iwp_wp_password  \
 vendor/bin/phpunit --filter=Integration
 
 - name: Destroy InstaWP instance
 uses: instawp/wordpress-testing-automation@main
 id: destroy-instawp
 if: $ always() 
 with:
 GITHUB_TOKEN: $ secrets.GITHUB_TOKEN 
 INSTAWP_TOKEN: $ secrets.INSTAWP_TOKEN 
 INSTAWP_TEMPLATE_SLUG: "integration-tests"
 REPO_ID: 25
 INSTAWP_ACTION: destroy-site
 

This workflow downloads the .zip file via Nightly Link, a service that lets you access artifacts through GitHub without logging into it, which simplifies the setup of InstaWP.

Releasing the extension plugin

We can provide tools to help release the extensions while automating the processes as much as possible.

It is the Monorepo Builder is a library that can be used to manage every PHP project, including the WordPress plugin. It provides the monorepo-builder release command for releasing an update of the project. It increments either the minor, major or patch part of the version according to semantic the versioning.

The command runs a set of release workers that are PHP classes that perform specific logic. There are default workers, one that generates a git tag using the updated version. The other pushes tags to a remote repository. Custom workers can be injected prior to, following, or in between these steps.

The release worker is configured via a configuration file:

use Symplify\MonorepoBuilder\Config\MBConfig;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\AddTagToChangelogReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushNextDevReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushTagReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetCurrentMutualDependenciesReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetNextMutualDependenciesReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\TagVersionReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateBranchAliasReleaseWorker;
 use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateReplaceReleaseWorker;
 
 return static function (MBConfig $mbConfig): void 
 // release workers - in order to execute
 $mbConfig->workers([
 UpdateReplaceReleaseWorker::class,
 SetCurrentMutualDependenciesReleaseWorker::class,
 AddTagToChangelogReleaseWorker::class,
 TagVersionReleaseWorker::class,
 PushTagReleaseWorker::class,
 SetNextMutualDependenciesReleaseWorker::class,
 UpdateBranchAliasReleaseWorker::class,
 PushNextDevReleaseWorker::class,
 ]);
 ;
 

We offer custom release staff to help with the process of release that is tailored to the requirements of a WordPress plugin. For example, the InjectStableTagVersionInPluginReadmeFileReleaseWorker sets the new version as the "Stable tag" entry in the extension's readme.txt file:

use Nette\Utils\Strings;
 use PharIo\Version\Version;
 use Symplify\SmartFileSystem\SmartFileInfo;
 use Symplify\SmartFileSystem\SmartFileSystem;
 
 class InjectStableTagVersionInPluginReadmeFileReleaseWorker implements ReleaseWorkerInterface
 
 public function __construct(
 // This class is provided by the Monorepo Builder
 private SmartFileSystem $smartFileSystem,
 ) 
 
 
 public function getDescription(Version $version): string
 
 return 'Have the "Stable tag" point to the new version in the plugin\'s readme.txt file';
 
 
 public function work(Version $version): void
 
 $replacements = [
 '/Stable tag:\s+[a-z0-9.-]+/' => 'Stable tag: ' . $version->getVersionString(),
 ];
 $this->replaceContentInFiles(['/readme.txt'], $replacements);
 
 
 /**
 * @param string[] $files
 * @param array $regexPatternReplacements regex pattern to search, and its replacement
 */
 protected function replaceContentInFiles(array $files, array $regexPatternReplacements): void
 
 foreach ($files as $file) 
 $fileContent = $this->smartFileSystem->readFile($file);
 foreach ($regexPatternReplacements as $regexPattern => $replacement) 
 $fileContent = Strings::replace($fileContent, $regexPattern, $replacement);
 
 $this->smartFileSystem->dumpFile($file, $fileContent);
 
 
 

By adding InjectStableTagVersionInPluginReadmeFileReleaseWorker to the configuration list, whenever executing the monorepo-builder release command to release a new version of the plugin, the "Stable tag" in the extension's readme.txt file will be automatically updated.

The extension plugin is published to the WP.org directory

We can also distribute an automated workflow that will help to release the extension to the WordPress Plugin Directory. When tagging the project on the remote repository and the process below will be used to publish the WordPress extension plugin into the directory:

# See: https://github.com/10up/action-wordpress-plugin-deploy#deploy-on-pushing-a-new-tag
 name: Deploy to WordPress.org Plugin Directory (SVN)
 on:
 push:
 tags:
 - "*"
 
 jobs:
 tag:
 name: New tag
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@master
 - name: WordPress Plugin Deploy
 uses: 10up/action-wordpress-plugin-deploy@stable
 env:
 SVN_PASSWORD: $ secrets.SVN_PASSWORD 
 SVN_USERNAME: $ secrets.SVN_USERNAME 
 SLUG: $ secrets.SLUG 
 

Summary

When we create an extensible plugin to WordPress Our goal is making it as easy to third-party developers to expand it, maximising the chance of fostering an active community that is built around our plugins.

Although providing detailed documentation could help developers understand ways to expand the plugin, an even better method is to provide all the PHP code and tooling for developing, testing, and releasing their extensions.

With the addition of additional code needed by extensions directly into our plugin, it makes the process easier for extension developers.

    Are you thinking of making your WordPress plugin extensible? Please let us know via the comment section.

Leonardo Losoviz

Leo is a blogger who writes about new Web development techniques, primarily in relation to PHP, WordPress and GraphQL. You can find him at leoloso.com and twitter.com/losoviz.