Architecting a WordPress plugin to support extensions

In the WordPress ecosystem, adopting a freemium model is a prevalent method for promoting and monetizing commercial plugins. This approach entails releasing a basic version of the plugin for free—usually through the WordPress plugin directory—and offering enhanced features through a PRO version or add-ons, typically sold on the plugin’s website.

There are three different ways to integrate commercial features within a freemium model:

  1. Ship these commercial features within the free plugin, and activate them only when the commercial version is installed on the website or a commercial license key is provided.
  2. Create the free and PRO versions as independent plugins, with the PRO version designed to replace the free version, ensuring that only one version is installed at any given time.
  3. Install the PRO version alongside the free plugin, extending its functionality. This requires both versions to be present.

However, the first approach is incompatible with the guidelines for plugins distributed via the WordPress plugin directory, as these rules prohibit the inclusion of features that are restricted or locked until a payment or upgrade is made.

This leaves us with the last two options, which offer advantages and disadvantages. The sections below explain why the latter strategy, “PRO on top of free,” is our best choice.

Let’s delve into the second option, “PRO as replacement of free,” its defects, and why it is ultimately not recommended.

Subsequently, we explore in depth the “PRO on top of free,” highlighting why it stands out as the preferred choice.

Advantages of the “PRO as replacement of free” strategy

The “PRO as replacement of free” strategy is relatively easy to implement because the developers can use a single codebase for both plugins (free and PRO) and create two outputs from it, with the free (or “standard”) version simply including a subset of the code, and the PRO version including all the code.

For example, the project’s codebase could be split into standard/ and pro/ directories. The plugin would always load the standard code, with the PRO code being loaded conditionally, based on the presence of the respective directory:

// Main plugin file: myplugin.php

// Always load the standard plugin's code
require_once __DIR__ . '/standard/load.php';

// Load the PRO plugin's code only if the folder exists
$proFolder = __DIR__ . '/pro';
if (file_exists($proFolder)) {
  require_once $proFolder . '/load.php';
}

Then, when generating the plugin via a Continuous Integration tool, we can create the two assets myplugin-standard.zip and myplugin-pro.zip from the same source code.

If hosting the project on GitHub and generating the assets via GitHub Actions, the following workflow does the job:

name: Generate the standard and PRO plugins
on:
  release:
    types: [published]

jobs:
  process:
    name: Generate plugins
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Install zip
        uses: montudor/[email protected]

      - name: Create the standard plugin .zip file (excluding all PRO code)
        run: zip -X -r myplugin-standard.zip . -x **/src//pro//*

      - name: Create the PRO plugin .zip file
        run: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip

      - name: Upload both plugins to the release page
        uses: softprops/action-gh-release@v1
        with:
          files: |
            myplugin-standard.zip
            myplugin-pro.zip
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Problems with the “PRO as replacement of free” strategy

The “PRO as replacement of free” strategy demands replacing the free plugin with the PRO version. As a consequence, if the free plugin is distributed via the WordPress plugin directory, its “active install” count will go down (as it only tracks the free plugin, not the PRO version), giving the impression that the plugin is less popular than it actually is.

This outcome would defeat the purpose of using the WordPress plugin directory in the first place: As a plugin discovery channel where users can find out about our plugin, download it, and install it. (After that, once it’s been installed, the free plugin can invite users to upgrade to the PRO version).

If the active install count is not high, users may not be convinced to install our plugin. As an example of how things can go wrong, the owners of the Newsletter Glue plugin decided to remove the plugin from the WordPress plugin directory, as the low activation count was hurting the prospects of the plugin.

As the “PRO as replacement of free” strategy is not viable, that leaves us with only one choice: the “PRO on top of free” strategy.

Let’s explore the ins and outs of this strategy.

Conceptualizing the “PRO on top of free” strategy

The idea is that the free plugin is installed on the site, and its functionality can be extended by installing additional plugins or addons. This could be via a single PRO plugin or via a collection of PRO extensions or addons, with each of them providing some specific functionality.

In this situation, the free plugin does not care what other plugins are installed on the site. All it does is to provide additional functionality. This model is versatile, allowing for expansion by both the original developers and third-party creators, fostering an ecosystem where a plugin can evolve in unforeseen directions.

Please notice how it doesn’t matter if the PRO extensions will be produced by us (i.e. the same developers building the standard plugin), or by somebody else: The code to deal with both is the same. As such, it is a good idea to create a foundation that does not restrict how the plugin can be extended. This will make it possible for 3rd-party developers to extend our plugin in ways we hadn’t conceived of.

Design approaches: hooks and service containers

There are two main approaches to making PHP code extensible:

  1. Via the WordPress action and filter hooks
  2. Via a service container

The first approach is the most common one among WordPress developers, while the latter one is preferred by the broader PHP community.

Let’s see examples of both.

Making code extensible via action and filter hooks

WordPress offers hooks (filters and actions) as a mechanism to modify behavior. Filter hooks are used to override values, and action hooks to execute custom functionality.

Our main plugin can then be “littered” with hooks throughout its codebase, allowing developers to modify its behavior.

A good example of this is WooCommerce, which has spanned a huge ecosystem of add-ons, with the majority of them being owned by 3rd-party providers. This is possible thanks to the extensive number of hooks offered by this plugin.

The developers of WooCommerce have purposefully added hooks, even though they themselves do not need them. It’s for someone else to use. Notice the great number of “before” and “after” action hooks:

  • woocommerce_after_account_downloads
  • woocommerce_after_account_navigation
  • woocommerce_after_account_orders
  • woocommerce_after_account_payment_methods
  • woocommerce_after_available_downloads
  • woocommerce_after_cart
  • woocommerce_after_cart_contents
  • woocommerce_after_cart_item_name
  • woocommerce_after_cart_table
  • woocommerce_after_cart_totals
  • woocommerce_before_account_downloads
  • woocommerce_before_account_navigation
  • woocommerce_before_account_orders
  • woocommerce_before_account_orders_pagination
  • woocommerce_before_account_payment_methods
  • woocommerce_before_available_downloads
  • woocommerce_before_cart
  • woocommerce_before_cart_collaterals
  • woocommerce_before_cart_contents
  • woocommerce_before_cart_table
  • woocommerce_before_cart_totals

As an example, file downloads.php contains several actions to inject extra functionality, and the shop URL can be overridden via a filter:

<?php

$downloads     = WC()->customer->get_downloadable_products();
$has_downloads = (bool) $downloads;

do_action( 'woocommerce_before_account_downloads', $has_downloads ); ?>

<?php if ( $has_downloads ) : ?>

  <?php do_action( 'woocommerce_before_available_downloads' ); ?>

  <?php do_action( 'woocommerce_available_downloads', $downloads ); ?>

  <?php do_action( 'woocommerce_after_available_downloads' ); ?>

<?php else : ?>

  <?php

  $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '';
  wc_print_notice( esc_html__( 'No downloads available yet.', 'woocommerce' ) . ' <a class="button wc-forward' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' );
  ?>

<?php endif; ?>

<?php do_action( 'woocommerce_after_account_downloads', $has_downloads ); ?>

Making code extensible via service containers

A service container is a PHP object that helps us manage the instantiation of all classes in the project, commonly offered as part of a “dependency injection” library.

Dependency injection is a strategy that allows gluing all parts of the application together in a decentralized way: PHP classes are injected into the application via configuration, and the application retrieves instances of these PHP classes via the service container.

There are plenty of dependency injection libraries available. The following are popular ones and are interchangeable as they all satisfy PSR-11 ( PHP standard recommendation) that describes dependency injection containers:

Laravel also contains a service container that is already baked into the application.

Using dependency injection, the free plugin does not need to know in advance what PHP classes are present on runtime: It simply requests instances of all classes to the service container. While many PHP classes are provided by the free plugin itself to satisfy its functionality, others are provided by whichever addons are installed on the site to extend functionality.

A good example of using a service container is Gato GraphQL, which relies on Symfony’s DependencyInjection library.

This is how the service container is instantiated:

<?php

declare(strict_types=1);

namespace GatoGraphQL/Container;

use Symfony/Component/Config/ConfigCache;
use Symfony/Component/DependencyInjection/Compiler/CompilerPassInterface;
use Symfony/Component/DependencyInjection/Dumper/PhpDumper;

trait ContainerBuilderFactoryTrait
{
  protected ContainerInterface $instance;
  protected bool $cacheContainerConfiguration;
  protected bool $cached;
  protected string $cacheFile;

  /**
   * Initialize the Container Builder.
   * If the directory is not provided, store the
   * cache in a system temp dir
   */
  public function init(
    bool $cacheContainerConfiguration,
    string $namespace,
    string $directory
  ): void {
    $this->cacheContainerConfiguration = $cacheContainerConfiguration;

    if ($this->cacheContainerConfiguration) {
      if (!$directory) {
        $directory = sys_get_temp_dir() . /DIRECTORY_SEPARATOR . 'container-cache';
      }
      $directory .= /DIRECTORY_SEPARATOR . $namespace;
      if (!is_dir($directory)) {
        @mkdir($directory, 0777, true);
      }
      
      // Store the cache under this file
      $this->cacheFile = $directory . 'container.php';

      $containerConfigCache = new ConfigCache($this->cacheFile, false);
      $this->cached = $containerConfigCache->isFresh();
    } else {
      $this->cached = false;
    }

    // If not cached, then create the new instance
    if (!$this->cached) {
      $this->instance = new ContainerBuilder();
    } else {
      require_once $this->cacheFile;
      /** @var class-string<ContainerBuilder> */
      $containerFullyQuantifiedClass = "//GatoGraphQL//ServiceContainer";
      $this->instance = new $containerFullyQuantifiedClass();
    }
  }

  public function getInstance(): ContainerInterface
  {
    return $this->instance;
  }

  /**
   * If the container is not cached, then compile it and cache it
   *
   * @param CompilerPassInterface[] $compilerPasses Compiler Pass objects to register on the container
   */
  public function maybeCompileAndCacheContainer(
    array $compilerPasses = []
  ): void {
    /**
     * Compile Symfony's DependencyInjection Container Builder.
     *
     * After compiling, cache it in disk for performance.
     *
     * This happens only the first time the site is accessed
     * on the current server.
     */
    if ($this->cached) {
      return;
    }

    /** @var ContainerBuilder */
    $containerBuilder = $this->getInstance();
    foreach ($compilerPasses as $compilerPass) {
      $containerBuilder->addCompilerPass($compilerPass);
    }

    // Compile the container.
    $containerBuilder->compile();

    // Cache the container
    if (!$this->cacheContainerConfiguration) {
      return;
    }
    
    // Create the folder if it doesn't exist, and check it was successful
    $dir = dirname($this->cacheFile);
    $folderExists = file_exists($dir);
    if (!$folderExists) {
      $folderExists = @mkdir($dir, 0777, true);
      if (!$folderExists) {
        return;
      }
    }

    // Save the container to disk
    $dumper = new PhpDumper($containerBuilder);
    file_put_contents(
      $this->cacheFile,
      $dumper->dump(
        [
          'class' => 'ServiceContainer',
          'namespace' => 'GatoGraphQL',
        ]
      )
    );

    // Change the permissions so it can be modified by external processes
    chmod($this->cacheFile, 0777);
  }
}

Please notice that the service container (accessible under PHP object with class GatoGraphQL/ServiceContainer) is generated the first time that the plugin is executed and then cached to disk (as file container.php in a system temp folder). This is because generating the service container is an expensive process that could potentially take several seconds to complete.

Then, both the main plugin and all its extensions define what services to inject into the container via a configuration file:

services:
  _defaults:
    public: true
    autowire: true
    autoconfigure: true

  GatoGraphQL/GatoGraphQL/Registries/ModuleTypeRegistryInterface:
    class: /GatoGraphQL/GatoGraphQL/Registries/ModuleTypeRegistry

  GatoGraphQL/GatoGraphQL/Log/LoggerInterface:
    class: /GatoGraphQL/GatoGraphQL/Log/Logger

  GatoGraphQL/GatoGraphQL/Services/:
    resource: ../src/Services/*

  GatoGraphQL/GatoGraphQL/State/:
    resource: '../src/State/*'

Notice that we can instantiate objects for specific classes (such as GatoGraphQL/GatoGraphQL/Log/Logger, accessed via its contract interface GatoGraphQL/GatoGraphQL/Log/LoggerInterface), and we can also indicate “instantiate all classes under some directory” (such as all services under ../src/Services).

Finally, we inject the configuration into the service container:

<?php

declare(strict_types=1);

namespace PoP/Root/Module;

use PoP/Root/App;
use Symfony/Component/Config/FileLocator;
use Symfony/Component/DependencyInjection/ContainerBuilder;
use Symfony/Component/DependencyInjection/Loader/YamlFileLoader;

trait InitializeContainerServicesInModuleTrait
{
  // Initialize the services defined in the YAML configuration file.
  public function initServices(
    string $dir,
    string $serviceContainerConfigFileName
  ): void {
    // First check if the container has been cached. If so, do nothing
    if (App::getContainerBuilderFactory()->isCached()) {
      return;
    }

    // Initialize the ContainerBuilder with this module's service implementations
    /** @var ContainerBuilder */
    $containerBuilder = App::getContainer();
    $loader = new YamlFileLoader($containerBuilder, new FileLocator($dir));
    $loader->load($serviceContainerConfigFileName);
  }
}

Services injected into the container can be configured to be initialized always or only when requested (lazy mode).

For instance, to represent a custom post type, the plugin has class AbstractCustomPostType, whose initialize method executes the logic to initialize it according to WordPress:

<?php

declare(strict_types=1);

namespace GatoGraphQL/GatoGraphQL/Services/CustomPostTypes;

use GatoGraphQL/GatoGraphQL/Services/Taxonomies/TaxonomyInterface;
use PoP/Root/Services/AbstractAutomaticallyInstantiatedService;

abstract class AbstractCustomPostType extends AbstractAutomaticallyInstantiatedService implements CustomPostTypeInterface
{
  public function initialize(): void
  {
    /add_action(
      'init',
      $this->initCustomPostType(...)
    );
  }

  /**
   * Register the post type
   */
  public function initCustomPostType(): void
  {
    /register_post_type($this->getCustomPostType(), $this->getCustomPostTypeArgs());
  }

  abstract public function getCustomPostType(): string;

  /**
   * Arguments for registering the post type
   *
   * @return array<string,mixed>
   */
  protected function getCustomPostTypeArgs(): array
  {
    /** @var array<string,mixed> */
    $postTypeArgs = [
      'public' => $this->isPublic(),
      'publicly_queryable' => $this->isPubliclyQueryable(),
      'label' => $this->getCustomPostTypeName(),
      'labels' => $this->getCustomPostTypeLabels($this->getCustomPostTypeName(), $this->getCustomPostTypePluralNames(true), $this->getCustomPostTypePluralNames(false)),
      'capability_type' => 'post',
      'hierarchical' => $this->isAPIHierarchyModuleEnabled() && $this->isHierarchical(),
      'exclude_from_search' => true,
      'show_in_admin_bar' => $this->showInAdminBar(),
      'show_in_nav_menus' => true,
      'show_ui' => true,
      'show_in_menu' => true,
      'show_in_rest' => true,
    ];
    return $postTypeArgs;
  }

  /**
   * Labels for registering the post type
   *
   * @param string $name_uc Singular name uppercase
   * @param string $names_uc Plural name uppercase
   * @param string $names_lc Plural name lowercase
   * @return array<string,string>
   */
  protected function getCustomPostTypeLabels(string $name_uc, string $names_uc, string $names_lc): array
  {
    return array(
      'name'         => $names_uc,
      'singular_name'    => $name_uc,
      'add_new'      => sprintf(/__('Add New %s', 'gatographql'), $name_uc),
      'add_new_item'     => sprintf(/__('Add New %s', 'gatographql'), $name_uc),
      'edit_item'      => sprintf(/__('Edit %s', 'gatographql'), $name_uc),
      'new_item'       => sprintf(/__('New %s', 'gatographql'), $name_uc),
      'all_items'      => $names_uc,//sprintf(/__('All %s', 'gatographql'), $names_uc),
      'view_item'      => sprintf(/__('View %s', 'gatographql'), $name_uc),
      'search_items'     => sprintf(/__('Search %s', 'gatographql'), $names_uc),
      'not_found'      => sprintf(/__('No %s found', 'gatographql'), $names_lc),
      'not_found_in_trash' => sprintf(/__('No %s found in Trash', 'gatographql'), $names_lc),
      'parent_item_colon'  => sprintf(/__('Parent %s:', 'gatographql'), $name_uc),
    );
  }
}

Then, the class GraphQLCustomEndpointCustomPostType.php is an implementation of a custom post type. Upon being injected as a service into the container, it is instantiated and registered into WordPress:

<?php

declare(strict_types=1);

namespace GatoGraphQL/GatoGraphQL/Services/CustomPostTypes;

class GraphQLCustomEndpointCustomPostType extends AbstractCustomPostType
{
  public function getCustomPostType(): string
  {
    return 'graphql-endpoint';
  }

  protected function getCustomPostTypeName(): string
  {
    return /__('GraphQL custom endpoint', 'gatographql');
  }
}

This class is present on the free plugin and other custom post-type classes, similarly extending from AbstractCustomPostType, are provided by PRO extensions.

Comparing hooks and service containers

Let’s compare the two design approaches.

On the plus side for action and filter hooks, it is the simpler method, with its functionality being part of WordPress core. And any developer working with WordPress already knows how to handle hooks, hence the learning curve is low.

However, its logic is attached to a hook name, which is a string, and, as such, may lead to bugs: If the hook name is modified, it breaks the logic of the extension. However, the developer may not notice that there is a problem because the PHP code still compiles.

Consequently, deprecated hooks tend to be kept for a very long time in the codebase, possibly even forever. The project then accumulates stale code that can’t be removed for fear of breaking extensions.

Back to WooCommerce, this situation is evidenced on file dashboard.php (notice how the deprecated hooks are kept since version 2.6, whereas the current latest version is 8.5):

<?php
  /**
   * My Account dashboard.
   *
   * @since 2.6.0
   */
  do_action( 'woocommerce_account_dashboard' );

  /**
   * Deprecated woocommerce_before_my_account action.
   *
   * @deprecated 2.6.0
   */
  do_action( 'woocommerce_before_my_account' );

  /**
   * Deprecated woocommerce_after_my_account action.
   *
   * @deprecated 2.6.0
   */
  do_action( 'woocommerce_after_my_account' );

Using a service container has the disadvantage that it requires an external library, which further adds complexity. Even more, this library must be scoped (using PHP-Scoper or Strauss) for fear that a different version of the same library is installed by another plugin on the same site, which could produce conflicts.

Using a service container is undoubtedly more difficult to implement and takes longer development time.

On the plus side, service containers deal with PHP classes without having to couple logic to some string. This results in the project using more PHP best practices, leading to a codebase that is easier to maintain in the long term.

Summary

When creating a plugin for WordPress, it’s a good idea for it to support extensions to allow us (the creators of the plugin) to offer commercial features and also anyone else to add extra functionality and, hopefully, span an ecosystem centered around the plugin.

In this article, we explored what are the considerations concerning the architecture of the PHP project to make the plugin extensible. As we learned, we can choose between two design approaches: using hooks or using a service container. And we compared both approaches, identifying the virtues and weaknesses of each.

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

The post Architecting a WordPress plugin to support extensions appeared first on Kinsta®.

版权声明:
作者:Zad
链接:https://www.techfm.club/p/111284.html
来源:TechFM
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>