Dans l’écosystème WordPress, l’adoption d’un modèle freemium est une méthode courante de promotion et de monétisation d’extensions commercialles.  Cette approche consiste à publier une version basique de l’extension gratuitement – généralement par l’intermédiaire du répertoire des extensions WordPress – et à offrir des fonctionnalités améliorées par le biais d’une version PRO ou de modules, généralement vendus sur le site web de l’extension.

Il existe trois façons différentes d’intégrer des fonctionnalités commerciales dans un modèle freemium :

  1. Intégrer ces fonctionnalités commerciales dans l’extension gratuite et ne les activer que lorsque la version commerciale est installée sur le site web ou qu’une clé de licence commerciale est fournie.
  2. Créez les versions gratuite et PRO comme des extensions indépendantes, la version PRO étant conçue pour remplacer la version gratuite, de sorte qu’une seule version soit installée à tout moment.
  3. Installez la version PRO à côté de l’extension gratuite, en étendant ses fonctionnalités. Pour cela, les deux versions doivent être présentes.

Cependant, la première approche est incompatible avec les lignes directrices relatives aux extensions distribuées via le répertoire des extensions WordPress, car ces règles interdisent l’inclusion de fonctionnalités restreintes ou verrouillées jusqu’à ce qu’un paiement ou une mise à niveau soit effectué.

Il nous reste donc les deux dernières options, qui présentent des avantages et des inconvénients. Les sections ci-dessous expliquent pourquoi la dernière stratégie, « PRO en plus de gratuit », est notre meilleur choix.

Nous allons nous pencher sur la seconde option, « PRO en remplacement de gratuit », sur ses défauts et sur les raisons pour lesquelles elle n’est finalement pas recommandée.

Ensuite, nous explorerons en profondeur la stratégie « PRO en plus de gratuit », en soulignant les raisons pour lesquelles elle constitue le meilleur choix.

Avantages de la stratégie  « PRO en remplacement de gratuit »

La stratégie w PRO en remplacement de free » est relativement facile à mettre en œuvre, car les développeurs peuvent utiliser une base de code unique pour les deux extensions (gratuite et PRO) et créer deux sorties à partir de celle-ci, la version gratuite (ou « standard ») comprenant simplement un sous-ensemble du code, et la version PRO comprenant tout le code.

Par exemple, la base de code du projet pourrait être divisée en deux répertoires : standard/ et pro/. L’extension chargerait toujours le code standard, le code PRO étant chargé conditionnellement, en fonction de la présence du répertoire concerné :

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

Ensuite, lors de la génération de l’extension via un outil d’intégration continue, nous pouvons créer les deux ressources myplugin-standard.zip et myplugin-pro.zip à partir du même code source.

Si vous hébergez le projet sur GitHub et que vous générez les ressources via GitHub Actions, le flux de travail suivant fait le travail :

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

Problèmes liés à la stratégie  « PRO en remplacement de gratuit »

La stratégie « PRO en remplacement de gratuit » demande de remplacer l’extension gratuite par la version PRO. Par conséquent, si l’extension gratuite est distribuée via le répertoire des extensions de WordPress, son nombre d’installations actives diminuera (car il ne tient compte que de l’extension gratuite, et non de la version PRO), ce qui donnera l’impression que l’extension est moins populaire qu’elle ne l’est en réalité.

Ce résultat irait à l’encontre de l’objectif premier de l’utilisation de l’annuaire des extensions WordPress : Il s’agit d’un canal de découverte d’extensions où les utilisateurs peuvent découvrir notre extension, la télécharger et l’installer. (Ensuite, une fois installée, l’extension gratuite peut inviter les utilisateurs à passer à la version PRO).

Si le nombre d’installations actives n’est pas élevé, les utilisateurs risquent de ne pas être convaincus d’installer notre extension. À titre d’exemple, les propriétaires de l’extension Newsletter Glue ont décidé de retirer l’extension du répertoire des extensions WordPress, car le faible nombre d’installations actives nuisait aux perspectives d’avenir de l’extension.

Comme la stratégie « PRO en remplacement de gratuit » n’est pas viable, il ne nous reste qu’un seul choix : la stratégie « PRO en plus de gratuit ».

Explorons les tenants et les aboutissants de cette stratégie.

Conceptualisation de la stratégie « PRO en plus du gratuit »

L’idée est que l’extension gratuite est installée sur le site et que ses fonctionnalités peuvent être étendues par l’installation d’extensions ou de modules supplémentaires. Il peut s’agir d’une seule extension PRO ou d’une collection d’extensions ou de modules PRO, chacun d’entre eux offrant une fonctionnalité spécifique.

Dans ce cas, l’extension gratuite ne se soucie pas des autres extensions installées sur le site. Tout ce qu’elle fait, c’est de fournir des fonctionnalités supplémentaires. Ce modèle est polyvalent et permet aux développeurs d’origine et aux créateurs tiers de se développer, ce qui favorise un écosystème dans lequel une extension peut évoluer dans des directions imprévues.

Notez qu’il importe peu que les extensions PRO soient produites par nous (c’est-à-dire par les mêmes développeurs que l’extension standard) ou par quelqu’un d’autre : le code pour traiter les deux est le même. En tant que tel, c’est une bonne idée de créer une base qui ne restreint pas la façon dont l’extension peut être étendue. Cela permettra aux développeurs tiers d’étendre notre extension d’une manière que nous n’avions pas imaginée.

Approches de conception : crochets et conteneurs de services

Il existe deux approches principales pour rendre le code PHP extensible :

  1. Via les crochets d’action et de filtre de WordPress
  2. Via un conteneur de service

La première approche est la plus courante parmi les développeurs WordPress, tandis que la seconde est préférée par l’ensemble de la communauté PHP.

Voyons des exemples des deux.

Rendre le code extensible via des crochets d’action et de filtre

WordPress propose des crochets (filtres et actions) comme mécanisme pour modifier le comportement. Les crochets de filtre sont utilisés pour remplacer des valeurs, et les crochets d’action pour exécuter des fonctionnalités personnalisées.

Notre extension principale peut alors être « jonchée » de crochets (ou hooks) à travers sa base de code, permettant aux développeurs de modifier son comportement.

Un bon exemple de ceci est WooCommerce, qui a couvert un énorme écosystème de modules, avec la majorité d’entre eux étant détenus par des fournisseurs tiers. Cela est possible grâce au grand nombre de crochets proposés par cette extension.

Les développeurs de WooCommerce ont délibérément ajouté des crochets, même s’ils n’en ont pas besoin eux-mêmes. C’est à quelqu’un d’autre de les utiliser. Remarquez le grand nombre de crochets d’action « before »  et « after » :

  • 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

Par exemple, le fichier downloads.php contient plusieurs actions pour injecter des fonctionnalités supplémentaires, et l’URL de la boutique peut être remplacée par un filtre :

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

Rendre le code extensible grâce aux conteneurs de services

Un conteneur de service est un objet PHP qui nous aide à gérer l’instanciation de toutes les classes du projet, généralement proposé dans le cadre d’une bibliothèque « d’injection de dépendances ».

L’injection de dépendances est une stratégie qui permet de coller toutes les parties de l’application de manière décentralisée : Les classes PHP sont injectées dans l’application via la configuration, et l’application récupère les instances de ces classes PHP via le conteneur de services.

Il existe de nombreuses bibliothèques d’injection de dépendances. Les bibliothèques suivantes sont populaires et interchangeables car elles satisfont toutes à la PSR-11 (recommandation du standard PHP) qui décrit les conteneurs d’injection de dépendances :

Laravel contient également un conteneur de service qui est déjà intégré à l’application.

En utilisant l’injection de dépendance, l’extension gratuite n’a pas besoin de savoir à l’avance quelles classes PHP sont présentes au moment de l’exécution : Elle demande simplement des instances de toutes les classes au conteneur de service. Alors que de nombreuses classes PHP sont fournies par l’extension gratuite elle-même pour satisfaire ses fonctionnalités, d’autres sont fournies par les modules installés sur le site pour étendre les fonctionnalités.

Un bon exemple d’utilisation d’un conteneur de service est Gato GraphQL, qui s’appuie sur la bibliothèque DependencyInjection de Symfony.

Voici comment le conteneur de service est instancié:

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

Veuillez noter que le conteneur de service (accessible sous l’objet PHP avec la classe GatoGraphQLServiceContainer) est généré la première fois que l’extension est exécutée et ensuite mise en cache sur le disque (en tant que fichier container.php dans un dossier temporaire du système). En effet, la génération du conteneur de service est un processus coûteux qui peut prendre plusieurs secondes.

Ensuite, l’extension principale et toutes ses extensions définissent les services à injecter dans le conteneur via un fichier de configuration:

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

Remarquez que nous pouvons instancier des objets pour des classes spécifiques (comme GatoGraphQL\GatoGraphQL\Log\Logger, accessible via son interface contractuelle GatoGraphQL\GatoGraphQL\Log\LoggerInterface), et nous pouvons également indiquer « instancier toutes les classes sous un certain répertoire » (comme tous les services sous ../src/Services).

Enfin, nous injectons la configuration dans le conteneur de services:

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

Les services injectés dans le conteneur peuvent être configurés pour être initialisés en permanence ou uniquement sur demande (mode différé).

Par exemple, pour représenter un type de publication personnalisé, l’extension dispose de la classe AbstractCustomPostTypedont la méthode initialize exécute la logique pour l’initialiser selon 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),
    );
  }
}

Ainsi, la classe GraphQLCustomEndpointCustomPostType.php est une implémentation d’un type de publication personnalisé. Une fois injectée dans le conteneur en tant que service, elle est instanciée et enregistrée dans 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');
  }
}

Cette classe est présente dans l’extension gratuite et d’autres classes de post-types personnalisés, s’étendant de la même manière à partir de AbstractCustomPostType, sont fournies par les extensions PRO.

Comparaison des crochets et des conteneurs de services

Comparons les deux approches de conception.

Les crochets d’action et de filtre ont l’avantage d’être la méthode la plus simple, leur fonctionnalité faisant partie du noyau de WordPress. Et tout développeur travaillant avec WordPress sait déjà comment manipuler les crochets, la courbe d’apprentissage est donc faible.

Cependant, sa logique est attachée à un nom de crochet, qui est une chaîne de caractères, et, en tant que tel, peut conduire à des bogues : Si le nom du crochet est modifié, la logique de l’extension est rompue. Cependant, le développeur peut ne pas remarquer qu’il y a un problème parce que le code PHP se compile toujours.

Par conséquent, les crochets dépréciés ont tendance à rester très longtemps dans la base de code, voire à jamais. Le projet accumule alors du code périmé qui ne peut être supprimé de peur de casser les extensions.

Pour en revenir à WooCommerce, cette situation est mise en évidence dans le fichier dashboard.php (remarquez comment les crochets dépréciés sont conservés depuis la version 2.6, alors que la dernière version actuelle est 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' );

L’utilisation d’un conteneur de service a l’inconvénient de nécessiter une bibliothèque externe, ce qui ajoute encore à la complexité. De plus, cette bibliothèque doit être scopée (en utilisant PHP-Scoper ou Strauss) de peur qu’une version différente de la même bibliothèque soit installée par une autre extension sur le même site, ce qui pourrait produire des conflits.

L’utilisation d’un conteneur de service est sans doute plus difficile à mettre en œuvre et prend plus de temps de développement.

D’un autre côté, les conteneurs de services traitent les classes PHP sans avoir à coupler la logique à une chaîne de caractères. Il en résulte que le projet utilise davantage de bonnes pratiques PHP, ce qui conduit à une base de code plus facile à maintenir à long terme.

Résumé

Lorsque vous créez une extension pour WordPress, c’est une bonne idée qu’elle supporte des extensions pour nous permettre à nous (les créateurs de l’extension) d’offrir des fonctionnalités commerciales et aussi à n’importe qui d’autre d’ajouter des fonctionnalités supplémentaires et, avec un peu de chance, de créer un écosystème centré sur l’extension.

Dans cet article, nous avons exploré les considérations concernant l’architecture du projet PHP pour rendre l’extension extensible. Comme nous l’avons appris, nous pouvons choisir entre deux approches de conception : utiliser des crochets ou utiliser un conteneur de service. Nous avons comparé les deux approches, en identifiant les vertus et les faiblesses de chacune.

Avez-vous l’intention de rendre votre extension WordPress extensible ? Faites-le nous savoir dans la section des commentaires.

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.