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:
- 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.
- 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.
- 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:
- Por meio da ação do WordPress e dos hooks de filtro
- 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.
Deixe um comentário