Making an WordPress plugin that is extensible by using PHP classes (r)
-sidebar-toc> -language-notice>
- In installing hooks (actions and filters) for extensions plugins that inject their own functionality
- 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.