WordPressのエコシステムでは、フリーミアムモデルを採用することが、商用プラグインのプロモーションと収益化のための一般的な方法です。このアプローチでは、通常WordPressのプラグインディレクトリを通じてプラグインの基本バージョンを無料でリリースし、通常プラグインのウェブサイトで販売されているPROバージョンやアドオンを通じて拡張機能を提供することができます。

フリーミアムモデルに商用機能を統合するには、3つの方法があります。

  1. 無料プラグインに商用機能を組み込んでおき、商用バージョンがウェブサイトにインストールされたとき、または商用ライセンスキーが提供されたときにのみ、特定の機能を有効にする。
  2. 無料版とPRO版を独立したプラグインとして作成し、PRO版は無料版を置き換えるように設計する。
  3. PRO版を無料プラグインと一緒にインストールし、その機能を拡張する。これには両方のバージョンが必要になる。

しかし、最初の方法はWordPressのプラグインディレクトリを通じて配布するプラグインのガイドラインとの互換性がありません。支払いやアップグレードがあるまで特定の機能をロックしたり制限したりすることが禁止されています。

このため、メリットとデメリットがある最後の2つの選択肢が残ります。以下のセクションでは、後者の「無料版にPRO版を追加する」について、それがおすすめである理由とあわせてご紹介します。

先に、一つ目の選択肢である「PRO版が無料版を置き換える」方法とその問題点に触れます。

その後、「無料版にPRO版を追加する」方法について深く掘り下げたいと思います。

「PRO版が無料版を置き換える」方法の利点

「PRO版が無料版を置き換える」方法は、比較的簡単です。というのも両方のプラグイン(無料版とPRO版)の土台として同じコードを用意し、そこから2つのプラグインを開発できます。無料(または “標準”)バージョンにはコードの一部を使用し、PRO版にはすべてのコードを使用するといった具合です。

例えば、プロジェクトのコードをstandard/pro/ディレクトリに分割します。プラグインは常に標準(standard)コードを読み込み、PROのコードはそれぞれのディレクトリの存在に基づいて条件付きで読み込まれるように記述できます。

// メインプラグインファイル:myplugin.php

// 常に標準のプラグインコードを読み込む
require_once __DIR__ . '/standard/load.php';

// 該当するフォルダがある場合のみPRO版のコードを読み込む
$proFolder = __DIR__ . '/pro';
if (file_exists($proFolder)) {
  require_once $proFolder . '/load.php';
}

そして、継続的インテグレーションツールでプラグインを生成するときに、同じソースコードからmyplugin-standard.zipmyplugin-pro.zipの2つのアセットを作成することができます。

プロジェクトをGitHubでホスティングしGitHub Actionsを使ってアセットを生成する場合は、次のようなワークフローになります。

name: 標準版とPRO版のプラグインを生成
on:
  release:
    types: [published]

jobs:
  process:
    name: プラグインの生成
    runs-on: ubuntu-latest
    steps:
      - name: コードのチェックアウト
        uses: actions/checkout@v3

      - name: zipのインストール
        uses: montudor/[email protected]

      - name: 標準版プラグインのzipファイルを作成(全てのPRO版コードを除去)
        run: zip -X -r myplugin-standard.zip . -x **/src/\pro/\*

      - name: PRO版プラグインのzipファイルを作成
        run: zip -X -r myplugin-pro.zip . -x myplugin-standard.zip

      - name: 両方のプラグインをリリースページにアップロード
        uses: softprops/action-gh-release@v1
        with:
          files: |
            myplugin-standard.zip
            myplugin-pro.zip
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

「PRO版が無料版を置き換える」方法の問題点

「PRO版が無料版を置き換える」方法だと、無料のプラグインを削除して代わりにPRO版のインストールが促されます。その結果、無料のプラグインがWordPressのプラグインディレクトリで配布されている場合、その「インストール」数は減少し(PRO版ではなく、無料のプラグインのみが追跡対象になるため)、プラグインが実際よりも人気がないという印象を与えるかもしれません。

これは、WordPressプラグインディレクトリを利用する目的に対し不利に働きます。ユーザーによるプラグインの発見とインストールのチャネルとしての価値が失われてしまいます(インストール後には、無料プラグインからPRO版への移行が意図されているため)。

インストール数が少ないと、プラグインをインストールする気にならない人もいるでしょう。例として、Newsletter Glueプラグインは、インストール数が少ないとプラグインの将来性が損なわれると判断しWordPressのプラグインディレクトリからプラグインを削除するに至っています。

「PRO版が無料版を置き換える」選択肢が理想的でないことから、残されたのは「無料版にPRO版を追加する」方法です。

この方法について考えてみましょう。

「無料版にPRO版を追加する」方法の概念化

この方法は、無料のプラグインをサイトにインストールし、その後別のプラグインやアドオンをインストールすることで機能を拡張するというものです。これは、単一のPRO版プラグインを介して、またはPRO用の拡張機能やアドオン(複数パターンを用意するなど)を介して行うことができます。

この方法では、PRO版のプラグインが追加の機能を提供することになります。このモデルは汎用性があり、開発者とサードパーティのクリエイターの両方による拡張を可能にし、プラグインが良い意味で予期せぬ方向に進化できる可能性も育まれます。

PRO版の拡張機能が元の開発者からも、他の誰かからも開発・提供される可能性があります。両方に対応する基本的なコードは同じです。そのため、プラグインの拡張に関わる制限を取り除いた環境をつくることができます。そうすることで、サードパーティの開発者が、元々の開発者が思いつかなかったような方法でプラグインを拡張してくれるかもしれません。

設計アプローチ─フックとService Container

PHPのコードを拡張可能にするには、主に2つのアプローチがあります。

  1. WordPressのアクションフックとフィルターフック
  2. Service Container経由

最初のアプローチはWordPress開発者の間で最も一般的なもので、後者はより広いPHPコミュニティで好まれています。

両方の例を見てみよう。

アクションフックとフィルターフックでコードを拡張可能にする

WordPressには、動作を変更する仕組みとしてフック(フィルターとアクション)があります。フィルターフックは値を上書きするために使われ、アクションフックは独自の機能を実行するのに使われます。

ここで行いたいのは、メインプラグインであらゆる開発者がその動作を変更できるようにコード全体にフックを「散りばめる」ことです。

これの良い例がWooCommerceで、膨大な数のアドオンが存在し、その大部分はサードパーティのプロバイダにより開発されています。すべてはこのプラグインに多くのフックがあるおかげです。

WooCommerceの開発元は(自身では必要なくとも)意図的に多くのフックを追加しています。紛れもなく、他の誰かが使うために組み込まれています。例えば「before」「after」アクションフックの多さは特筆に値します。

  • woocommerce_after_account_downloads
  • woocommerce_after_account_navigation
  • woocommerce_after_account_orders
  • woocommerce_after_account_payment_methods
  • woocommerce_after_available_downloads
  • woocommerce_after_cart
  • woocommerce_after_cart_contents
  • woocommerce_after_cart_item_name
  • woocommerce_after_cart_table
  • woocommerce_after_cart_totals
  • woocommerce_before_account_downloads
  • woocommerce_before_account_navigation
  • woocommerce_before_account_orders
  • woocommerce_before_account_orders_pagination
  • woocommerce_before_account_payment_methods
  • woocommerce_before_available_downloads
  • woocommerce_before_cart
  • woocommerce_before_cart_collaterals
  • woocommerce_before_cart_contents
  • woocommerce_before_cart_table
  • woocommerce_before_cart_totals

例として、ファイルdownloads.phpには機能を追加するためのアクションが複数あり、ショップのURLはフィルターを通して上書きできます。

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

Service Containerによってコードを拡張可能にする

Service Containerはプロジェクト内のすべてのクラスのインスタンス化管理の手助けをするPHPオブジェクトで、一般的には「依存性挿入」ライブラリの一部として提供されます。

依存性挿入は、アプリケーションのすべての部分を分散化された方法で接着する手法です。PHPクラスが設定によってアプリケーションに挿入され、アプリケーションはService Containerを通してこのPHPクラスのインスタンスを取得します。

依存性挿入ライブラリはたくさんあります。人気のものには例えば以下があります。依存性挿入コンテナについて記述したPSR-11(PHP標準勧告) を満たしているので、それぞれに互換性があります。

Laravelには、アプリケーション組み込み型のService Containerもあります。

依存性挿入を使用すると、無料プラグインでは実行時にどのPHPクラスが存在するかを事前に知る必要が無くなります。純粋に全てのクラスのインスタンスをService Containerにリクエストすることになります。多くのPHPクラスが無料プラグイン自体の機能のために提供され、その他のクラスは、機能を拡張すべくサイトにインストールするアドオンにより管理されます。

Service Containerを使うよい例がGato GraphQLです。これはSymfonyのDependencyInjectionライブラリに依存しています。

Service Containerのインスタンス化方法は以下の通りです。

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

  /**
   * ContainerBuilderを初期化。
   * ディレクトリが指定されていない場合は
   * システムの一時ディレクトリにキャッシュを保存する。
   */
  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);
      }
      
      // このファイル下にキャッシュを保存
      $this->cacheFile = $directory . 'container.php';

      $containerConfigCache = new ConfigCache($this->cacheFile, false);
      $this->cached = $containerConfigCache->isFresh();
    } else {
      $this->cached = false;
    }

    // キャッシュされていない場合は新しいインスタンスを作成
    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;
  }

  /**
   * コンテナがキャッシュされていない場合は、コンパイルしてキャッシュ
   *
   * @param CompilerPassInterface[] $compilerPasses コンテナに登録するCompiler Passオブジェクト
   */
  public function maybeCompileAndCacheContainer(
    array $compilerPasses = []
  ): void {
    /**
     * SymfonyのDependencyInjection Container Builderをコンパイル
     *
     * コンパイル後、パフォーマンス最適化のためにディスクにキャッシュ
     *
     * サーバーで初めてサイトにアクセスしたときにのみ発生
     */
    if ($this->cached) {
      return;
    }

    /** @var ContainerBuilder */
    $containerBuilder = $this->getInstance();
    foreach ($compilerPasses as $compilerPass) {
      $containerBuilder->addCompilerPass($compilerPass);
    }

    // コンテナをコンパイル
    $containerBuilder->compile();

    // コンテナをキャッシュ
    if (!$this->cacheContainerConfiguration) {
      return;
    }
    
    // フォルダが存在しない場合は作成し、成功したことを確認
    $dir = dirname($this->cacheFile);
    $folderExists = file_exists($dir);
    if (!$folderExists) {
      $folderExists = @mkdir($dir, 0777, true);
      if (!$folderExists) {
        return;
      }
    }

    // コンテナをディスクに保存
    $dumper = new PhpDumper($containerBuilder);
    file_put_contents(
      $this->cacheFile,
      $dumper->dump(
        [
          'class' => 'ServiceContainer',
          'namespace' => 'GatoGraphQL',
        ]
      )
    );

    // 外部プロセスで変更できるようにパーミッションを変更
    chmod($this->cacheFile, 0777);
  }
}

Service Container(クラスGatoGraphQL\ServiceContainerを持つPHPオブジェクトでアクセス可)はプラグインが最初に実行されたときに生成され、その後ディスクにキャッシュ(システムtempフォルダのファイルcontainer.php)されます。これは、Service Containerの生成に数秒かかる可能性があるためです。

次に、メインプラグインとそのすべての拡張機能の両方が、設定ファイルを介してコンテナに挿入するサービスを定義します。

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

特定のクラスのオブジェクトをインスタンス化することもできますし(たとえば、GatoGraphQL\GatoGraphQL\Log\LoggerInterface─コントラクトインターフェースGatoGraphQL\GatoGraphQL\Log\Logger からアクセス)、「あるディレクトリ配下のすべてのクラスをインスタンス化する」こともできます(たとえば、../src/Services配下のすべてのサービス)。

最後に、Service Containerに設定を挿入します。

<?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
{
  // YAML設定ファイルで定義したサービスを初期化
  public function initServices(
    string $dir,
    string $serviceContainerConfigFileName
  ): void {
    // 先にコンテナがキャッシュされているかどうかをチェック(されていれば何もしない)
    if (App::getContainerBuilderFactory()->isCached()) {
      return;
    }

    // このモジュールのサービス実装でContainerBuilderを初期化
    /** @var ContainerBuilder */
    $containerBuilder = App::getContainer();
    $loader = new YamlFileLoader($containerBuilder, new FileLocator($dir));
    $loader->load($serviceContainerConfigFileName);
  }
}

コンテナに挿入されたサービスは、常に初期化するように設定することも、要求されたときだけ初期化するように設定することも(遅延モード)できます。

たとえば、カスタム投稿タイプを表すために、プラグインにはAbstractCustomPostTypeクラスがあり、そのinitializeメソッドが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(...)
    );
  }

  /**
   * 投稿タイプを登録
   */
  public function initCustomPostType(): void
  {
    \register_post_type($this->getCustomPostType(), $this->getCustomPostTypeArgs());
  }

  abstract public function getCustomPostType(): string;

  /**
   * 投稿タイプを登録するための引数
   *
   * @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;
  }

  /**
   * 投稿タイプ登録のためのラベル
   *
   * @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),
    );
  }
}

そして、GraphQLCustomEndpointCustomPostType.phpクラスがカスタム投稿タイプの実装になります。コンテナにサービスとして挿入されると、インスタンス化の上で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');
  }
}

このクラスは無料のプラグインに存在し、同様にAbstractCustomPostTypeから拡張される他のカスタム投稿タイプクラスはPRO版の拡張機能で提供されます。

フックとService Containerの比較

2つの設計方法を比較してみましょう。

アクションフックとフィルターフックの強みは、その機能がWordPressコアの一部であり、よりシンプルであることです。また、WordPressを使っている開発者なら誰でも、フックの扱い方をすでに知っているので、簡単に使いこなすことができるでしょう。

しかし、そのロジックは文字列であるフック名に付加されるため、ある種のバグにつながる可能性があります。つまりフック名が変更されると、拡張機能のロジックが壊れてしまいます。そのような場合であってもPHPコードはコンパイルされるため、開発者は問題に気づかないかもしれません。

その結果として、非推奨のフックがコードに長期間、場合によっては永久に残ることになります。そして、拡張モジュールの破損を恐れて削除できない古いコードがプロジェクトに蓄積されていきます。

WooCommerceに話を戻すと、この状況はdashboard.phpファイルで見られます。非推奨のフックがバージョン2.6以降保持されています。現在の最新バージョンは8.5です。

<?php
  /**
   * My Account画面
   *
   * @since 2.6.0
   */
  do_action( 'woocommerce_account_dashboard' );

  /**
   * 非推奨のwoocommerce_before_my_accountアクション
   *
   * @deprecated 2.6.0
   */
  do_action( 'woocommerce_before_my_account' );

  /**
   * 非推奨のwoocommerce_after_my_accountアクション
   *
   * @deprecated 2.6.0
   */
  do_action( 'woocommerce_after_my_account' );

Service Containerを使用すると、外部ライブラリが必要になり、さらに複雑になるという欠点があります。さらに、このライブラリはPHP-ScoperStraussを使って)スコープする必要があります。これは、同じサイトの別のプラグインから同じバージョンのライブラリがインストールされ、競合が発生する可能性があるためです。

Service Containerの使用は間違いなく実装がより難しく、開発時間がかかります。

とはいえプラスの面として、Service Containerではロジックを文字列に変換することなくPHPのクラスを扱うことができます。その結果、プロジェクトでより多くのPHPのベストプラクティスを使うことになり、長期的に保守しやすいコードになります。

まとめ

WordPressプラグインを開発する際には、プラグインの作成者が商用機能を確保したり、他の誰でも機能を追加したりできるように拡張機能をサポートするのがおすすめです。

この記事では、プラグインを拡張可能にするPHPアーキテクチャについてご紹介しました。簡単に言えば、フックを使うかService Containerを使うかです。両方の方法の良し悪しを比較しましたのでご活用ください。

WordPressプラグインを拡張可能にする予定はありますか?コメント欄でお聞かせください。

Leonardo Losoviz

PHP、WordPress、GraphQLを中心に革新的なウェブ開発のトレンドについて執筆している。仕事情報は、leoloso.comとXアカウントで公開中。