En el ecosistema de WordPress, la adopción de un modelo freemium es un método predominante para promocionar y monetizar plugins comerciales. Este enfoque implica lanzar una versión básica del plugin de forma gratuita — normalmente a través del directorio de plugins de WordPress — y ofrecer funciones mejoradas a través de una versión PRO o add-ons, que suelen venderse en el sitio web del plugin.

Hay tres formas diferentes de integrar funciones comerciales en un modelo freemium:

  1. Incluir estas funciones comerciales dentro del plugin gratuito, y activarlas sólo cuando se instale la versión comercial en el sitio web o se proporcione una clave de licencia comercial.
  2. Crea las versiones gratuita y PRO como plugins independientes, con la versión PRO diseñada para sustituir a la versión gratuita, garantizando que sólo haya una versión instalada en cada momento.
  3. Instalar la versión PRO junto al plugin gratuito, ampliando su funcionalidad. Esto requiere que ambas versiones estén presentes.

Sin embargo, el primer enfoque es incompatible con las directrices para plugins distribuidos a través del directorio de plugins de WordPress, ya que estas normas prohíben la inclusión de funciones restringidas o bloqueadas hasta que se realice un pago o una actualización.

Esto nos deja con las dos últimas opciones, que ofrecen ventajas e inconvenientes. Las siguientes secciones explican por qué la última estrategia, «PRO sobre gratis», es nuestra mejor opción.

Vamos a profundizar en la segunda opción, «PRO como sustituto de gratis», sus defectos y por qué, en definitiva, no es recomendable.

Posteriormente, exploramos en profundidad la «PRO sobre gratis», destacando por qué se erige como la opción preferida.

Ventajas de la estrategia «PRO como sustituto de gratis»

La estrategia «PRO como sustituto de gratis» es relativamente fácil de aplicar porque los desarrolladores pueden utilizar un único código base para ambos plugins (free y PRO) y crear dos productos a partir de él, con la versión gratuita (o «estándar») incluyendo simplemente un subconjunto del código, y la versión PRO incluyendo todo el código.

Por ejemplo, el código base del proyecto podría dividirse en los directorios standard/ y pro/. El plugin cargaría siempre el código estándar, y el código PRO se cargaría condicionalmente, en función de la presencia del directorio respectivo:

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

Entonces, al generar el plugin mediante una herramienta de Integración Continua, podemos crear los dos activos myplugin-standard.zip y myplugin-pro.zip a partir del mismo código fuente.

Si alojas el proyecto en GitHub y generas los activos mediante Acciones de GitHub, el siguiente flujo de trabajo realiza el trabajo:

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

Problemas con la estrategia «PRO como sustituto de gratis»

La estrategia «PRO como sustituto de gratis» exige sustituir el plugin gratuito por la versión PRO. Como consecuencia, si el plugin gratuito se distribuye a través del directorio de plugins de WordPress, su recuento de «instalaciones activas» descenderá (ya que sólo rastrea el plugin gratuito, no la versión PRO), dando la impresión de que el plugin es menos popular de lo que realmente es.

Este resultado anularía el propósito de utilizar el directorio de plugins de WordPress en primer lugar: Como un canal de descubrimiento de plugins en el que los usuarios pueden conocer nuestro plugin, descargarlo e instalarlo. (Después, una vez instalado, el plugin gratuito puede invitar a los usuarios a actualizarse a la versión PRO).

Si el número de instalaciones activas no es elevado, es posible que los usuarios no se convenzan de instalar nuestro plugin. Como ejemplo de cómo las cosas pueden ir mal, los propietarios del plugin Newsletter Glue decidieron eliminar el plugin del directorio de plugins de WordPress, ya que el bajo recuento de activaciones estaba perjudicando las posibilidades del plugin.

Como la estrategia «PRO como sustituto de gratis» no es viable, sólo nos queda una opción: la estrategia «PRO sobre gratuito».

Exploremos los entresijos de esta estrategia.

Conceptualización de la estrategia «PRO sobre gratis»

La idea es que el plugin gratuito se instala en el sitio, y su funcionalidad puede ampliarse instalando plugins o addons adicionales. Puede ser mediante un único plugin PRO o mediante una colección de extensiones o addons PRO, cada uno de los cuales proporciona una funcionalidad específica.

En esta situación, al plugin gratuito no le importa qué otros plugins están instalados en el sitio. Lo único que hace es proporcionar una funcionalidad adicional. Este modelo es versátil, ya que permite la expansión tanto por parte de los desarrolladores originales como de terceros creadores, fomentando un ecosistema en el que un plugin puede evolucionar en direcciones imprevistas.

Por favor, fíjate en que no importa si las extensiones PRO serán producidas por nosotros (es decir, los mismos desarrolladores que construyen el plugin estándar), o por alguien más: El código para tratar con ambos es el mismo. Por lo tanto, es una buena idea crear una base que no restrinja cómo se puede ampliar el plugin. Esto hará posible que los desarrolladores de terceros amplíen nuestro plugin de formas que no habíamos concebido.

Enfoques de diseño: hooks y contenedores de servicios

Hay dos enfoques principales para hacer extensible el código PHP:

  1. A través de los hooks de acción y filtro de WordPress
  2. A través de un contenedor de servicios

El primer enfoque es el más común entre los desarrolladores de WordPress, mientras que el segundo es el preferido por la comunidad PHP en general.

Veamos ejemplos de ambos.

Hacer extensible el código mediante hooks de acciones y filtros

WordPress ofrece hooks (filtros y acciones) como mecanismo para modificar el comportamiento. Los hooks de filtro se utilizan para anular valores, y los hooks de acción para ejecutar funcionalidades personalizadas.

Nuestro plugin principal puede entonces estar «plagado» de hooks por toda su base de código, permitiendo a los desarrolladores modificar su comportamiento.

Un buen ejemplo de esto es WooCommerce, que ha extendido un enorme ecosistema de add-ons, la mayoría de los cuales pertenecen a proveedores de terceros. Esto es posible gracias al amplio número de hooks que ofrece este plugin.

Los desarrolladores de WooCommerce han añadido hooks a propósito, aunque ellos mismos no los necesiten. Es para que otro los utilice. Fíjate en el gran número de hooks de acción «before»(antes) y «after»(después):

  • 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

Por ejemplo, el archivo downloads.php contiene varias acciones para inyectar funcionalidad adicional, y la URL de la tienda puede anularse mediante 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 ); ?>

Hacer extensible el código mediante contenedores de servicio

Un contenedor de servicios es un objeto PHP que nos ayuda a gestionar la instanciación de todas las clases del proyecto, y que suele ofrecerse como parte de una biblioteca de «inyección de dependencias».

La inyección de dependencias es una estrategia que permite unir todas las partes de la aplicación de forma descentralizada: Las clases PHP se inyectan en la aplicación a través de la configuración, y la aplicación recupera instancias de estas clases PHP a través del contenedor de servicios.

Hay muchas bibliotecas de inyección de dependencias disponibles. Las siguientes son las más populares y son intercambiables, ya que todas satisfacen la PSR-11 (recomendación del estándar PHP) que describe los contenedores de inyección de dependencias:

Laravel también contiene un contenedor de servicios que ya está incorporado en la aplicación.

Utilizando la inyección de dependencias, el plugin gratuito no necesita saber previamente qué clases PHP están presentes en la fase de ejecución: Simplemente solicita instancias de todas las clases al contenedor de servicios. Mientras que muchas clases PHP son proporcionadas por el propio plugin gratuito para satisfacer su funcionalidad, otras son proporcionadas por cualquier complemento instalado en el sitio para ampliar la funcionalidad.

Un buen ejemplo de uso de un contenedor de servicios es Gato GraphQL, que se basa en la biblioteca DependencyInjection de Symfony.

Así se instanciará el contenedor de servicios:

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

Observa que el contenedor de servicios (accesible bajo el objeto PHP con la clase GatoGraphQLServiceContainer) se genera la primera vez que se ejecuta el plugin y luego se almacena en caché en el disco (como archivo container.php en una carpeta temporal del sistema). Esto se debe a que generar el contenedor de servicio es un proceso costoso que podría tardar varios segundos en completarse.

A continuación, tanto el plugin principal como todas sus extensiones definen qué servicios inyectar en el contenedor mediante un archivo de configuración:

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

Observa que podemos instanciar objetos para clases concretas (como GatoGraphQL\GatoGraphQL\Log\Logger, a la que se accede a través de su interfaz de contrato GatoGraphQL\GatoGraphQL\Log\LoggerInterface), and ), y también podemos indicar «instanciar todas las clases bajo algún directorio» (como todos los servicios bajo ../src/Services).

Por último, inyectamos la configuración en el contenedor de servicios:

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

Los servicios inyectados en el contenedor pueden configurarse para que se inicialicen siempre o sólo cuando se soliciten (modo perezoso).

Por ejemplo, para representar un tipo de entrada personalizado, el plugin tiene la clase AbstractCustomPostTypecuyo método initialize ejecuta la lógica para inicializarlo según 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),
    );
  }
}

Entonces, la clase GraphQLCustomEndpointCustomPostType.php es una implementación de un tipo de entrada personalizado. Al ser inyectada como servicio en el contenedor, es instanciada y registrada en 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');
  }
}

Esta clase está presente en el plugin gratuito y otras clases de tipo de publicación personalizada, que se extienden de forma similar desde AbstractCustomPostType, son proporcionadas por las extensiones PRO.

Comparación de hooks y contenedores de servicios

Comparemos los dos enfoques de diseño.

El lado positivo para los hooks de acción y filtro es que es el método más sencillo, ya que su funcionalidad forma parte del core de WordPress. Y cualquier desarrollador que trabaje con WordPress ya sabe cómo manejar los hooks, por lo que la curva de aprendizaje es baja.

Sin embargo, su lógica está ligada a un nombre de hook, que es una cadena, y, como tal, puede dar lugar a errores: Si se modifica el nombre del hook, se rompe la lógica de la extensión. Sin embargo, el desarrollador puede no darse cuenta de que hay un problema porque el código PHP sigue compilándose.

En consecuencia, los hooks obsoletos tienden a mantenerse durante mucho tiempo en la base de código, posiblemente incluso para siempre. El proyecto acumula entonces código obsoleto que no puede eliminarse por miedo a romper las extensiones.

Volviendo a WooCommerce, esta situación se evidencia en el archivo dashboard.php (observa cómo los hooks obsoletos se mantienen desde la versión 2.6, mientras que la última versión actual es 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' );

Utilizar un contenedor de servicios tiene el inconveniente de que requiere una biblioteca externa, lo que añade complejidad. Aún más, esta biblioteca debe estar aislada (utilizando PHP-Scoper o Strauss) por miedo a que otra versión de la misma biblioteca sea instalada por otro plugin en el mismo sitio, lo que podría producir conflictos.

Utilizar un contenedor de servicios es, sin duda, más difícil de implementar y requiere más tiempo de desarrollo.

En el lado positivo, los contenedores de servicio tratan con clases PHP sin tener que acoplar la lógica a alguna cadena. Esto hace que el proyecto utilice más prácticas recomendadas de PHP, lo que conduce a una base de código más fácil de mantener a largo plazo.

Resumen

Al crear un plugin para WordPress, es una buena idea que admita extensiones para permitirnos a nosotros (los creadores del plugin) ofrecer funciones comerciales y también a cualquier otra persona añadir funcionalidades adicionales y, con suerte, abarcar un ecosistema centrado en el plugin.

En este artículo, exploramos cuáles son las consideraciones relativas a la arquitectura del proyecto PHP para que el plugin sea extensible. Como hemos aprendido, podemos elegir entre dos enfoques de diseño: utilizar hooks o utilizar un contenedor de servicios. Y comparamos ambos enfoques, identificando las virtudes y debilidades de cada uno.

¿Piensas hacer extensible tu plugin de WordPress? Háznoslo saber en la sección de comentarios.

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.