素のPHPで動くCMSに、フック式のプラグイン機構を後付けした設計メモ

フレームワークなし、素のPHPで10年動いているCMSがあります。
そこへ「本体を触らずに機能を足せる仕組み」を後付けしたときの設計判断を、忘れないうちに書き残しておきます。

案件ごとに本体のコードへ直接手を入れる運用を続けていると、確実に破綻の日が来ます。
A案件のための変更がB案件を壊す。差分がどんどん増えて、本体のバージョンアップが誰にもできなくなる。
うちのCMSも、その一歩手前まで行きました。

答え自体は昔からあって、WordPressがやっているようなフック式です。
本体は「いま保存が終わったよ」と叫ぶだけ。何をするかはプラグイン側が決める。
問題は、後付けでどこまで小さく実装できるか、でした。

最小実装:クラスひとつ、メソッドふたつ

最初のバージョンは本当にこれだけです。静的クラス1枚で、登録(add)と発火(run)しかありません。

class/Hook.php

<?php

final class Hook
{
    /** @var array<string, array<int, callable[]>> フック名 => 優先度 => コールバック群 */
    private static $actions = [];

    public static function add(string $name, callable $fn, int $priority = 10): void
    {
        self::$actions[$name][$priority][] = $fn;
    }

    public static function run(string $name, ...$args): void
    {
        if (empty(self::$actions[$name])) {
            return;
        }
        ksort(self::$actions[$name]);
        foreach (self::$actions[$name] as $callbacks) {
            foreach ($callbacks as $fn) {
                $fn(...$args);
            }
        }
    }
}

本体側は、要所にこの1行を置くだけ。フックが誰にも使われていなければ、実質何もしないので既存動作を壊しません。
「後付け」の条件はここで、既存コードへの変更が挿入だけで済むことが決定的に重要でした。

本体側(保存処理の末尾)とプラグイン側

// 本体側:投稿の保存が確定した直後に叫ぶ
Hook::run('post_saved', $postId, $isNew);

// プラグイン側:検索インデックスのプラグインが拾う
Hook::add('post_saved', function (int $postId, bool $isNew) {
    SearchIndex::rebuildFor($postId);
});

// 別のプラグインも同じフックに相乗りできる(優先度で順序制御)
Hook::add('post_saved', function (int $postId) {
    Cache::purge('post_' . $postId);
}, 20);

フックポイントをどこに切るか:迷ったときの基準

実装よりずっと難しかったのが、「どこに Hook::run を置くか」でした。
多すぎれば本体が叫び声だらけになるし、少なすぎればプラグインが書けない。
半年運用して、基準は3つに落ち着いています。

ひとつめは、状態が確定した直後に置くこと。
保存「しようとしている」途中ではなく、保存が「終わった」直後。中途半端な状態をプラグインに見せると、事故の原因になります。

ふたつめは、画面の描画では「枠の切れ目」に置くこと。
ヘッダーの直後、本文の直前、フッターの直前。HTMLの任意の場所に差し込めるようにはせず、挿入口を数カ所に限定しました。
自由度は下がりますが、プラグイン同士が同じ場所を取り合って崩れる事故は起きていません。

みっつめは、フック名を「主語+過去形」で統一すること。
post_saveduser_deletedpage_rendered
命名が揃っているだけで、プラグインを書く側(未来の自分を含む)の推測が当たるようになります。

少しの後悔:フィルタを最初から分けておけばよかった

後悔はひとつだけあって、値を書き換えて返すタイプのフック(WordPressで言うフィルタ)を最初のバージョンで用意しなかったことです。

運用を始めるとすぐに「本文のHTMLを出力前に加工したい」という要求が出ました。
それをアクション型の Hook::run で無理やり実現しようとして、引数に参照渡しの配列を突っ込む、いま思えばかなり苦しい実装を一度やっています。
結局 Hook::filter() を別メソッドとして追加して書き直しましたが、最初の設計時点で「通知(アクション)」と「加工(フィルタ)」は別物だと割り切っておくべきでした。

それでも、この機構を入れてから案件固有のコードは本体からほぼ消えました。
本体のコードを汚さずに機能を足せる安心感は、想像していたより大きかった。
なお、この改修の一部はAIエージェントとの分業で進めていて、その前提づくりの話は規約ファイルを1枚書いた話に書きました。

「納品して終わり」をやめた経緯は受託開発15年目の記事でどうぞ。

技術ブログ一覧へ戻る