Nell’ecosistema WordPress, l’adozione di un modello freemium è un metodo prevalente per promuovere e monetizzare i plugin commerciali. Questo approccio prevede il rilascio gratuito di una versione di base del plugin, di solito attraverso la directory dei plugin di WordPress, e l’offerta di funzionalità avanzate attraverso una versione PRO o dei componenti aggiuntivi, di solito venduti sul sito web del plugin.

Esistono tre modi diversi per integrare le funzionalità commerciali in un modello freemium:

  1. Inserire le funzionalità commerciali all’interno del plugin gratuito e attivarle solo quando la versione commerciale viene installata sul sito web o viene fornita una chiave di licenza commerciale.
  2. Creare le versioni gratuite e PRO come plugin indipendenti, con la versione PRO progettata per sostituire la versione gratuita, assicurando che sia installata una sola versione in qualsiasi momento.
  3. Installare la versione PRO accanto al plugin gratuito, estendendone le funzionalità. Ciò richiede la presenza di entrambe le versioni.

Tuttavia, il primo approccio è incompatibile con le linee guida per i plugin distribuiti tramite la directory dei plugin di WordPress, in quanto queste regole vietano l’inclusione di funzionalità limitate o bloccate fino al pagamento o all’aggiornamento.

Rimangono quindi le ultime due opzioni, che presentano vantaggi e svantaggi. Le sezioni seguenti spiegano perché l’ultima strategia, “PRO in aggiunta a free”, è la scelta migliore.

Partiremo approfondendo la seconda opzione, “PRO come sostituto di free”, i suoi difetti e i motivi per cui è sconsigliata.

Successivamente, analizzeremo in modo approfondito la strategia “PRO in aggiunta a free”, evidenziando il motivo per cui è la scelta migliore.

Vantaggi della strategia “PRO come sostituto di free”

La strategia “PRO come sostituto di free” è relativamente facile da implementare perché gli sviluppatori possono utilizzare un’unica base di codice per entrambi i plugin (free e PRO) e creare due output, con la versione free (o “standard”) che include semplicemente un sottoinsieme del codice e la versione PRO che include tutto il codice.

Ad esempio, la base di codice del progetto potrebbe essere suddivisa nelle directory standard/ e pro/. Il plugin caricherebbe sempre il codice standard, mentre il codice PRO verrebbe caricato in modo condizionale, in base alla presenza della rispettiva 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';
}

Quindi, quando si genera il plugin tramite uno strumento di integrazione continua, possiamo creare le due risorse myplugin-standard.zip e myplugin-pro.zip dallo stesso codice sorgente.

Se il progetto è ospitato su GitHub e si generano le risorse tramite le GitHub Actions, il flusso di lavoro seguente svolge il suo compito:

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

Problemi con la strategia “PRO come sostituto di free”

La strategia “PRO come sostituto di free” prevede la sostituzione del plugin gratuito con la versione PRO. Di conseguenza, se il plugin gratuito viene distribuito tramite la directory dei plugin di WordPress, il suo conteggio delle “installazioni attive” diminuirà (poiché tiene conto solo del plugin gratuito e non della versione PRO), dando l’impressione che il plugin sia meno popolare di quanto non sia in realtà.

Questo risultato vanificherebbe lo scopo dell’utilizzo della directory dei plugin di WordPress come canale di scoperta dei plugin in cui gli utenti possono scoprire il nostro plugin, scaricarlo e installarlo. (Una volta installato, il plugin gratuito può invitare gli utenti a passare alla versione PRO).

Se il numero di installazioni attive non è elevato, gli utenti potrebbero non essere convinti a installare il nostro plugin. Come esempio di come le cose possono andare male, i proprietari del plugin Newsletter Glue hanno deciso di rimuoverlo dalla directory dei plugin di WordPress, poiché il basso numero di attivazioni stava danneggiando le prospettive del plugin.

Poiché la strategia “PRO come sostituto di free” non è praticabile, ci rimane solo una scelta: la strategia “PRO in aggiunta a free”.

Analizziamo i dettagli di questa strategia.

Concetto di strategia “PRO in aggiunta a free”

L’idea è che il plugin gratuito venga installato sul sito e che le sue funzionalità possano essere estese installando altri plugin o addon. Questo può avvenire tramite un singolo plugin PRO o tramite una serie di estensioni o addon PRO, ognuno dei quali fornisce una funzionalità specifica.

In questa situazione, il plugin gratuito non si preoccupa di quali altri plugin siano installati sul sito. Tutto ciò che fa è fornire funzionalità aggiuntive. Questo modello è versatile e consente l’espansione sia agli sviluppatori originali che ai creatori di terze parti, favorendo un ecosistema in cui un plugin può evolvere in direzioni impreviste.

Notate che non importa se le estensioni PRO saranno prodotte da noi (cioè dagli stessi sviluppatori del plugin standard) o da qualcun altro: il codice per gestire entrambi è lo stesso. Per questo motivo, è una buona idea creare una base che non limiti le modalità di estensione del plugin. In questo modo, gli sviluppatori di terze parti potranno estendere il nostro plugin in modi che non avevamo previsto.

Approcci progettuali: hook e contenitori di servizi

Esistono due approcci principali per rendere estensibile il codice PHP:

  1. Tramite gli hook per le azioni e i filtri di WordPress
  2. Tramite un contenitore di servizi

Il primo approccio è il più comune tra gli sviluppatori di WordPress, mentre il secondo è preferito dalla più ampia comunità PHP.

Vediamo alcuni esempi di entrambi.

Rendere il codice estensibile tramite hook per azioni e filtri

WordPress offre degli hook (filtri e azioni) come meccanismo per modificare il comportamento. Gli hook per i filtri sono utilizzati per sovrascrivere i valori e gli hook per le azioni per eseguire funzionalità personalizzate.

Il nostro plugin principale può essere “disseminato” di hook in tutto il suo codice, consentendo agli sviluppatori di modificarne il comportamento.

Un buon esempio di ciò è WooCommerce, che ha dato vita a un enorme ecosistema di componenti aggiuntivi, la maggior parte dei quali è di proprietà di fornitori di terze parti. Questo è possibile grazie all’ampio numero di hook offerti da questo plugin.

Gli sviluppatori di WooCommerce hanno aggiunto di proposito gli hook, anche se loro stessi non ne avevano bisogno. Vengono, però, utilizzati da altri. Notate il gran numero di hook per le azioni “prima” e “dopo”:

  • 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

Ad esempio, il file downloads.php contiene diverse azioni per iniettare funzionalità extra e l’URL del negozio può essere sovrascritto tramite un filtro:

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

Rendere il codice estensibile tramite i service container

Un service container, o contenitore di servizi, è un oggetto PHP che ci aiuta a gestire l’istanziazione di tutte le classi del progetto, comunemente offerto come parte di una libreria “dependency injection”.

L’iniezione di dipendenze è una strategia che permette di unire tutte le parti dell’applicazione in modo decentralizzato: Le classi PHP vengono iniettate nell’applicazione tramite la configurazione e l’applicazione recupera le istanze di queste classi PHP tramite il contenitore di servizi.

Esistono numerose librerie di dependency injection. Le seguenti sono quelle più diffuse e sono intercambiabili poiché soddisfano tutte la PSR-11 (raccomandazione dello standard PHP) che descrive i contenitori di dependency injection:

Laravel contiene anche un service container già integrato nell’applicazione.

Utilizzando la dependency injection, il plugin gratuito non ha bisogno di sapere in anticipo quali classi PHP sono presenti in fase di esecuzione: richiede semplicemente le istanze di tutte le classi al contenitore di servizi. Mentre molte classi PHP sono fornite dal plugin gratuito stesso per soddisfare le sue funzionalità, altre sono fornite da qualsiasi componente aggiuntivo installato sul sito per estendere le funzionalità.

Un buon esempio di utilizzo di un contenitore di servizi è Gato GraphQL, che si basa sulla libreria DependencyInjection di Symfony.

Ecco come viene istanziato il contenitore di servizi:

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

Si noti che il contenitore di servizi (accessibile come oggetto PHP con la classe GatoGraphQLServiceContainer) viene generato la prima volta che il plugin viene eseguito e poi memorizzato su disco (come file container.php in una cartella temp del sistema). Questo perché la generazione del contenitore di servizi è un processo costoso che potrebbe richiedere diversi secondi per essere completato.

In seguito, sia il plugin principale che tutte le sue estensioni definiscono quali servizi iniettare nel contenitore tramite un file di configurazione:

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

Possiamo istanziare oggetti per classi specifiche (come GatoGraphQL\GatoGraphQL\Log\Logger, a cui si accede tramite l’interfaccia del contratto GatoGraphQL\GatoGraphQL\Log\LoggerInterface) e possiamo anche indicare “istanziare tutte le classi sotto una certa directory” (come tutti i servizi sotto ../src/Services).

Infine, iniettiamo la configurazione nel contenitore del servizio:

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

I servizi iniettati nel contenitore possono essere configurati per essere inizializzati sempre o solo quando richiesto (modalità lazy).

Ad esempio, per rappresentare un tipo di post personalizzato, il plugin ha la classe AbstractCustomPostTypeil cui metodo initialize esegue la logica per inizializzarlo secondo 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),
    );
  }
}

Quindi, la classe GraphQLCustomEndpointCustomPostType.php è un’implementazione di un tipo di post personalizzato. Dopo essere stata iniettata come servizio nel contenitore, viene istanziata e registrata 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');
  }
}

Questa classe è presente nel plugin gratuito e altre classi di tipi di post personalizzati, che si estendono in modo simile da AbstractCustomPostType, sono fornite dalle estensioni PRO.

Confronto tra hook e contenitori di servizi

Confrontiamo i due approcci progettuali.

Il lato positivo di hook per azioni e filtri è che si tratta di un metodo più semplice, in quanto le sue funzionalità fanno parte del nucleo di WordPress. Inoltre, qualsiasi sviluppatore che lavori con WordPress sa già come gestire gli hook, quindi la curva di apprendimento è bassa.

Tuttavia, la sua logica è legata al nome dell’hook, che è una stringa e, in quanto tale, può portare a dei bug: se il nome dell’hook viene modificato, la logica dell’estensione viene interrotta. Tuttavia, lo sviluppatore potrebbe non accorgersi del problema perché il codice PHP continua a essere compilato.

Di conseguenza, gli hook deprecati tendono a rimanere per molto tempo nella base di codice, forse addirittura per sempre. Il progetto accumula quindi codice obsoleto che non può essere rimosso per paura di “rompere” le estensioni.

Tornando a WooCommerce, questa situazione è evidenziata nel file dashboard.php (si noti come gli hook deprecati siano conservati dalla versione 2.6, mentre l’ultima versione attuale è 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’utilizzo di un contenitore di servizi ha lo svantaggio di richiedere una libreria esterna, che aggiunge ulteriore complessità. Inoltre, questa libreria deve essere sottoposta a scoping (utilizzando PHP-Scoper o Strauss) per paura che una versione diversa della stessa libreria sia installata da un altro plugin sullo stesso sito, il che potrebbe generare conflitti.

L’utilizzo di un contenitore di servizi è senza dubbio più difficile da implementare e richiede tempi di sviluppo più lunghi.

Il lato positivo è che i contenitori di servizi si occupano di classi PHP senza dover accoppiare la logica a qualche stringa. Questo fa sì che il progetto utilizzi un maggior numero di best practice PHP e che la base di codice sia più facile da mantenere a lungo termine.

Riepilogo

Quando si crea un plugin per WordPress, è bene che supporti le estensioni per consentire a noi (creatori del plugin) di offrire funzionalità commerciali e a chiunque altro di aggiungere funzionalità extra e, auspicabilmente, di creare un ecosistema incentrato sul plugin.

In questo articolo abbiamo analizzato quali sono le considerazioni sull’architettura del progetto PHP per rendere il plugin estensibile. Come abbiamo appreso, possiamo scegliere tra due approcci progettuali: l’utilizzo di hook o di un contenitore di servizi. Abbiamo confrontato entrambi gli approcci, individuando i pregi e i difetti di ciascuno.

Avete intenzione di rendere estensibile il vostro plugin per WordPress? Fatecelo sapere nella sezione commenti.

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.