No ecossistema do WordPress, adotar um modelo freemium é um método comum para promover e monetizar plugins comerciais. Essa abordagem envolve lançar uma versão básica do plugin gratuitamente, geralmente através do diretório de plugins do WordPress, e oferecer funcionalidades avançadas por meio de uma versão PRO ou complementos, normalmente vendidos no site do plugin.

Há três maneiras diferentes de integrar funcionalidades comerciais em um modelo freemium:

  1. Incluir funcionalidades comerciais no plugin gratuito, ativando-as apenas quando a versão comercial estiver instalada no site ou mediante o fornecimento de uma chave de licença comercial.
  2. Criar versões gratuita e PRO como plugins independentes, com a versão PRO projetada para substituir a versão gratuita, garantindo que apenas uma versão seja instalada em um determinado momento.
  3. Instalar a versão PRO com o plugin gratuito, ampliando sua funcionalidade. Para isso, é necessário que ambas as versões estejam presentes.

No entanto, a primeira abordagem é incompatível com as diretrizes para plugins distribuídos por meio do diretório de plugins do WordPress, pois essas regras proíbem a inclusão de funcionalidades que são restritos ou bloqueados até que um pagamento ou atualização seja feito.

Isso nos leva às duas últimas opções disponíveis, cada uma apresentando seus prós e contras. Nas seções seguintes, detalharemos os motivos pelos quais a última estratégia, denominada “Versão PRO sobre a Gratuita”, representa a nossa escolha ideal.

Vamos detalhar a segunda opção, “PRO como substituto do gratuito”, e discutir seus pontos fracos, esclarecendo o motivo pelo qual não a recomendamos.

Em seguida, daremos uma atenção especial ao “PRO em adição ao gratuito”, realçando os motivos que o tornam a escolha mais vantajosa.

Vantagens da estratégia “PRO como substituto do gratuito”

A estratégia “PRO como substituto do gratuito” é relativamente fácil de implementar porque os desenvolvedores podem usar uma única base de código para ambos os plugins (gratuito e PRO) e criar dois resultados a partir dela, com a versão gratuita (ou “padrão”) simplesmente incluindo um subconjunto do código e a versão PRO incluindo todo o código.

Por exemplo, a base de código do projeto poderia ser dividida nos diretórios standard/ e pro/. O plugin sempre carregaria o código padrão, com o código PRO sendo carregado condicionalmente, com base na presença do respectivo diretório:

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

Então, ao gerar o plugin por meio de uma ferramenta de integração contínua, podemos criar os dois ativos myplugin-standard.zip e myplugin-pro.zip a partir do mesmo código-fonte.

Se você hospedar o projeto no GitHub e gerar os ativos por meio do GitHub Actions, o fluxo de trabalho a seguir fará o trabalho:

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 com a estratégia “PRO como substituto do gratuito”

A estratégia “PRO como substituto do gratuito” exige a substituição do plugin gratuito pela versão PRO. Como consequência, se o plugin gratuito for distribuído por meio do diretório de plugins de WordPress, sua contagem de “instalações ativas” diminuirá (já que ele rastreia apenas o plugin gratuito, não a versão PRO), dando a impressão de que o plugin é menos popular do que realmente é.

Esse resultado anularia o objetivo de usar o diretório de plugins de WordPress em primeiro lugar: Como um canal de descoberta de plugins em que os usuários podem saber mais sobre nosso plugin, baixá-lo e instalá-lo. (Depois disso, uma vez instalado, o plugin gratuito pode convidar os usuários a fazer upgrade para a versão PRO).

Se o número de instalações ativas não for expressivo, pode ser difícil convencer os usuários a instalar nosso plugin. Um exemplo do que pode dar errado é o caso dos desenvolvedores do plugin Newsletter Glue, que optaram por retirá-lo do diretório de plugins do WordPress. A decisão foi motivada pelo baixo número de ativações, o que estava comprometendo o potencial de sucesso do plugin.

Como a estratégia “PRO como substituto do gratuito” não é viável, isso nos deixa com apenas uma opção: a estratégia “PRO baseado no do gratuito”.

Vamos explorar os prós e contras dessa estratégia.

Conceituando a estratégia “PRO em adição ao gratuito”

A ideia é que o plugin gratuito seja instalado no site e sua funcionalidade possa ser ampliada com a instalação de plugins ou complementos adicionais. Isso pode ser feito por meio de um único plugin PRO ou por meio de uma coleção de extensões, ou complementos PRO, com cada um deles fornecendo uma funcionalidade específica.

Nessa situação, o plugin gratuito não se importa com os outros plugins que estão instalados no site. Tudo o que ele faz é fornecer funcionalidade adicional. Esse modelo é versátil, permitindo a expansão tanto pelos desenvolvedores originais quanto por criadores de terceiros, promovendo um ecossistema em que um plugin pode evoluir em direções imprevistas.

Note que é irrelevante se as versões PRO serão desenvolvidas por nós (os mesmos desenvolvedores do plugin padrão) ou por terceiros: o código necessário para gerenciar ambos é o mesmo. Portanto, é prudente estabelecer uma base que não limite as maneiras como o plugin pode ser ampliado. Isso possibilitará que os desenvolvedores de terceiros ampliem nosso plugin de maneiras que não havíamos imaginado.

Abordagens de design: Hooks e contêineres de serviço

Há duas abordagens principais para tornar o código PHP extensível:

  1. Por meio da ação do WordPress e dos hooks de filtro
  2. Por meio de um contêiner de serviço

A primeira abordagem é a mais comum entre os desenvolvedores WordPress, enquanto a segunda é a preferida pela comunidade PHP mais ampla.

Vamos ver exemplos de ambas.

Tornando o código extensível por meio de hooks de ação e filtro

O WordPress oferece hooks (filtros e ações) como um mecanismo para modificar o comportamento. Os hooks de filtro são usados para substituir valores e os hooks de ação para executar funcionalidades personalizadas.

Nosso plugin principal pode então ser “cheio” de hooks em toda a sua base de código, permitindo que os desenvolvedores modifiquem seu comportamento.

Um bom exemplo disso é o WooCommerce, que abrangeu um enorme ecossistema de complementos, sendo que a maioria deles pertence a fornecedores terceirizados. Isso é possível graças ao grande número de hooks oferecidos por esse plugin.

Os desenvolvedores do WooCommerce adicionaram hooks propositalmente, mesmo que eles próprios não precisem deles. É para que outra pessoa os utilize. Observe o grande número de hooks de ação “antes” e “depois”:

  • 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

Como exemplo, o arquivo downloads.php contém várias ações para injetar funcionalidade extra, e a URL da loja pode ser substituído por um 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 ); ?>

Tornando o código extensível por meio de contêineres de serviço

Um contêiner de serviço é um objeto PHP que nos ajuda a gerenciar a instanciação de todas as classes do projeto, geralmente oferecido como parte de uma biblioteca de “injeção de dependência”.

A injeção de dependência é uma estratégia que permite unir todas as partes do aplicativo de forma descentralizada: As classes PHP são injetadas no aplicativo por meio da configuração, e o aplicativo recupera instâncias dessas classes PHP por meio do contêiner de serviço.

Existem várias bibliotecas de injeção de dependência disponíveis. As seguintes se destacam por sua popularidade e intercambiabilidade, uma vez que todas atendem à PSR-11 (PHP Standard Recommendation), que define os requisitos para contêineres de injeção de dependência:

O Laravel também contém um contêiner de serviço já integrado ao aplicativo.

Usando a injeção de dependência, o plugin gratuito não precisa saber antecipadamente quais classes PHP estão presentes no runtime: Ele simplesmente solicita instâncias de todas as classes ao contêiner de serviço. Embora o plugin gratuito forneça várias classes PHP essenciais para sua funcionalidade, outras são disponibilizadas por complementos adicionais instalados no site, com o objetivo de expandir essa funcionalidade.

Um bom exemplo de uso de um contêiner de serviço é o Gato GraphQL, que depende da biblioteca DependencyInjection do Symfony.

É assim que o contêiner de serviço é instanciado:

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

Observe que o contêiner de serviço (acessível no objeto PHP com a classe GatoGraphQL\ServiceContainer) é gerado na primeira vez que o plugin é executado e, em seguida, armazenado em cache no disco (como o arquivo container.php em uma pasta temporária do sistema). Isso ocorre porque a geração do contêiner de serviço é um processo caro que pode levar vários segundos para ser concluído.

Em seguida, tanto o plugin principal quanto todas as suas extensões definem quais serviços devem ser injetados no contêiner por meio de um arquivo de configuração:

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

Observe que podemos instanciar objetos para classes específicas (como GatoGraphQL\GatoGraphQL\Log\Logger, acessado por meio de sua interface de contrato GatoGraphQL\GatoGraphQL\Log\LoggerInterface) e também podemos indicar “instanciar todas as classes em algum diretório” (como todos os serviços em ../src/Services).

Por fim, injetamos a configuração no contêiner do serviço:

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

Os serviços injetados no contêiner podem ser configurados para serem inicializados sempre ou somente quando solicitados (modo preguiçoso).

Por exemplo, para representar um tipo de artigo personalizado, o plugin tem a classe AbstractCustomPostType cujo método initialize executa a lógica para inicializá-lo de acordo com o 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),
    );
  }
}

Então, a classe GraphQLCustomEndpointCustomPostType.php é uma implementação de um tipo de artigo personalizado. Ao ser injetada como um serviço no contêiner, ela é instanciada e registrada no WordPress:

<?php

declare(strict_types=1);

namespace GatoGraphQLGatoGraphQLServicesCustomPostTypes;

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

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

Essa classe está presente no plugin gratuito, e outras classes de tipo de artigo personalizado, estendidas de forma semelhante a partir de AbstractCustomPostType, fornecidas pelas extensões PRO.

Comparação entre hooks e contêineres de serviço

Vamos comparar as duas abordagens de design.

A vantagem dos hooks de ação e filtro reside na sua simplicidade, uma vez que fazem parte do núcleo do WordPress. Qualquer desenvolvedor familiarizado com o WordPress já tem conhecimento sobre como utilizar os hooks, o que significa que a curva de aprendizado é mínima.

Porém, a lógica está vinculada a um nome de hook, que é uma string de caracteres. Isso pode resultar em erros: se o nome do hook for alterado, a lógica da extensão falhará. Além disso, o desenvolvedor pode não notar o problema imediatamente, pois o código PHP ainda será compilado sem erros.

Consequentemente, os hooks obsoletos tendem a ser mantidos por muito tempo na base de código, possivelmente até para sempre. Assim, o projeto acumula código obsoleto que não pode ser removido por medo de quebrar as extensões.

De volta ao WooCommerce, essa situação é evidenciada no arquivo dashboard.php (observe como os hooks obsoletos são mantidos desde a versão 2.6, enquanto a última versão atual é 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' );

O uso de um contêiner de serviço tem a desvantagem de exigir uma biblioteca externa, o que aumenta ainda mais a complexidade. Além disso, essa biblioteca deve ter escopo (usando PHP-Scoper ou Strauss), dado o receio de que outra versão da mesma biblioteca possa ser instalada por um plugin diferente no mesmo site, criando potenciais conflitos.

O uso de um contêiner de serviço é, sem dúvida, mais difícil de implementar e leva mais tempo de desenvolvimento.

O lado positivo é que os contêineres de serviço lidam com classes PHP sem precisar acoplar a lógica a alguma string. Isso faz com que o projeto use mais práticas recomendadas de PHP, levando a uma base de código mais fácil de manter a longo prazo.

Resumo

Ao criar um plugin para WordPress, é recomendável que ele suporte extensões, permitindo a nós (os criadores do plugin) oferecer recursos comerciais e também a terceiros adicionar funcionalidades extras e, com sorte, desenvolver um ecossistema centrado no plugin.

Neste artigo, exploramos quais são as considerações sobre a arquitetura do projeto PHP para tornar o plugin extensível. Como aprendemos, podemos escolher entre duas abordagens de design: Utilizar hooks ou adotar um contêiner de serviço. Analisamos ambas as estratégias, destacando as qualidades e as limitações de cada uma.

Você planeja tornar seu plugin de WordPress extensível? Compartilhe conosco na seção de comentários.

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.