For baserCMS 5 series (based on CakePHP 5)newly developing or modifying pluginsThis is a feature-by-feature compilation of coding patterns for when doing so. Common rules such as environment, directory structure, naming conventions, and the prohibition of core hacks (vendor/baserproject・vendor/cakephp is read-only), etc., are described in basercms5-development, theme development (mainly templates) is described in basercms5-theme-development, migration work from the 4 series is described in basercms-plugin-4-to-5-upgrade, test execution and infrastructure setup are described in basercms-unittest, and development using bc-custom-content (custom content) is described in basercms5-custom-content-development .
1. How to start new development
- Generate the skeleton with bake:
bin/cake bake bc_plugin {PluginName}can generate a plugin skeleton, andbin/cake bake bc_all {ModelName} --plugin {PluginName}can generate a full set of Table/Entity/Controller/templates. For the procedure to retrofit a test foundation onto an existing plugin, refer to basercms-unittest . - Standard directory structure:
src/{PluginName}Plugin.php— Plugin classsrc/Controller/(front)・src/Controller/Admin/(admin screen)src/Model/Table/・src/Model/Entity/src/View/Helper/・src/Event/(event listeners. Simply placing them here attaches them automatically)config/(config.php・setting.php・routes.php・Migrations/)templates/(front)・templates/Admin/(admin screen. The search form is written astemplates/Admin/element/search/)webroot/(js/css/img. Vue source iswebroot/js/src)
- Plugin classは
class SamplePlugin extends \BaserCore\BcPlugin.install(array $options = []): boolはreturn parent::install($options);should be the basis, andbootstrap()should be used **only for dynamic settings (adding Configure values determined at runtime, registering log settings, etc.)**. - Make setting.php / config.php a "static return array"(do not write executable code). Separate dynamically determined parts into
bootstrap().// config/setting.php return [ 'BcApp' => [...], 'BcShortCode' => ['Sample' => ['Sample.showList']], ]; - adminNavigation (admin menu)is registered in setting.php.
'plugin'・'controller'はAll CamelCase write it with'controller' => 'SampleArticles'). If written in snake_case,all admin screens will return 404(because URL resolution fails when rendering the menu, it affects screens other than the target screen as well). - The
{controller}segment in admin screen URLs is actually the plugin name converted to dash-delimited (kebab-case)(example:BlogTagGroup→/baser/admin/blog-tag-group/{controller}/...). Do not infer from other plugins' URL patterns; after checking the actual route withbin/cake routes | grep <PluginName>(search with case lowered), use it. - BcBlog(
bc-blog)'s admin screen "article edit" URL requires the 2 argumentsedit/{blogContentId}/{id}as mandatory(Admin\BlogPostsController::beforeFilter()が$this->request->getParam('pass.0')をBlog content ID, and without them it becomesBcException: コンテンツデータが見つかりません。). It cannot be accessed withedit/{id}containing only the article ID. When another plugin links to the edit screen for a specific blog article (for example, the editing path of an additional fields plugin), pass both according to the signature ofedit($blogContentId, $id). - Activationis done from the plugin management in the admin screen, or by directly INSERTing into the
pluginstable (status=1). Plugins activated in the DB are loaded automatically, so registration in composer orcomposer dump-autoloadis not required. - Introduce third-party libraries (e.g.
phpoffice/phpspreadsheet)はcomposer require). Place template assets (Excel templates, etc.) intemplates/Admin/Excel/...and reference them with\Cake\Core\Plugin::templatePath('Sample') . 'Admin' . DS . 'Excel' . DS . ...(templatePath()must have a trailing slash).
2. ORM / Table / Entity
Separation of responsibilities: write validation and business logic in the Table (src/Model/Table), and row data behavior in the Entity (src/Model/Entity). Use Migrations (phinx) for DB changes.
- Associations are set to
initialize()and the alias should be plural.classNamemust be written with the plugin prefix and in plural form. The alias affectscontain(), condition keys, and all entity property names.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/hasOneis the only valid option. - Validation uses the methods of
validationDefault(). Register custom rules with the signature($value, $context)withadd().public function validationDefault(\Cake\Validation\Validator $validator): \Cake\Validation\Validator { $validator->notEmptyString('title', 'タイトルを入力してください'); $validator->add('title', 'custom', ['rule' => [$this, 'customMethod'], 'message' => '...']); return $validator; } - Write searches with the query builder:
$this->find()->where([...])->contain([...])->orderBy([...])->first()/all()。list はfind('list', keyField: 'id', valueField: 'name')。- IN is not automatically added for array values. Always explicitly specify
'field IN' => (array)$valuesfor multiple values (otherwiseCannot convert value Arrayexception).An empty arrayIN ()causes an exception, so if it can be empty, guard at the beginning and return early. - For null matching, use the
ISoperator:['field IS' => null]/for negation use['field IS NOT' => null]。 - When filtering by columns in related tables, use
contain()instead of->innerJoinWith('Assocs')かmatching()(containcannot be used in WHERE). Condition keys use the plural alias (SampleComments.user_id). Note that always joining excludes rows with no relation.
- IN is not automatically added for array values. Always explicitly specify
- Save via the entity:
$entity = $table->newEntity($data);(new) or$entity = $table->patchEntity($table->get($id), $data);(update) →$table->save($entity);。 - Write belongsToMany saves with
_ids:patchEntity($e, $data + ['sample_tags' => ['_ids' => [$id, ...]]], ['associated' => ['SampleTags']]). On the display side, addcontain(['SampleTags'])。 - name/value KVS tableは
initialize()に$this->addBehavior('BaserCore.BcKeyValue')andsaveKeyValue([$key => $value])becomes available. For reading, usefind()->where(['name' => $key])->first()->value。 - Write aggregations with
func()->sum()+->first()->alias ?? 0:$total = $this->find() ->where(['user_id' => $userId]) ->select(['total' => $this->find()->func()->sum('amount')]) ->first()->total ?? 0;- Do not use aggregate functions for MIN/MAX on datetime columns。
func()->min()/max()because the result is cast to a numeric type and corrupted; instead use->orderBy(['col' => 'ASC'])->first()to retrieve the oldest/newest row and use that entity's datetime property.
- Do not use aggregate functions for MIN/MAX on datetime columns。
- When saving other tables in afterSave (saving each other mutually), place a reentry guard flag。
save()にcallbacksoptions aredoes not exist(even if passed, it is silently ignored and afterSave always fires), so suppress via a flag.private bool $inAfterSave = false; public function afterSave(EventInterface $e, EntityInterface $entity, \ArrayObject $o): void { if ($this->inAfterSave) return; $this->inAfterSave = true; try { /* 他テーブルの save 等 */ } finally { $this->inAfterSave = false; } } - Write deletions with
get($id)→delete($entity)(relateddependent => truecascades).deleteAll()does not go through the ORM cascade, leaving children as orphans, so do not use it for deletions that should remove children together. fetchTable()is exclusively for Controller / Command. Inside Table, Lib, Helper, and event listeners, use\Cake\ORM\TableRegistry::getTableLocator()->get('Sample.SampleArticles')(callingfetchTableresults inBadMethodCallException). Aliases are always plural.- **
getControlSource()(the source for form options) ** must be defined in the Table, and called from templates with$this->BcAdminForm->getControlSource('Sample.SampleArticles.user_id')likePlugin.複数形モデル.field(if singular,MissingTableClassException)。 disableHydration()Using gives a flat associative array. Add explicit aliases to select (->select(['id' => 'SampleArticles.id', 'tag_name' => 'SampleTags.name']))$row['id']reference flatly with .- When raw SQL is needed, use
$this->getConnection()->execute($sql)->fetchAll('assoc')(the return is a flat associative array). For transactions, use$this->getConnection()->begin()/commit()/rollback()。 - Methods common to plugins go in the common base Table (
\BaserCore\Model\Table\AppTablewhich extendsSampleAppTable, etc.), and each Table extends it.
3. Controller
Place admin screen controllers in src/Controller/Admin/ , and inherit via the plugin's Admin base (SampleAdminAppController etc.,BcAdminAppController which extends).
- For table retrieval, use
$this->fetchTable('Sample.SampleArticles')(plural alias). - For pagination, build a query builder and pass it to
$this->paginate($query)(PaginatorComponent does not exist.loadComponent('Paginator')はMissingComponentException). For necessary associations, usecontain(), and for manual joins you can also useleftJoinWith()/innerJoinWith()/matching()、->groupBy()/->having(). Sorting and paging are handled automatically via the query string?sort=&direction=&page=. - Retrieve site setting values with
\BaserCore\Utility\BcSiteConfig::get('admin_list_num'). For the logged-in user, use\BaserCore\Utility\BcUtil::loginUser()(when not logged in it returnsfalse, so?->does not work.(BcUtil::loginUser() ?: null)?->idWrite it with or an explicit branch). - Flash messages are
$this->BcMessage->setSuccess('...')/setError('...')。 redirect()must always be written asreturn $this->redirect([...]);.redirect only returns a Response, sosubsequent code continues to execute, so in guard clauses (argument check -> redirect), if you do not return, the immediately following processing also runs and causes TypeError etc.Exception:initialize()/beforeFilter()etc.: voidDo not add return inside a method declared with (adding it causes TypeError).- request は immutable. You cannot write an assignment to a getter. When adding a value, create a new request with
withQueryParams()/withData()andsetRequest()do.
For reading, use$this->setRequest($this->getRequest()->withQueryParams( $this->getRequest()->getQueryParams() + ['begin' => $begin] ));getData('key')/getQuery('key')/getParam('key')/getQueryParams(). Note that the ajax check returns with$this->getRequest()->is('ajax')。getParam('controller')は CamelCase('SampleArticles') (if you branch and compare with snake_case, it is always false). - Custom Component は
public function initialize(array $config): void { parent::initialize($config); }Write with the signature, and obtain controller references with$this->getController(). Explicitly load theinitialize()で$this->loadComponent('Sample.Excel')of the controller to be used, like (autoloading is not done). - Standard pattern for the delete action:
$this->getRequest()->allowMethod(['post', 'delete'])→$entity = $table->get($id)(RecordNotFoundExceptiontry/catch -> "Invalid operation" -> to index) ->$table->delete($entity). Actions that receive POSTs via the list's bca-submit-token should be registered in FormProtection'sunlockedActions(§6). - Calculated values and setting values referenced by templates should be explicitly
set()doin the controller. Since there is no automatic injection via legacy callbacks, apply per-row calculated values by looping after paginate using Table calculation methods (calcBalance($entity), etc.). If many templates reference plugin settings (Configure::read('Sample')), then in theof the plugin's Admin base controllerbeforeRender()set them all at once do.
Each Admin controllerpublic function beforeRender(\Cake\Event\EventInterface $event): void { parent::beforeRender($event); $this->set(\Cake\Core\Configure::read('Sample')); }BcAdminAppControllerInherit this base rather than inheriting directly (if inherited directly, the settings view variables will be missing on all screens). - Write error responses as exceptions:
throw new \Cake\Http\Exception\BadRequestException(...)/NotFoundException/InternalErrorException. Response manipulation is$this->setResponse($this->getResponse()->withStatus(400))。 - Search index and help are configured with
$this->setSearch('sample_articles_index')/$this->setHelp('...').
4. Admin panel forms, lists, and search
- Admin panel forms use
$this->BcAdminForm, and inputs must always be output withcontrol()there is no(input()). For front-end$this->BcForm/$this->Formor raw widgets (text()/select()/multiCheckbox()), calling them directly means the bca-* admin styles are not applied and the layout breaks. List bulk processing (ListTool.batch) and bulk selection (ListTool.checkall)・submit()/button()もBcAdminFormare output. - Form start: If there is an entity,
create($entity), if contextless,create(null, ['valueSources' => ['data', 'context']])write it as (for GET search forms,['query', 'context']). If you pass a string model name,No context provider found for value of type string。 - For multiple checkboxes, loop over options and write individual control (
['multiple' => 'checkbox']やmultiCheckbox()breaks):
The condition on the receiving side is<?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()のlabelWhen passing HTML to, use the array format['text' => '<span...>', 'escape' => false]and write it as (if a string, it is escaped and displayed raw).- Search forms are placed in
templates/Admin/element/search/{name}.php(singularsearch/) The controller'ssetSearch('sample_articles_index')resolves this path. Search is basically submitted via GET (conditions remain in the URL and are preserved during pager transitions), and the controller reads conditions fromgetQueryParams(). To redisplay input values, includevalueSourcesにquery. control()Understand the DOM generation rules of and keep JS consistent with them(rendering succeeds but JS integration silently dies: the most frequent pitfall):- id is lowercase hyphen:
control('SampleArticle.publish_date')→id="samplearticle-publish-date"(Text::slugby). - name は
Model[field]format:name="SampleArticle[publish_date]"。 - Bundle JS is guarded by CamelCase id (
#SampleArticlePublishDate) or if the design grabs names in another format,match the JS side to the actually generated id/nameか、control(..., ['id' => 'SampleArticlePublishDate'])assign an explicit id with (in that case, also match the label'sfor(name is the POST key, so do not change it). Delegation to the entire form$("#FormId").on('change', 'select, input', ...)can eliminate the id dependency itself. - Guardian pattern that asserts in integration tests that "the id/name JS expects actually exists in the HTML"If you add this, you can detect regressions of this mismatch (a rendering-200 test cannot detect it. See basercms-unittest).
- id is lowercase hyphen:
- For year-month selects, write them with your own options such as
control('Model.field.year', ['type' => 'select', 'options' => $years, 'label' => false])(year()/month()there is no such method).getData('Model.field')は['year' =>, 'month' =>]passes. - Be careful about list elements and forms coexisting:
Paginator->sort()やelement('pagination')If you render a list element containing on a screen that does not paginate (such as embedding it in an edit form), it will fail withYou must set a pagination instance using setPaginated() first. Render pager-related output only when "it is a dedicated list controller"getParam('controller') === 'SampleArticles'(CamelCase). - Custom pagination such as raw SQLfetch the total count + current page rows yourself and set
new \Cake\Datasource\Paging\PaginatedResultSet(new \ArrayIterator($rows), $params)as a view variable (PaginatorHelperPaginatedInterfaceis automatically detected). The sort column builds ORDER BY using a whitelist. - The form extension hook uses
$this->BcFormTable->dispatchBefore()/dispatchAfter()、$this->BcAdminForm->dispatchAfterForm(). BcBaser->link()When passing images/HTML to['escape' => false]. Icon buttons are['class' => 'bca-btn-icon', 'data-bca-btn-type' => 'add', 'data-bca-btn-size' => 'lg'], write it in the manner of
5. Events
Listeners are src/Event/ .The plugin's BcPlugin automatically attaches, so be aware that if a listener is broken, event firing will immediately cause a Fatal, and write accordingly.
- The actual mechanism of auto-attach and the strict rules for file names:
BaserCore\Utility\BcEvent::registerPluginEvent()(BaserCorePluginis called for all enabled plugins), butsrc/Event/{Plugin名}{Controller|Model|View|Helper|Mailer}EventListener.phpcalledonly the class that exactly matches the file nameis automatically generated andEventManager::instance()->on()registered. For example, for a view event listener that replaces the header in the<Plugin>plugin, the file name must besrc/Event/<Plugin>ViewEventListener.phpand the class name must also be<Plugin>ViewEventListener(if the name is off by even one character, it will silently not be registered, and no events will fire at all). - Base class: Model-related classes inherit from
BcModelEventListener, controller-related fromBcControllerEventListener, view-related fromBcViewEventListener, and helper-related fromBcHelperEventListener. - Event naming conventions:
public $events = [...].- Model events of your own plugin:
'SampleArticles.afterSave'(Table name plural)。 - When qualifying another plugin:
'BcBlog.BlogPosts.afterSave'(plugin name + Table plural). If the name does not match an actual event,it will not fire without even an error, sogrep -rn "dispatchLayerEvent\|createEvent" vendor/baserproject/...the firing side and match it to an existing event name. - For View-related ones,
BcViewEventDispatcherre-dispatches standard events as{Plugin}.{ViewName}.afterRender.'BcBlog.Blog.afterRender'like the View'sgetPlugin()/getName()to match.
- Model events of your own plugin:
- Handler names use the CamelCase of the event name:
'BcBlog.BlogPosts.afterSave'→public function bcBlogBlogPostsAfterSave(EventInterface $event). Type hints are\Cake\Event\EventInterface。 - Event data: For reading,
$event->getData('key'), and for writing,$event->setData('key', $v), the subject (Table/Controller/View) is$event->getSubject(). For save-related events, get the entity with$event->getData('entity'). - Do not obtain the Table immediately in the constructor. Listeners are created during the bootstrap stage, so
TableRegistry::...->get()はMissingTableClassExceptionin the constructor. Obtain it lazilylazy retrievaldo. Model.afterFindThere is no event called. Processing of find results should be written informatResults()or a custom finder.- When reading view variables in View-related handlers, use
$view->get('varName')($view->viewVarsthere is no property).$view->requestThere is also no property (referencing an unknown property is treated asautomatic loading of a helperandxxxHelper could not be foundinvolves the entire screen), so write with$view->getRequest()->getParam(...). - When passing values from a handler to the view, use
$controller->set('x', $v)(assignment to request is ineffective because it is immutable).
6. Vue / jQuery Integration
- Do not hardcode ajax / link URLs in the admin screen. This is because they break with deployment (subdirectory, prefix changes), so concatenate with the global
$.bcUtil.adminBaseUrl(baseUrl + '/' + baserCorePrefix + '/' + adminPrefix + '/'(with trailing slash) provided by baser and write it:$.bcUtil.adminBaseUrl + 'sample/sample_articles/edit/' + id。- From Vue2 template expressions, the global
$cannot be referenced, so computedadminBaseUrl() { return $.bcUtil.adminBaseUrl; }expose:href="adminBaseUrl + 'sample/...'"and write it as
- From Vue2 template expressions, the global
- Vue bundle is source (
webroot/js/src) and rebuild with webpack(do not manually edit the compiled.bundle.js/.bundle.css).webpack.config.jsif there iscd plugins/Sample && npm install && npx webpack. The output iswebroot/js/[name].bundle.js、CSS は../css/[name].bundle.css. After rebuilding, grep to check whether it was reflected in the bundle, and check the browser with Cmd+Shift+R. - ajax actions return a Response:
return $this->getResponse()->withType('application/json')->withStringBody(json_encode($rows ?: []));. Returning a string does not output it. Format the fields referenced by Vue into a flat array and return them. - Form rows dynamically generated by Vue (such as line items) are rejected by FormProtection(
Unexpected field '...' in POST data). Fields that are not in the DOM at render time are not included in the submission token, so actions that POST dynamic fields should be registered inbeforeFilter()で$this->FormProtection->setConfig('unlockedActions', ['add', 'edit', ...])(CSRF is separate middleware, so it is maintained). On the Vue side, write in the:nameは'Model[' + i + '][field]'format (a form that is accepted by the controller'sgetData('Model')). - If multiple types come in for a prop, allow them like
type: [String, Number]. If the ajax response becomes a string instead of JSON,v-foriterates character by character (ifExpected Array, got Stringappears in large amounts one character at a time, suspect a server-side error).
7. Dates, Numbers, Strings
$this->Time->format($d, 'yyyy-MM-dd')is an ICU patternwrite with (not the PHPdate()format.Y=week-year,m=minute,M=month, so writing'Y-m-d'makes the month 0). Slash-separated is'yyyy/MM/dd', with time is'yyyy-MM-dd HH:mm'。Omitting the second argument results in datetime display, so always specify it for date only.BcTime->formatis the same.- On the other hand,the
$v->format('Y/m/d')of an entity's datetime property is PHP's date() format(DateTimeInterface::format). Use it differently from going through TimeHelper (ICU). - For dates that can be null, write
$this->Time->format($e->x, 'yyyy-MM-dd')(the helper gives an empty string for null) or$e->x?->format('Y-m-d').
- On the other hand,the
Number::format()/NumberHelper::format()/currency()cannot take null as the first argument(TypeError). For nullable values,format($x ?? 0, [...])/currency($e->budget ?? 0, ...)and guard.Text::truncate()/BcText::truncate()also requires a string as the first argument. Nullable columns aretruncate((string)$e->notes, 46)と(string)cast.- There is no custom numeric format registration (equivalent to addFormat). For 3-digit grouping without symbols, write
$this->Number->format($v, ['places' => 0])(the second argument ofcurrency()assumes an ICU ISO currency code, and unknown codes are output as-is as a prefix).
8. Routing, Shortcodes, Admin Screen CSS
config/routes.phpis a RouteBuilder closurewrite with:
The controller name is CamelCase.<?php use Cake\Routing\RouteBuilder; return function (RouteBuilder $routes) { $routes->connect('/sample/*', ['plugin' => 'Sample', 'controller' => 'SampleArticles', 'action' => 'view']); };Router::getRequest()When using in routes.php, null when run from CLI , soif (!$request) return;will stop all commands).bin/cake(without it,- Shortcodes are in setting.php's
'BcShortCode'register with thekey (see the code example in §1). If the registering plugin is disabled, it is treated as unregistered, and in content[Sample.showList]がexposed as raw text without error(plugin activation is a prerequisite). - Wide table (many columns) admin screenIn this case, the admin theme's
.bca-container(flex) child element.bca-mainにmin-width:0is missing, so the entire page scrolls horizontally. Add.bca-main { min-width: 0; }to that screen's CSS, and wrap the table inoverflow:autoa scroll wrapper (position:stickycolumn fixing also works with this). If the CSS is in a bundle, rebuild with webpack (§6).
9. Tests
For test execution procedures, introduction of a standalone plugin test framework, and how to write Fixture Factory, refer to the basercms-unittest skill as the authoritative source. To mention only the key points during development: for admin screens, fix GET rendering 200 + POST flow (assert DB changes) with controller integration tests that include login; rendering 200 swallows warnings, so after the test check error.log the pristine state; for screens with JS integration, place an assert that 'the DOM id/name expected by JS actually exists in the HTML' (the guardian pattern in §4); for date saving, assert down to the value (do not overlook silently NULL).