Skip to main content 首页 创作者 baserproject basercms basercms5-plugin-development
basercms5-plugin-development 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 を参照。
跳到安装 Skills Marketplace 发现并探索由社区构建的 Agent Skills
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/baserproject/basercms --skill basercms5-plugin-development命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
下载 Zip 下载中... 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 は / のみ有効。
3. コントローラ
管理画面コントローラは src/Controller/Admin/ に置き、プラグインの Admin 基底(SampleAdminAppController など、BcAdminAppController を継承)を経由して継承する。
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 (( ) , , ), => ])
受け側の条件は (§2)。
5. イベント
リスナーは src/Event/ に置く。プラグインの BcPlugin が自動 attach する ため、リスナーが壊れているとイベント発火で即 Fatal になる点に注意して書く。
自動 attach の実体・ファイル名の厳密な規則 : BaserCore\Utility\BcEvent::registerPluginEvent()(BaserCorePlugin から有効化済み全プラグインに対して呼ばれる)が、src/Event/{Plugin名}{Controller|Model|View|Helper|Mailer}EventListener.php というファイル名と完全一致するクラス だけを自動生成・EventManager::instance()->on() で登録する。例えば <Plugin> プラグインでヘッダーを差し替えるビューイベントリスナーなら、ファイル名は必ず src/Event/<Plugin>ViewEventListener.php、クラス名も <Plugin>ViewEventListener にする(名前が1文字でもズレると静かに登録されず、イベントが一切発火しない)。
基底クラス : モデル系は 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') を使う( プロパティは無い)。 プロパティも無い(未知プロパティ参照は 扱いとなり で全画面を巻き込む)ので で書く。
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 を見逃さない)。
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 はそれを継承する。 getParam('controller')
CamelCase
'SampleArticles'
カスタム 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('...') で設定する。 string
$v
$selected
true
'hiddenField'
false
?>
<?php
endforeach
?>
'field IN' => (array)$values
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'] の作法で書く。$view->viewVars
$view->request
ヘルパの自動ロード
xxxHelper could not be found
$view->getRequest()->getParam(...)
ハンドラからビューへ値を渡すときは $controller->set('x', $v)(request への代入は immutable のため無効)。