| name | basercms5-plugin-development |
| description | baserCMS 5系(CakePHP 5ベース)のプラグインを新規開発・改修する際の正本パターン集。「プラグインを作成」「bc_plugin で bake」「管理画面を追加」「Table/Entity の書き方」「initialize でアソシエーション宣言」「validationDefault」「fetchTable と TableRegistry の使い分け」「BcAdminForm / control() の書き方」「検索フォーム element」「control の id/name 生成規則と JS 連携」「イベントリスナー(BcModelEventListener 等)」「adminNavigation の登録」「setting.php / config.php」「BcShortCode 登録」「paginate($query)」「redirect は return」「_ids で belongsToMany 保存」「BcKeyValue」「afterSave の再入ガード」「$.bcUtil.adminBaseUrl」「Time::format の ICU パターン」「routes.php の RouteBuilder」等で参照する。環境・ディレクトリ・命名・コアハック禁止などの共通ルールは basercms5-development、テーマ開発は basercms5-theme-development、4系→5系の移行作業は basercms-plugin-4-to-5-upgrade、テスト実行・テスト基盤導入は basercms-unittest を参照。 |
| license | MIT |
baserCMS 5系 プラグイン開発の正本パターン集
baserCMS 5系(CakePHP 5ベース)でプラグインを新規開発・改修するときのコーディングパターンを機能別にまとめたもの。環境・ディレクトリ構成・命名規則・コアハック禁止(vendor/baserproject・vendor/cakephp は読み取り専用)などの共通ルールは basercms5-development、テーマ(templates 中心)の開発は basercms5-theme-development、4系からの移行作業は basercms-plugin-4-to-5-upgrade、テストの実行・基盤導入は basercms-unittest、bc-custom-content(カスタムコンテンツ)を使った開発は basercms5-custom-content-development を参照する。
1. 新規開発の始め方
2. ORM / Table / Entity
責務分離: バリデーション・ビジネスロジックは Table(src/Model/Table)、行データの振る舞いは Entity(src/Model/Entity)に書く。DB変更は Migration (phinx) を使う。
- アソシエーションは
initialize() で宣言し、エイリアスは複数形にする。className はプラグイン接頭辞付き・複数形で書く。エイリアスは contain()・条件キー・エンティティのプロパティ名すべてに波及する。
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('sample_articles');
$this->addBehavior('BaserCore.BcUpload', [...]);
$this->belongsTo('Users', ['className' => 'BaserCore.Users', 'foreignKey' => 'user_id']);
$this->hasMany('SampleComments', ['className' => 'Sample.SampleComments', 'foreignKey' => 'article_id', 'dependent' => true]);
$this->belongsToMany('SampleTags', [
'className' => 'Sample.SampleTags',
'joinTable' => 'sample_articles_sample_tags',
'foreignKey' => 'article_id',
'targetForeignKey' => 'tag_id',
]);
}
dependent は hasMany/hasOne のみ有効。
- バリデーションは
validationDefault() に書く。カスタムルールはシグネチャ ($value, $context) のメソッドを add() で登録する。
public function validationDefault(\Cake\Validation\Validator $validator): \Cake\Validation\Validator
{
$validator->notEmptyString('title', 'タイトルを入力してください');
$validator->add('title', 'custom', ['rule' => [$this, 'customMethod'], 'message' => '...']);
return $validator;
}
- 検索はクエリビルダで書く:
$this->find()->where([...])->contain([...])->orderBy([...])->first()/all()。list は find('list', keyField: 'id', valueField: 'name')。
- 配列値に IN は自動付与されない。複数値は必ず
'field IN' => (array)$values と明示する(書かないと Cannot convert value Array 例外)。空配列の IN () は例外になるので、空になりうる場合は先頭でガードして早期 return する。
- null 一致は
IS 演算子: ['field IS' => null]/否定は ['field IS NOT' => null]。
- 関連テーブルの列で絞り込むときは
contain() ではなく ->innerJoinWith('Assocs') か matching() を使う(contain は WHERE に使えない)。条件キーは複数形エイリアス(SampleComments.user_id)。常時 join すると関連なし行が除外される点に注意。
- 保存はエンティティ経由:
$entity = $table->newEntity($data);(新規)または $entity = $table->patchEntity($table->get($id), $data);(更新)→ $table->save($entity);。
- belongsToMany の保存は
_ids で書く: patchEntity($e, $data + ['sample_tags' => ['_ids' => [$id, ...]]], ['associated' => ['SampleTags']])。表示側は contain(['SampleTags'])。
- name/value の KVS テーブルは
initialize() に $this->addBehavior('BaserCore.BcKeyValue') を足すと saveKeyValue([$key => $value]) が使える。読み出しは find()->where(['name' => $key])->first()->value。
- 集計は
func()->sum() + ->first()->alias ?? 0 で書く:
$total = $this->find()
->where(['user_id' => $userId])
->select(['total' => $this->find()->func()->sum('amount')])
->first()->total ?? 0;
- datetime カラムの MIN/MAX に集計関数を使わない。
func()->min()/max() は結果が数値型にキャストされて壊れるため、->orderBy(['col' => 'ASC'])->first() で最古/最新行を取り、そのエンティティの日時プロパティを使う。
- afterSave で他テーブルを save する(相互に save し合う)場合は再入ガードフラグを置く。
save() に callbacks オプションは存在しない(渡しても黙って無視され afterSave は常に発火する)ため、抑止はフラグで行う。
private bool $inAfterSave = false;
public function afterSave(EventInterface $e, EntityInterface $entity, \ArrayObject $o): void
{
if ($this->inAfterSave) return;
$this->inAfterSave = true;
try { } finally { $this->inAfterSave = false; }
}
- 削除は
get($id) → delete($entity) で書く(関連の dependent => true が cascade する)。deleteAll() は ORM の cascade を通さず子が孤児として残るので、子を連れて消す削除には使わない。
fetchTable() は Controller / Command 専用。Table・Lib・Helper・イベントリスナー内では \Cake\ORM\TableRegistry::getTableLocator()->get('Sample.SampleArticles') を使う(fetchTable を呼ぶと BadMethodCallException)。エイリアスは常に複数形。
- **
getControlSource()(フォーム選択肢の供給元)**は Table に定義し、テンプレートからは $this->BcAdminForm->getControlSource('Sample.SampleArticles.user_id') のように Plugin.複数形モデル.field で呼ぶ(単数形だと MissingTableClassException)。
disableHydration() を使うと結果はフラット連想配列になる。select には明示エイリアスを付けて(->select(['id' => 'SampleArticles.id', 'tag_name' => 'SampleTags.name']))$row['id'] でフラット参照する。
- 生SQLが必要なときは
$this->getConnection()->execute($sql)->fetchAll('assoc')(戻りはフラット連想配列)。トランザクションは $this->getConnection()->begin()/commit()/rollback()。
- プラグイン共通のメソッドは共通基底 Table(
\BaserCore\Model\Table\AppTable を継承した SampleAppTable 等)に置き、各 Table はそれを継承する。
3. コントローラ
管理画面コントローラは src/Controller/Admin/ に置き、プラグインの Admin 基底(SampleAdminAppController など、BcAdminAppController を継承)を経由して継承する。
- テーブル取得は
$this->fetchTable('Sample.SampleArticles')(複数形エイリアス)。
- ページネーションはクエリビルダを組んで
$this->paginate($query) に渡す(PaginatorComponent は存在しない。loadComponent('Paginator') は MissingComponentException)。必要な関連は contain()、手書き結合は leftJoinWith()/innerJoinWith()/matching()、->groupBy()/->having() も使える。並べ替え・ページはクエリ文字列 ?sort=&direction=&page= で自動処理される。
- サイト設定値は
\BaserCore\Utility\BcSiteConfig::get('admin_list_num') で取得する。ログインユーザーは \BaserCore\Utility\BcUtil::loginUser()(未ログイン時は false を返すので ?-> は効かない。(BcUtil::loginUser() ?: null)?->id か明示分岐で書く)。
- フラッシュメッセージは
$this->BcMessage->setSuccess('...') / setError('...')。
redirect() は必ず return $this->redirect([...]); と書く。redirect は Response を返すだけで後続コードが実行され続けるため、ガード節(引数チェック→リダイレクト)では return しないと直後の処理まで走って TypeError 等になる。例外: initialize()/beforeFilter() など : void 宣言のメソッド内では return を付けない(付けると TypeError)。
- request は immutable。getter への代入は書けない。値を足すときは
withQueryParams()/withData() で新しい request を作って setRequest() する。
$this->setRequest($this->getRequest()->withQueryParams(
$this->getRequest()->getQueryParams() + ['begin' => $begin]
));
読み取りは getData('key')/getQuery('key')/getParam('key')/getQueryParams()。ajax 判定は $this->getRequest()->is('ajax')。getParam('controller') は CamelCase('SampleArticles')で返る点に注意(分岐で snake_case と比較すると常に false)。
- カスタム Component は
public function initialize(array $config): void { parent::initialize($config); } のシグネチャで書き、コントローラ参照は $this->getController() で取得する。使用するコントローラの initialize() で $this->loadComponent('Sample.Excel') のように明示ロードする(自動ロードはされない)。
- delete アクションの定石:
$this->getRequest()->allowMethod(['post', 'delete']) → $entity = $table->get($id)(RecordNotFoundException を try/catch して「無効な処理です」→ index へ)→ $table->delete($entity)。一覧の bca-submit-token 経由の POST を受けるアクションは FormProtection の unlockedActions に登録する(§6)。
- テンプレートが参照する計算値・設定値はコントローラで明示的に
set() する。旧来のコールバックによる自動注入は無いので、行ごとの計算値は Table の計算メソッド(calcBalance($entity) 等)を paginate 後にループ適用する。プラグイン設定(Configure::read('Sample'))を多数のテンプレートが参照する場合は、プラグインの Admin 基底コントローラの beforeRender() で一括 set する。
public function beforeRender(\Cake\Event\EventInterface $event): void
{
parent::beforeRender($event);
$this->set(\Cake\Core\Configure::read('Sample'));
}
各 Admin コントローラは BcAdminAppController 直継承ではなくこの基底を継承する(直継承だと設定ビュー変数が全画面で欠落する)。
- エラー応答は例外で書く:
throw new \Cake\Http\Exception\BadRequestException(...) / NotFoundException / InternalErrorException。レスポンス操作は $this->setResponse($this->getResponse()->withStatus(400))。
- 検索インデックス・ヘルプは
$this->setSearch('sample_articles_index') / $this->setHelp('...') で設定する。
4. 管理画面フォーム・一覧・検索
- 管理画面のフォームは
$this->BcAdminForm を使い、入力は必ず control() で出す(input() は無い)。フロント用 $this->BcForm/$this->Form や素のウィジェット(text()/select()/multiCheckbox())を直接呼ぶと bca-* の管理スタイルが当たらず崩れる。一覧の一括処理(ListTool.batch)・一括選択(ListTool.checkall)・submit()/button() も BcAdminForm で出す。
- フォーム開始: エンティティがあるなら
create($entity)、コンテキストレスなら create(null, ['valueSources' => ['data', 'context']]) と書く(GET検索フォームは ['query', 'context'])。文字列モデル名を渡すと No context provider found for value of type string。
- 複数チェックボックスは options をループして個別 control で書く(
['multiple' => 'checkbox'] や multiCheckbox() は崩れる):
<?php $selected = array_map('strval', (array)$this->getRequest()->getData('SampleArticle.tags')) ?>
<?php foreach($options as $v => $label): ?>
<?php echo $this->BcAdminForm->control('SampleArticle.tags[]', [
'type' => 'checkbox', 'label' => $label, 'value' => $v,
'checked' => in_array((string)$v, $selected, true), 'hiddenField' => false]) ?>
<?php endforeach ?>
受け側の条件は 'field IN' => (array)$values(§2)。
control() の label に HTML を渡すときは配列形式 ['text' => '<span...>', 'escape' => false] と書く(文字列だとエスケープされ生表示)。
- 検索フォームは
templates/Admin/element/search/{name}.php(単数 search/) に置く。コントローラの setSearch('sample_articles_index') がこのパスを解決する。検索は GET 送信が基本(条件がURLに残りページャ遷移でも保持)で、コントローラは getQueryParams() から条件を読む。入力値の再表示には valueSources に query を含める。
control() の DOM 生成規則を把握して JS と整合させる(描画は通るのに JS 連携が無言で死ぬ最頻出ポイント):
- id は小文字ハイフン:
control('SampleArticle.publish_date') → id="samplearticle-publish-date"(Text::slug による)。
- name は
Model[field] 形式: name="SampleArticle[publish_date]"。
- バンドルJSが CamelCase id(
#SampleArticlePublishDate)や別形式の name を掴む設計なら、JS 側を実生成の id/name に合わせるか、control(..., ['id' => 'SampleArticlePublishDate']) で明示 id を振る(その場合 label の for も合わせる。name は POST キーなので変えない)。フォーム全体への委譲 $("#FormId").on('change', 'select, input', ...) にすると id 依存自体をなくせる。
- 統合テストで「JS が期待する id/name が HTML に実在する」ことを assert する番人パターンを置くと、この不整合の回帰を検出できる(描画200のテストでは検出できない。basercms-unittest 参照)。
- 年月セレクトは
control('Model.field.year', ['type' => 'select', 'options' => $years, 'label' => false]) のように自前 options で書く(year()/month() というメソッドは無い)。getData('Model.field') は ['year' =>, 'month' =>] で受かる。
- 一覧 element とフォームの同居に注意:
Paginator->sort() や element('pagination') を含む一覧 element を、ページネーションしない画面(編集フォームへの埋め込み等)で描画すると You must set a pagination instance using setPaginated() first で落ちる。ページャ系の描画は「専用一覧コントローラのときだけ」に getParam('controller') === 'SampleArticles'(CamelCase)でガードする。
- 生SQL等のカスタムページネーションは、総件数+当ページ行を自前で取得し
new \Cake\Datasource\Paging\PaginatedResultSet(new \ArrayIterator($rows), $params) をビュー変数に set する(PaginatorHelper が PaginatedInterface を自動検出する)。sort カラムはホワイトリストで ORDER BY を組み立てる。
- フォーム拡張フックは
$this->BcFormTable->dispatchBefore()/dispatchAfter()、$this->BcAdminForm->dispatchAfterForm() を使う。
BcBaser->link() に画像/HTML を渡すときは ['escape' => false]。アイコンボタンは ['class' => 'bca-btn-icon', 'data-bca-btn-type' => 'add', 'data-bca-btn-size' => 'lg'] の作法で書く。
5. イベント
リスナーは src/Event/ に置く。プラグインの BcPlugin が自動 attach するため、リスナーが壊れているとイベント発火で即 Fatal になる点に注意して書く。
- 基底クラス: モデル系は
BcModelEventListener、コントローラ系は BcControllerEventListener、ビュー系は BcViewEventListener、ヘルパ系は BcHelperEventListener を継承する。
- イベント名の命名規則:
public $events = [...] に登録する。
- 自プラグインのモデルイベント:
'SampleArticles.afterSave'(Table 名の複数形)。
- 他プラグインを修飾する場合:
'BcBlog.BlogPosts.afterSave'(プラグイン名+Table複数形)。名前が実在イベントと一致しないとエラーも出ずに無発火になるため、発火側を grep -rn "dispatchLayerEvent\|createEvent" vendor/baserproject/... して実在するイベント名に合わせる。
- View 系は
BcViewEventDispatcher が標準イベントを {Plugin}.{ViewName}.afterRender として再ディスパッチする方式。'BcBlog.Blog.afterRender' のように View の getPlugin()/getName() に一致させる。
- ハンドラ名はイベント名の CamelCase:
'BcBlog.BlogPosts.afterSave' → public function bcBlogBlogPostsAfterSave(EventInterface $event)。型ヒントは \Cake\Event\EventInterface。
- イベントデータ: 読み取りは
$event->getData('key')、書き込みは $event->setData('key', $v)、サブジェクト(Table/Controller/View)は $event->getSubject()。保存系イベントのエンティティは $event->getData('entity') で取る。
- コンストラクタで Table を即時取得しない。リスナーは bootstrap 段階で生成されるため、コンストラクタ内の
TableRegistry::...->get() は MissingTableClassException になる。ハンドラ内で必要になったときに遅延取得する。
Model.afterFind というイベントは存在しない。find 結果の加工は formatResults() かカスタム finder に書く。
- View 系ハンドラでビュー変数を読むときは
$view->get('varName') を使う($view->viewVars プロパティは無い)。$view->request プロパティも無い(未知プロパティ参照はヘルパの自動ロード扱いとなり xxxHelper could not be found で全画面を巻き込む)ので $view->getRequest()->getParam(...) で書く。
- ハンドラからビューへ値を渡すときは
$controller->set('x', $v)(request への代入は immutable のため無効)。
6. Vue / jQuery 連携
- 管理画面の ajax / リンク URL をハードコードしない。配置(サブディレクトリ・prefix 変更)で壊れるため、baser が用意するグローバル
$.bcUtil.adminBaseUrl(baseUrl + '/' + baserCorePrefix + '/' + adminPrefix + '/'、末尾スラッシュ付き)に連結して書く: $.bcUtil.adminBaseUrl + 'sample/sample_articles/edit/' + id。
- Vue2 のテンプレート式からはグローバル
$ を参照できないので、computed adminBaseUrl() { return $.bcUtil.adminBaseUrl; } を公開し :href="adminBaseUrl + 'sample/...'" と書く。
- Vue バンドルはソース(
webroot/js/src)を直して webpack で再ビルドする(コンパイル済み .bundle.js/.bundle.css を手で編集しない)。webpack.config.js があれば cd plugins/Sample && npm install && npx webpack。出力は webroot/js/[name].bundle.js、CSS は ../css/[name].bundle.css。再ビルド後に bundle へ反映されたか grep し、ブラウザは Cmd+Shift+R で確認する。
- ajax アクションは Response を返す:
return $this->getResponse()->withType('application/json')->withStringBody(json_encode($rows ?: []));。文字列を return しても出力されない。Vue が参照するフィールドはフラット配列に整形して返す。
- Vue が動的生成するフォーム行(明細等)は FormProtection に弾かれる(
Unexpected field '...' in POST data)。描画時に DOM に無いフィールドは送信トークンに含まれないため、動的フィールドを POST するアクションは beforeFilter() で $this->FormProtection->setConfig('unlockedActions', ['add', 'edit', ...]) に登録する(CSRF は別ミドルウェアなので維持される)。Vue 側の :name は 'Model[' + i + '][field]' 形式で書く(コントローラの getData('Model') で受かる形)。
- prop に複数型が来るなら
type: [String, Number] のように許容する。ajax レスポンスが JSON でなく文字列になると v-for が文字単位で反復する(Expected Array, got String が1文字ずつ大量に出たらサーバ側エラーを疑う)。
7. 日付・数値・文字列
$this->Time->format($d, 'yyyy-MM-dd') は ICU パターンで書く(PHP の date() 形式ではない。Y=週年・m=分・M=月なので 'Y-m-d' と書くと月が0になる)。スラッシュ区切りは 'yyyy/MM/dd'、時刻込みは 'yyyy-MM-dd HH:mm'。第2引数を省略すると日時表示になるため、日付のみは必ず指定する。BcTime->format も同様。
- 一方、エンティティの日時プロパティの
$v->format('Y/m/d') は PHP の date() 形式(DateTimeInterface::format)。TimeHelper 経由(ICU)と使い分ける。
- null になりうる日付は
$this->Time->format($e->x, 'yyyy-MM-dd')(helper は null で空文字)か $e->x?->format('Y-m-d') で書く。
Number::format() / NumberHelper::format() / currency() は第1引数 null 不可(TypeError)。nullable な値は format($x ?? 0, [...]) / currency($e->budget ?? 0, ...) とガードする。
Text::truncate() / BcText::truncate() も第1引数 string 必須。nullable カラムは truncate((string)$e->notes, 46) と (string) キャストする。
- 独自の数値フォーマット登録(addFormat 相当)は無い。記号なし3桁区切りは
$this->Number->format($v, ['places' => 0]) で書く(currency() の第2引数は ICU の ISO 通貨コード前提で、未知コードは接頭辞としてそのまま出力される)。
8. ルーティング・ショートコード・管理画面CSS
config/routes.php は RouteBuilder のクロージャで書く:
<?php
use Cake\Routing\RouteBuilder;
return function (RouteBuilder $routes) {
$routes->connect('/sample/*', ['plugin' => 'Sample', 'controller' => 'SampleArticles', 'action' => 'view']);
};
コントローラ名は CamelCase。Router::getRequest() を routes.php で使う場合は CLI 実行時に null になるため if (!$request) return; のガードを必ず入れる(無いと bin/cake が全コマンド停止する)。
- ショートコードは setting.php の
'BcShortCode' キーに登録する(§1のコード例参照)。登録元プラグインが無効だと未登録扱いとなり、コンテンツ中の [Sample.showList] がエラーなく生テキストのまま露出する(プラグイン有効化が前提)。
- 横長テーブル(多数列)の管理画面では、管理テーマの
.bca-container(flex) 子要素 .bca-main に min-width:0 が無いためページ全体が横スクロールする。その画面の CSS に .bca-main { min-width: 0; } を足し、テーブルは overflow:auto のスクロールラッパーで囲う(position:sticky の列固定もこれで効く)。CSSがバンドル内なら webpack 再ビルド(§6)。
9. テスト
テストの実行手順・プラグイン単体(スタンドアロン)テスト基盤の導入・Fixture Factory の書き方は basercms-unittest スキルを正本として参照する。開発時の要点だけ挙げると: 管理画面はログイン付きコントローラ統合テストで GET 描画200+POST フロー(DB 変化の assert)を固定する、描画200は warning を握り潰すのでテスト後に error.log の pristine を確認する、JS 連携がある画面は「JS が期待する DOM id/name が HTML に実在する」assert(§4の番人パターン)を置く、日付保存は値まで assert する(silently NULL を見逃さない)。