The creation of a WordPress plugin extensible with the help of PHP classes (r)

Aug 8, 2024

-sidebar-toc> -language-notice>

  1. In installing hooks (actions and filters) for extensions plugins that inject their own functionality
  2. By offering PHP classes from which extension plugins can inherit

The first method relies more on documentation, detailing available hooks and their usage. The second approach, by contrast, has ready-to-use code that can be extended to improve functionality, eliminating the requirement to provide a detailed document. This is advantageous because the writing of documentation as well as the code can hinder the management of the plugins and distribution.

Let's look at a few strategies to accomplish this however, with the aim to build an ecosystem of integrations built around the WordPress plugin.

Define the basic PHP classes for the WordPress plugin

Let's take a look at how this can be implemented in the open source Gato GraphQL plugin.

AbstractPlugin class:

AbstractPlugin can be described as a plug-in compatible with 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 to be able to represent the plugin's main features:

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 is able to expand AbstractPlugin to make it 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, ); // ... 

It is important to note it is the case that AbstractExtension is an integral part of the plugin. It allows you to sign up and begin an extension. However, it's only used by extensions, not by the plugin in general.

AbstractPlugin is one of the AbstractPlugin classes. AbstractPlugin class which includes the shared initialization method that is invoked at different times. These procedures are established at the ancestor level but are also invoked by inheriting classes according to their lifespan.

The primary plugin and its extensions begin by running the initialization procedure in the class that is invoked inside the base WordPress plugin's code.

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

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

Setup method

In the case of the parent, the configuration includes the logic common between the extension and the plugin, for example, unregistering them after the plugin is removed. The method does not have to be a final one and may be altered by inheriting classes in order to enhance their capabilities:

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

Method of setting up the main plugin:

The plugin's primary setup method initiates the entire process of establishing the plugin. Its primary method for executing its function through processes such as the initialization, configureComponents, configure, and start and also activates 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 procedure

AbstractExtension class AbstractExtension class implements 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 start, configureComponents, configure, and begin are common to the main plugin along with extensions, and could share the same reasoning. The logic used by these techniques is contained 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(); 

The extension class also, offers its own implementation

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

Methods that initiate, configureComponents and the start are decided by the parent. They are able to be modified 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

Each method can be modified with AbstractMainPlugin or AbstractExtension to extend them with functions of your choosing.

The primary plugin's configuration method also removes the cache of 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 eliminates cache and boots performs other actions hooks, which are unique 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 that the dependency is a version

Because the extension is derived from the PHP class used by the extension, it is essential to verify that the proper Version of the plugin has been set up. Failure to check this may create problems which could lead to the loss of the site.

This is the case for instance, if the AbstractExtension class has been updated to include significant changes that break the code and is made available as an upgrade version 4.0.0 from the earlier version 3.4.0, loading the extension without checking its version can produce a PHP error, preventing WordPress from loading.

In order to avoid this, it's essential for the extension to ensure that the plugin is running version 3.x.x. If the version 4.0.0 is installed the extension will be removed, thus preventing from making mistakes.

This extension is able to perform this verification using the logic described below, which is run on the plugins_loaded hook (since the core plugin has been installed at this point) in the extension's main plugin file. The logic is able to access extensions via the extensions manager class which is part of the core extension plugin. It manages extensions

Code >/** * Create and set up the extension** Create an Add_action( "plugins_loaded" Function () The extension will be deleted. /*** the extension's name, and the version. It is recommended to 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 allow the extension to be activated. */ $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 the plugin isn't active, the plugin will not 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 the dependency of its extension on the Version of the constraint ^1.0 of the main plugin (using Composer's version constraints). So, if the the version 2.0.0 of Gato GraphQL is installed and it is not enabled then it is activated.

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 will display an error to the administrator of WP, and then results in false. string

Test integrations on a WordPress server

To automate testing during CI/CD, it is necessary to access the server through an internet connection to the CI/CD server. Software such as InstaWP let you create Sandbox sites using WordPress that can be utilized as an 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 files in the .zip file via Nightly Link the service that allows access to artifacts through GitHub without logging into it It also makes setting up the installation of InstaWP.

The extension plugin is now available.

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

It is an extension of the Monorepo Builder is a program that can be used to manage each PHP project, including WordPress. WordPress plugin. It comes with the monorepo-builder release command that allows you to publish an update for the project. It will increase either the major, minor or patch parts of the update based on semantic the language of versioning.

The command is able to run a set of release workers which include PHP classes that perform certain process. There are default builders, one which creates a git tag using the updated version. The other pushes tags to remote repositories. Customized workers may be added before, after, or in between these steps.

The release worker is set up by 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 assist with the process of release specifically designed to meet 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 uploaded on the WP.org directory

There is also the option of distributing an automated workflow to help to release the extension to the WordPress Plugin Directory. When tagging the project on the remote repository and following the process below is used for publishing 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 

Summary

If we develop an extension-friendly plugin for WordPress We aim to make it easy for third party developers to extend it to increase the possibility of creating a vibrant community around the plugins we create.

Though providing thorough documentation may aid developers in understanding how to extend the plugin, a better method is to provide all the PHP tools and code to develop, test, and releasing their extensions.

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

     Have you thought of making your WordPress plugin extensible? Let us know in the comments section.

Leonardo Losoviz

Leo is blogger, who writes about the latest Web methods of development, mostly in relation to PHP, WordPress and GraphQL. Leo is available at leoloso.com and twitter.com/losoviz.

This post was posted on here