In het WordPress ecosysteem is het gebruik van een freemium model een veelgebruikte methode voor het promoten en te gelde maken van commerciële plugins. Deze aanpak houdt in dat je een basisversie van de plugin gratis uitbrengt – meestal via de WordPress plugin directory – en uitgebreide features aanbiedt via een PRO versie of add-ons, die meestal verkocht worden op de website van de plugin.

Er zijn drie verschillende manieren om commerciële functies te integreren in een freemium model:

  1. Herberg deze commerciële functies in de gratis plugin en activeer ze alleen als de commerciële versie op de website is geïnstalleerd of als er een commerciële licentiesleutel is verstrekt.
  2. Maak de gratis en PRO versies als onafhankelijke plugins, waarbij de PRO versie ontworpen is om de gratis versie te vervangen, zodat er altijd maar één versie geïnstalleerd is.
  3. Installeer de PRO versie naast de gratis plugin en breid de functionaliteit uit. Hiervoor moeten beide versies aanwezig zijn.

De eerste benadering is echter onverenigbaar met de richtlijnen voor plugins die worden gedistribueerd via de WordPress plugin directory, omdat deze regels verbieden om functies op te nemen die beperkt of vergrendeld zijn totdat er een betaling of upgrade is gedaan.

Dan blijven de laatste twee opties over, die voor- en nadelen hebben. De paragrafen hieronder leggen uit waarom de laatste strategie, “PRO naast gratis”, onze beste keuze is.

Maar laten we eerst kijken naar de tweede optie, “PRO die de gratis versie vervangt”, de gebreken ervan en waarom het uiteindelijk niet wordt aanbevolen.

Daarna gaan we dieper in op “PRO naast gratis”, waarbij we uitleggen waarom dit de beste keuze is.

Voordelen van de “PRO die de gratis versie vervangt” strategie

De “PRO die de gratis versie vervangt” strategie is relatief eenvoudig te implementeren omdat de ontwikkelaars een enkele codebase kunnen gebruiken voor beide plugins (gratis en PRO) en er twee uitgangen van kunnen maken, waarbij de gratis (of “standaard”) versie eenvoudigweg een subset van de code bevat en de PRO versie alle code.

De codebase van het project zou bijvoorbeeld kunnen worden opgesplitst in standard/ en pro/ directories. De plugin zou altijd de standaardcode laden, waarbij de PRO code voorwaardelijk wordt geladen, gebaseerd op de aanwezigheid van de betreffende map:

// 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';
}

Wanneer we dan de plugin genereren via een Continuous Integration tool, kunnen we de twee assets myplugin-standard.zip en myplugin-pro.zip maken van dezelfde sourcecode.

Als we het project hosten op GitHub en de assets genereren via GitHub Actions, dan doet de volgende workflow het werk:

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

Problemen met de “PRO die de gratis versie vervangt” strategie

De “PRO die de gratis versie vervangt” strategie vereist het vervangen van de gratis plugin door de PRO versie. Als gevolg daarvan, als de gratis plugin wordt gedistribueerd via de WordPress plugin directory, zal het aantal “actieve installaties” dalen (omdat alleen de gratis plugin wordt bijgehouden, niet de PRO versie), waardoor de indruk wordt gewekt dat de plugin minder populair is dan hij in werkelijkheid is.

Dit zou het doel van het gebruik van de WordPress plugin directory teniet doen: Als een ontdekkingskanaal van plugins waar gebruikers onze plugin kunnen vinden, downloaden en installeren. (Daarna, als het eenmaal geïnstalleerd is, kan de gratis plugin gebruikers uitnodigen om te upgraden naar de PRO versie).

Als het aantal actieve installaties niet hoog is, worden gebruikers wellicht niet overtuigd om onze plugin te installeren. Een voorbeeld van hoe het mis kan gaan: de eigenaren van de Newsletter Glue plugin besloten om de plugin te verwijderen uit de WordPress plugin directory, omdat het lage aantal actieve installaties de vooruitzichten van de plugin schaadde.

Omdat de “PRO die de gratis versie vervangt” strategie niet levensvatbaar is, blijft er maar één keuze over: de “PRO bovenop gratis” strategie.

Laten we de ins en outs van deze strategie bekijken.

Conceptualisering van de “PRO naast gratis” strategie

Het idee is dat de gratis plugin op de site wordt geïnstalleerd en dat de functionaliteit kan worden uitgebreid door extra plugins of addons te installeren. Dit kan via een enkele PRO plugin of via een verzameling PRO extensies of addons, waarbij elk van hen een specifieke functionaliteit biedt.

In deze situatie maakt het de gratis plugin niet uit welke andere plugins er op de site geïnstalleerd zijn. Het enige wat het doet is extra functionaliteit bieden. Dit model is veelzijdig en biedt ruimte voor uitbreiding door zowel de oorspronkelijke developers als externe makers, waardoor een ecosysteem ontstaat waarin een plugin zich in onvoorziene richtingen kan ontwikkelen.

Merk op dat het niet uitmaakt of de PRO uitbreidingen door ons geproduceerd worden (d.w.z. dezelfde developers die de standaard plugin bouwen), of door iemand anders: De code om met beide om te gaan is hetzelfde. Daarom is het een goed idee om een basis te creëren die geen beperkingen oplegt aan hoe de plugin kan worden uitgebreid. Dit maakt het mogelijk voor externe d om onevelopers ze plugin uit te breiden op manieren waar wij niet aan gedacht hadden.

Ontwerpbenaderingen: hooks en service containers

Er zijn twee hoofdbenaderingen om PHP code uitbreidbaar te maken:

  1. Via de WordPress action- en filterhooks
  2. Via een service container

De eerste benadering is het meest gebruikelijk onder WordPress developers, terwijl de laatste de voorkeur heeft van de bredere PHP community.

Laten we voorbeelden van beide bekijken.

Code uitbreidbaar maken via action- en filterhooks

WordPress biedt hooks (filters en actions) als een mechanisme om gedrag aan te passen. Filterhooks worden gebruikt om waarden te overschrijven en actionhooks om aangepaste functionaliteit uit te voeren.

Onze hoofdplugin kan dan worden “bezaaid” met hooks in de hele codebase, zodat developers het gedrag kunnen aanpassen.

Een goed voorbeeld hiervan is WooCommerce, dat een enorm ecosysteem van add-ons heeft, waarvan de meerderheid eigendom is van derde partijen. Dit is mogelijk dankzij het uitgebreide aantal hooks dat deze plugin biedt.

De devs van WooCommerce hebben doelbewust hooks toegevoegd, ook al hebben ze die zelf niet nodig. Iemand anders kan ze gebruiken. Let op het grote aantal “after” en “before” actionhooks:

  • 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

Als voorbeeld bevat bestand downloads.php verschillende acties om extra functionaliteit te injecteren, en de URL van de winkel kan worden overschreven via een 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 ); ?>

Code uitbreidbaar maken via service containers

Een servicecontainer is een PHP object dat ons helpt bij het beheren van de instantiëring van alle klassen in het project, meestal aangeboden als onderdeel van een “dependency injection” bibliotheek.

Dependency injection is een strategie die het mogelijk maakt om alle onderdelen van de applicatie op een gedecentraliseerde manier aan elkaar te lijmen: PHP klassen worden in de applicatie geïnjecteerd via configuratie en de applicatie haalt instanties van deze PHP klassen op via de service container.

Er zijn veel bibliotheken voor het injecteren van dependencies die je kan gebruiken. De volgende zijn populair en uitwisselbaar, omdat ze allemaal voldoen aan PSR-11 (PHP standaard aanbeveling) dat dependency-injectie containers beschrijft:

Laravel bevat ook een service container die al in de applicatie is ingebakken.

Met behulp van dependency injection hoeft de gratis plugin niet van tevoren te weten welke PHP-klassen aanwezig zijn tijdens runtime: Hij vraagt simpelweg instanties van alle klassen op bij de service container. Hoewel veel PHP klassen worden geleverd door de gratis plugin zelf om aan de functionaliteit te voldoen, worden andere geleverd door de add-ons die op de site zijn geïnstalleerd om de functionaliteit uit te breiden.

Een goed voorbeeld van het gebruik van een servicecontainer is Gato GraphQL, dat vertrouwt op de DependencyInjection bibliotheek van Symfony.

Dit is hoe de service container wordt geïnstantieerd:

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

Merk op dat de service container (toegankelijk onder PHP-object met klasse GatoGraphQL\ServiceContainer) wordt gegenereerd de eerste keer dat de plugin wordt uitgevoerd en vervolgens op schijf wordt gecached (als bestand container.php in een temp-map van het systeem). Dit komt omdat het genereren van de service container een zwaar proces is dat mogelijk enkele seconden kan duren.

Vervolgens definiëren zowel de hoofdplugin als al zijn uitbreidingen via een configuratiebestand welke services ze in de container willen injecteren:

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/*'

Merk op dat we objecten kunnen instantiëren voor specifieke klassen (zoals GatoGraphQL\GatoGraphQL\Log\Logger, benaderd via zijn contract interface GatoGraphQL\GatoGraphQL\Log\LoggerInterface), en we kunnen ook aangeven “alle klassen onder een bepaalde map instantiëren” (zoals alle diensten onder ../src/Services).

Tenslotte injecteren we de configuratie in de 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 die in de container worden geïnjecteerd kunnen worden geconfigureerd om altijd te worden geïnitialiseerd of alleen wanneer daarom wordt gevraagd (lazy mode).

Om bijvoorbeeld een custom berichttype te representeren, heeft de plugin de klasse AbstractCustomPostTypewaarvan de methode initialize de logica uitvoert om het te initialiseren volgens 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),
    );
  }
}

Dan is de klasse GraphQLCustomEndpointCustomPostType.php een implementatie van een custom berichttype. Nadat het als service in de container is geïnjecteerd, wordt het geïnstantieerd en geregistreerd in 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');
  }
}

Deze klasse is aanwezig in de gratis plugin en andere aangepaste posttype klassen, vergelijkbaar met uitbreidingen van AbstractCustomPostType, worden geleverd door PRO extensies.

Vergelijking hooks en service containers

Laten we de twee ontwerpbenaderingen eens vergelijken.

Aan de positieve kant is het voor action- en filterhooks de eenvoudigere methode, omdat de functionaliteit deel uitmaakt van de WordPress core. En elke developer die met WordPress werkt, weet al hoe hij hooks moet gebruiken, dus de leercurve is laag.

De logica is echter gekoppeld aan een hooknaam, wat een string is, en als zodanig kan leiden tot bugs: Als de hooknaam wordt gewijzigd, wordt de logica van de extensie verbroken. Het is echter mogelijk dat de developer niet merkt dat er een probleem is, omdat de PHP code nog steeds compileert.

Als gevolg daarvan hebben afgeschreven hooks de neiging om heel lang in de codebase te blijven, mogelijk zelfs voor altijd. Het project verzamelt dan afgezaagde code die niet kan worden verwijderd uit angst om extensies kapot te maken.

Terug naar WooCommerce, deze situatie blijkt uit het bestand dashboard.php (merk op hoe de afgeschreven hooks worden bewaard sinds versie 2.6, terwijl de huidige laatste versie 8.5 is):

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

Het gebruik van een service container heeft als nadeel dat er een externe bibliotheek nodig is, wat de complexiteit nog verder vergroot. Bovendien moet deze bibliotheek worden gescoped (met PHP-Scoper of Strauss) uit angst dat een andere versie van dezelfde bibliotheek wordt geïnstalleerd door een andere plugin op dezelfde site, wat conflicten kan opleveren.

Het gebruik van een service container is ongetwijfeld moeilijker te implementeren en kost meer ontwikkeltijd.

Aan de andere kant hebben service containers te maken met PHP klassen zonder dat je logica hoeft te koppelen aan een of andere string. Dit zorgt ervoor dat het project meer PHP best practices gebruikt, wat leidt tot een codebase die op de lange termijn gemakkelijker te onderhouden is.

Samenvatting

Wanneer je een plugin voor WordPress maakt, is het een goed idee om extensies te ondersteunen, zodat wij (de makers van de plugin) commerciële functies kunnen aanbieden en ook anderen extra functionaliteit kunnen toevoegen en hopelijk een ecosysteem rond de plugin kunnen opbouwen.

In dit artikel hebben we onderzocht wat de overwegingen zijn met betrekking tot de architectuur van het PHP project om de plugin uitbreidbaar te maken. Zoals we hebben geleerd, kunnen we kiezen tussen twee ontwerpbenaderingen: hooks gebruiken of een service container gebruiken. En we hebben beide benaderingen vergeleken en de deugden en zwakheden van beide geïdentificeerd.

Ben jij van plan om je WordPress plugin uitbreidbaar te maken? Laat het ons weten in de commentsectie.

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.