SuperAPI AI Directory

basercms5-plugin-development

Coding & development

View repository

Updated 10-16 09:51

About this skill

A collection of canonical patterns for baserCMS 5 series plugin development

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 class
    • src/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 as templates/Admin/element/search/)
    • webroot/(js/css/img. Vue source is webroot/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 arguments edit/{blogContentId}/{id} as mandatory(Admin\BlogPostsController::beforeFilter() が $this->request->getParam('pass.0') をBlog content ID, and without them it becomes BcException: コンテンツデータが見つかりません。 ). It cannot be accessed with edit/{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 theplugins table (status=1). Plugins activated in the DB are loaded automatically, so registration in composer or composer dump-autoload is not required.
  • Introduce third-party libraries (e.g. phpoffice/phpspreadsheet)は composer require ). Place template assets (Excel templates, etc.) in templates/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.className must be written with the plugin prefix and in plural form. The alias affects contain(), 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/hasOne is the only valid option.
  • Validation uses the methods of validationDefault() . Register custom rules with the signature ($value, $context) with add() .
    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)$values for multiple values (otherwise Cannot convert value Array exception).An empty array IN () causes an exception, so if it can be empty, guard at the beginning and return early.
    • For null matching, use the IS operator: ['field IS' => null]/for negation use ['field IS NOT' => null]。
    • When filtering by columns in related tables, use contain() instead of ->innerJoinWith('Assocs') か matching() (contain cannot be used in WHERE). Condition keys use the plural alias (SampleComments.user_id). Note that always joining excludes rows with no relation.
  • 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, add contain(['SampleTags'])。
  • name/value KVS tableは initialize() に $this->addBehavior('BaserCore.BcKeyValue') and saveKeyValue([$key => $value]) becomes available. For reading, use find()->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.
  • When saving other tables in afterSave (saving each other mutually), place a reentry guard flag。save() に callbacks options 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) (related dependent => true cascades).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') (callingfetchTable results in BadMethodCallException). 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') like Plugin.複数形モデル.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\AppTable which extends SampleAppTable , 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, use contain(), and for manual joins you can also use leftJoinWith()/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 returns false , so ?-> does not work.(BcUtil::loginUser() ?: null)?->id Write it with or an explicit branch).
  • Flash messages are $this->BcMessage->setSuccess('...') / setError('...')。
  • redirect() must always be written as return $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. : void Do 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() and setRequest() do.
    $this->setRequest($this->getRequest()->withQueryParams(
        $this->getRequest()->getQueryParams() + ['begin' => $begin]
    ));
    
    For reading, use 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 the initialize() で $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)(RecordNotFoundException try/catch -> "Invalid operation" -> to index) -> $table->delete($entity). Actions that receive POSTs via the list's bca-submit-token should be registered in FormProtection's unlockedActions (§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 controller beforeRender() set them all at once do.
    public function beforeRender(\Cake\Event\EventInterface $event): void
    {
        parent::beforeRender($event);
        $this->set(\Cake\Core\Configure::read('Sample'));
    }
    
    Each Admin controller BcAdminAppController Inherit 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 with control() there is no(input() ). For front-end $this->BcForm/$this->Form or 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() も BcAdminForm are 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):
    <?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 ?>
    
    The condition on the receiving side is 'field IN' => (array)$values(§2)。
  • control() の label When 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(singular search/) The controller's setSearch('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 from getQueryParams() . To redisplay input values, include valueSources に 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::slug by).
    • 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's for (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).
  • 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 with You 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 (PaginatorHelper PaginatedInterface is 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()(BaserCorePlugin is called for all enabled plugins), butsrc/Event/{Plugin名}{Controller|Model|View|Helper|Mailer}EventListener.php calledonly 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 be src/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 from BcControllerEventListener, view-related from BcViewEventListener, and helper-related from BcHelperEventListener .
  • 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, so grep -rn "dispatchLayerEvent\|createEvent" vendor/baserproject/... the firing side and match it to an existing event name.
    • For View-related ones, BcViewEventDispatcher re-dispatches standard events as {Plugin}.{ViewName}.afterRender .'BcBlog.Blog.afterRender' like the View's getPlugin()/getName() to match.
  • 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() は MissingTableClassException in the constructor. Obtain it lazilylazy retrievaldo.
  • Model.afterFind There is no event called. Processing of find results should be written in formatResults() or a custom finder.
  • When reading view variables in View-related handlers, use $view->get('varName') ($view->viewVars there is no property).$view->request There is also no property (referencing an unknown property is treated asautomatic loading of a helperand xxxHelper could not be found involves 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 computed adminBaseUrl() { return $.bcUtil.adminBaseUrl; } expose :href="adminBaseUrl + 'sample/...'" and write it as
  • Vue bundle is source (webroot/js/src) and rebuild with webpack(do not manually edit the compiled .bundle.js/.bundle.css ).webpack.config.js if there is cd plugins/Sample && npm install && npx webpack. The output is webroot/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 in beforeFilter() で $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's getData('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-for iterates character by character (ifExpected Array, got String appears 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 PHP date() 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->format is 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') .
  • 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 are truncate((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.php is a RouteBuilder closurewrite with:
    <?php
    use Cake\Routing\RouteBuilder;
    return function (RouteBuilder $routes) {
        $routes->connect('/sample/*', ['plugin' => 'Sample', 'controller' => 'SampleArticles', 'action' => 'view']);
    };
    
    The controller name is CamelCase.Router::getRequest() When using in routes.php, null when run from CLI , so if (!$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:0 is missing, so the entire page scrolls horizontally. Add .bca-main { min-width: 0; } to that screen's CSS, and wrap the table in overflow:auto a scroll wrapper (position:sticky column 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).