Making a WordPress plugin extensible with PHP classes

WordPress plugins can be extended with additional functionality, as demonstrated by popular plugins like WooCommerce and Gravity Forms. In the article “Architecting a WordPress plugin to support extensions,” we learn there are two primary ways to make a WordPress plugin extensible:

By setting up hooks (actions and filters) for extension plugins to inject their own functionality
By providing PHP classes that extension plugins can inherit

The first method relies more on documentation, detailing available hooks and their usage. The second method, by contrast, offers ready-to-use code for extensions, reducing the need for extensive documentation. This is advantageous because creating documentation alongside code can complicate the plugin’s management and release.

Providing PHP classes directly effectively replaces documentation with code. Instead of teaching how to implement a feature, the plugin supplies the necessary PHP code, simplifying the task for third-party developers.

Let’s explore some techniques for achieving this, with the ultimate goal of fostering an ecosystem of integrations around our WordPress plugin.

Defining base PHP classes in the WordPress plugin

The WordPress plugin will include PHP classes intended for use by extension plugins. These PHP classes might not be used by the main plugin itself but are provided specifically for others to use.

Let’s see how this is implemented in the open-source Gato GraphQL plugin.

AbstractPlugin class:

AbstractPlugin represents a plugin, both for the main 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 extends AbstractPlugin to represent 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:

Similarly, AbstractExtension extends AbstractPlugin to represent 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 that AbstractExtension is included within the main plugin, providing functionality to register and initialize an extension. However, it is only used by extensions, not by the main plugin itself.

The AbstractPlugin class contains shared initialization code invoked at different times. These methods are defined at the ancestor level but are invoked by the inheriting classes according to their lifecycles.

The main plugin and extensions are initialized by executing the setup method on the corresponding class, invoked from within the main WordPress plugin file.

For instance, in Gato GraphQL, this is done in gatographql.php:

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

setup method:

At the ancestor level, setup contains the common logic between the plugin and its extensions, such as unregistering them when the plugin is deactivated. This method is not final; It can be overridden 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 main plugin’s setup method initializes the application’s lifecycle. It executes the main plugin’s functionality through methods like initialize, configureComponents, configure, and boot, and triggers corresponding action hooks for 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:

The 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 initialize, configureComponents, configure, and boot are common to both the main plugin and extensions and may share logic. This shared logic is stored in 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();
}
}

Similarly, the extension class provides its implementation:

abstract class AbstractExtension extends AbstractPlugin implements ExtensionInterface
{
// …

protected function callPluginInitializationConfiguration(): void
{
$this->extensionInitializationConfiguration?->initialize();
}
}

Methods initialize, configureComponents and boot are defined at the ancestor level and can be overridden by 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 their custom functionality.

For the main plugin, the setup method also removes any caching from the WordPress instance when the plugin or any of its extensions 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
}

// …
}

Similarly, the deactivate method removes caching and boot executes additional action hooks for the main plugin only:

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;
}
);
}
}

From all the code presented above, it is clear that when designing and coding a WordPress plugin, we need to consider the needs of its extensions and reuse code across them as much as possible. Implementing sound Object-Oriented Programming patterns (such as the SOLID principles) helps achieve this, making the codebase maintainable for the long term.

Declaring and validating the version dependency

Since the extension inherits from a PHP class provided by the plugin, it’s crucial to validate that the required version of the plugin is present. Failing to do so could cause conflicts that bring the site down.

For example, if the AbstractExtension class is updated with breaking changes and releases a major version 4.0.0 from the previous 3.4.0, loading the extension without checking the version could result in a PHP error, preventing WordPress from loading.

To avoid this, the extension must validate that the installed plugin is version 3.x.x. When version 4.0.0 is installed, the extension will be disabled, thus preventing errors.

The extension can accomplish this validation using the following logic, executed on the plugins_loaded hook (since the main plugin will be loaded by then) in the extension’s main plugin file. This logic accesses the ExtensionManager class, which is included in the main plugin to manage extensions:

/**
* Create and set-up the extension
*/
add_action(
‘plugins_loaded’,
function (): void {
/**
* Extension’s name and version.
*
* Use a stability suffix as supported by Composer.
*/
$extensionVersion = ‘1.1.0’;
$extensionName = __(‘Gato GraphQL – Extension Template’);

/**
* The minimum version required from the Gato GraphQL plugin
* to activate the extension.
*/
$gatoGraphQLPluginVersionConstraint=”^1.0″;

/**
* Validate Gato GraphQL is active
*/
if (!class_exists(GatoGraphQLGatoGraphQLPlugin::class)) {
add_action(‘admin_notices’, function () use ($extensionName) {
printf(
”,
sprintf(
__(‘Plugin %s is not installed or activated. Without it, plugin %s will not be loaded.’),
__(‘Gato GraphQL’),
$extensionName
)
);
});
return;
}

$extensionManager = GatoGraphQLGatoGraphQLPluginApp::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();
}
);

Notice how the extension declares a dependency on version constraint ^1.0 of the main plugin (using Composer’s version constraints). Thus, when version 2.0.0 of Gato GraphQL is installed, the extension will not be activated.

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

use ComposerSemverSemver;

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|null $mainPluginVersionConstraint the semver version constraint required for the plugin (eg: “^1.0” means >=1.0.0 and getPlugin();
$mainPluginVersion = $mainPlugin->getPluginVersion();
if (
$mainPluginVersionConstraint !== null && !Semver::satisfies(
$mainPluginVersion,
$mainPluginVersionConstraint
)
) {
$this->printAdminNoticeErrorMessage(
sprintf(
__(‘Extension or bundle %s requires plugin %s to satisfy version constraint %s, but the current version %s does not. The extension or bundle has not been loaded.’, ‘gatographql’),
$extensionName ?? $extensionClass,
$mainPlugin->getPluginName(),
$mainPluginVersionConstraint,
$mainPlugin->getPluginVersion(),
)
);
return false;
}

return true;
}

protected function printAdminNoticeErrorMessage(string $errorMessage): void
{
add_action(‘admin_notices’, function () use ($errorMessage): void {
$adminNotice_safe = sprintf(
”,
$errorMessage
);
echo $adminNotice_safe;
});
}
}

Running integration tests against a WordPress server

To make it easier for third-party developers to create extensions for your plugins, provide them with tools for development and testing, including workflows for their continuous integration and continuous delivery (CI/CD) processes.

During development, anyone can easily spin up a web server using DevKinsta, install the plugin for which they are coding the extension, and immediately validate that the extension is compatible with the plugin.

To automate testing during CI/CD, we need to have the web server accessible over a network to the CI/CD service. Services such as InstaWP can create a sandbox site with WordPress installed for this purpose.

If the extension’s codebase is hosted on GitHub, developers can use GitHub Actions to run integration tests against the InstaWP service. The following workflow installs the extension on an InstaWP sandbox site (alongside the latest stable version of the main plugin) and then runs the integration tests:

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 accesses the .zip file via Nightly Link, a service that allows accessing an artifact from GitHub without logging in, simplifying the configuration of InstaWP.

Releasing the extension plugin

We can provide tools to help release the extensions, automating the procedures as much as possible.

The Monorepo Builder is a library for managing any PHP project, including a WordPress plugin. It provides the monorepo-builder release command to release a version of the project, incrementing either the major, minor, or patch component of the version according to semantic versioning.

This command executes a series of release workers, which are PHP classes that execute certain logic. The default workers include one that creates a git tag with the new version and another that pushes the tag to the remote repository. Custom workers can be injected before, after, or in between these steps.

The release workers are configured via a configuration file:

use SymplifyMonorepoBuilderConfigMBConfig;
use SymplifyMonorepoBuilderReleaseReleaseWorkerAddTagToChangelogReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerPushNextDevReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerPushTagReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerSetCurrentMutualDependenciesReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerSetNextMutualDependenciesReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerTagVersionReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerUpdateBranchAliasReleaseWorker;
use SymplifyMonorepoBuilderReleaseReleaseWorkerUpdateReplaceReleaseWorker;

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 can provide custom release workers to augment the release process tailored to the needs 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 NetteUtilsStrings;
use PharIoVersionVersion;
use SymplifySmartFileSystemSmartFileInfo;
use SymplifySmartFileSystemSmartFileSystem;

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.

Publishing the extension plugin to the WP.org directory

We can also distribute a workflow to help release the extension to the WordPress Plugin Directory. When tagging the project on the remote repository, the following workflow will publish the WordPress extension plugin to 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 }}

This workflow uses the 10up/action-wordpress-plugin-deploy action, which retrieves the code from a Git repository and pushes it to the WordPress.org SVN repository, simplifying the operation.

Summary

When creating an extensible plugin for WordPress, our goal is to make it as easy as possible for third-party developers to extend it, thereby maximizing the chances of fostering a vibrant ecosystem around our plugins.

While providing extensive documentation can guide developers on how to extend the plugin, an even more effective approach is to supply the necessary PHP code and tooling for development, testing, and releasing their extensions.

By including the additional code needed by extensions directly in our plugin, we simplify the process for developers.

Do you plan to make your WordPress plugin extensible? Let us know in the comments section.

Save time and costs, plus maximize site performance, with $300+ worth of enterprise-level integrations included in every Managed WordPress plan. This includes a high-performance CDN, DDoS protection, malware and hack mitigation, edge caching, and Google’s fastest CPU machines. Get started with no long-term contracts, assisted migrations, and a 30-day money-back guarantee.

Check out our plans or talk to sales to find the plan that’s right for you.

Leonardo Losoviz

Leo writes about innovative web development trends, mostly concerning PHP, WordPress and GraphQL. You can find him at leoloso.com and twitter.com/losoviz.

Source link

Stay up to date
Register now to get updates on promotions and coupons
The owner of this website has made a commitment to accessibility and inclusion, please report any problems that you encounter using the contact form on this website. This site uses the WP ADA Compliance Check plugin to enhance accessibility.

Shopping cart

×