SuperAPI AI Directory

basercms5-custom-content-development

Coding & development

View repository

Updated 10-15 17:25

About this skill

Canonical pattern collection for baserCMS 5 bc-custom-content development

A collection of coding patterns for creating and modifying new content types using the baserCMS 5 standard plugin bc-custom-content(Custom Content).

For common rules such as environment and naming conventions, see basercms5-development, and for plugin development in general, see basercms5-plugin-development . This skill specializes in bc-custom-content'sdevelopment knowledge itself(procedures specific to migration from a particular version 4 plugin are outside the scope of this skill. When performing a migration, prepare a separate migration-specific skill that references this skill as prerequisite knowledge).


0. Major premise: bc-custom-content is a mechanism for "content types under an independent content tree," and cannot be used to add fields onto existing models after the fact

custom_tables/custom_contents/custom_fields/custom_links/custom_entries It has a completely independent data model. Actual data is stored in the dynamically generated table custom_entry_<table_id>_<name> .blog_posts There is no code that integrates with existing tables such as(this can be confirmed with grep).

If the requirement is "I want to add fields to existing blog posts after the fact," it cannot be achieved with bc-custom-content. Either rebuild the target as a bc-custom-content content type independent of blog posts, or use the core's BcModelEventDispatcher mechanism that automatically fires CakePHP standard events for all Tables in the Model.<ModelName>.<eventName> format Model.BlogPosts.beforeSave/afterSave etc. (in the latter case, refer not to this skill but to Chapter 8 "Events" of basercms5-plugin-development).


1. Data model and Service API

Table Role
custom_tables Definition of the content type (type=1 is the content-tree-linked type). At creation time, the dynamic table custom_entry_<id>_<name> is automatically generated
custom_contents custom_table to content tree (contents).site_id は custom_contents Held on not Contents itself but on the relatedBcContentsBehavior )
custom_fields Field definition (name/title/type/source, etc.).Reusable and does not belong to a custom_table
custom_links custom_table × custom_field Intermediate table. Has display order (Tree behavior,no)・display_front(described later)
custom_entries Template only. Actual data is stored in the dynamic table custom_entry_<id>_<name> .

Service は BaserCore\Utility\BcContainerTrait use $this->getService(XxxServiceInterface::class) and obtain from the DI container with newEntity+saveOrFail the Table. This is because each Service encapsulates side effects such as dynamic table creation and column addition within a transaction).

// custom_table 作成(type=1 でコンテンツツリー紐付け型、display_field は一覧表示用フィールド名)
$customTable = $customTablesService->create([
    'name' => 'works', 'title' => '制作実績', 'type' => '1', 'display_field' => 'title',
]);
// フィールド定義作成(再利用可能)
$customField = $customFieldsService->create(['name' => 'work_url', 'title' => 'サイトURL', 'type' => 'BcCcText']);
// table と field を紐付け(表示順 no、display_front は後述)
$customLinksService->create([
    'custom_table_id' => $customTable->id, 'custom_field_id' => $customField->id,
    'name' => 'work_url', 'title' => 'サイトURL', 'type' => 'BcCcText', 'no' => 1,
    'display_front' => true,
]);
// コンテンツツリーへの紐付け(content キーで Contents を同時保存、site_id は content 側)
$customContentsService->create([
    'custom_table_id' => $customTable->id, 'template' => 'works', 'list_count' => 10,
    'content' => ['title' => '制作実績', 'site_id' => 1, 'parent_id' => $parentContentId],
]);

2. Field names must be ^[a-z0-9_]+$ only (camelCase not allowed)

CustomFieldsTable/CustomLinksTable の validationDefault() が name impose regex('name', '/^[a-z0-9_]+$/') on columns. Field names must always be designed in snake_case (uppercase and camelCase will cause an exception at save time).

3. custom_fields.name is globally unique across tables (across custom_table)

CustomFieldsTable の name For the column, validateUnique is assigned, andmust be unique system-wide, not per custom_tableIf you want to share a field with the same name across multiple content types (e.g. contentfor body text), simply doing create() every time will cause the second and subsequent entries to name.validateUnique results in an error.

Countermeasure: Before creating a field, search for an existing CustomField with the same name, and if found, reuse it without creating a new one (onlyCustomLinks is newly created). However, always verify that the type matchesand if it does not match, throw an exception and stop (to prevent data from being silently saved with mismatched types):

$customField = $customFieldsTable->find()->where(['name' => $field['name']])->first();
if (!$customField) {
    $customField = $customFieldsService->create([...]);
} elseif ($customField->type !== $field['type']) {
    throw new \RuntimeException(
        "カスタムフィールド「{$field['name']}」のtypeが既存のものと一致しません。"
        . "既存: {$customField->type} / 新規: {$field['type']}"
    );
}

4. custom_links.display_front explicitly as true to be

CustomContentHelper::getFieldValue() は if (empty($customLink->display_front)) return ''; passes through, sodisplay_front if unset (NULL/false), the front end will always return an empty string even if the value is actually in the DB。CustomLinksService::create() When calling, always pass 'display_front' => true . If there is missing configuration in existing data, it can be fixed all at once with the following SQL:

UPDATE custom_links SET display_front=1 WHERE display_front IS NULL OR display_front=0;

5. If you want to operate with ID-based URLs, custom_entries.name explicitly set to an empty string (if it remains NULL, the detail page will 404)

CustomEntriesService::get($id, $options) は、$id is numeric and $options['status'] === 'publish'(when the front end displays the published state), thenCustomEntries.name = '' をadd as a required conditionto do:

if (is_numeric($id)) {
    $conditions['CustomEntries.id'] = $id;
    if ($options['status'] === 'publish') {
        $conditions['CustomEntries.name'] = '';
    }
} else {
    $conditions['CustomEntries.name'] = rawurldecode($id); // 非数値ならスラッグ扱い
}

In SQL, NULL = '' is always false, sowhen creating an entry, name unless explicitly saved as an empty string '' , the detail page in published state will always 404 on numeric ID access(This is hard to notice because the list and admin screens look normal). If you want to operate with ID-based detail URLs, always include 'name' => '' in the data when saving an entry. Conversely, if you want to use slugs, putting the actual slug string in name will work as is (custom_entries.name is the slug mechanism itself).

6. Escaping and newline conversion by field type are completed on the helper side (do not process twice in the template)

CustomContentHelper::getFieldValue($entry, $fieldName) is, for the field type's BcCc*Helper::get() is delegated to.For many types, the returned string is already escaped and newline-converted, so if the template further applies h()/nl2br() , it becomes double processing and&lt;br /&gt; causes a bug where strings like this appear on screen.

field type get() behavior handling in templates
BcCcText h($value) echo as-is (h() not needed)
BcCcTextarea nl2br(h($value)) echo as-is (h()/nl2br() both not needed)
BcCcPref(prefecture code) h($value)(as code) BaserCore.BcText::pref() convert to name → echo the converted value as is (pref() against the return value of h() do not)
BcCcRadio raw value without escaping On the template side, h() is required (the only exception)
BcCcSelect including resolution from index value to label basically echo as is

Before implementing the template, always check the actual helper for the target field type (vendor/baserproject/bc-custom-content/plugins/BcCc*/src/View/Helper/*.php)の get() Read the implementation and check whether it is escaped. Since behavior differs by type, avoid blanket h()/nl2br() wrapping.

7. Values for Select/Radio/Multiple-type fields are stored as indexes (not label strings) + source must always be written in the ラベル:インデックス format

BcCcSelect/BcCcRadio/BcCcMultiple values are, at form submission and DB save time, the **index number (0-based)** within the option list, not the label string itself (BcCcMultiple stores a JSON-encoded array of selected indexes). At display time, custom_fields.source(newline-separated option text) is turned into an array, looked up by index, and resolved to a label (CustomContentArrayTrait::textToArray() → arrayValue())。

★Trap (a bug actually encountered): source is written as a simple newline-separated list (e.g., 新卒採用\n中途採用\nアルバイト), index-based resolution always fails, and the front-end display silently becomes an empty string. textToArray() is, when each line has no : separator,make the array key the line's value itself (the label string itself)($keyValueArray[$value] = $value;). On the other hand, because the form-save/backfill side assumes numeric indexes (0,1,2...) are saved,arrayValue(2, $sourceArray) が $sourceArray[2] cannot be found (because the key is the label string), and the default empty string $noValue keeps being returned.No PHP warning or error appears, and in the admin preview it looks correct as an option, so the blank is first noticed on the front end, making this a hard-to-notice bug.

Countermeasure: If you design values to be saved as numeric indexes,source must always be written in the ラベル:インデックス format (e.g., 新卒採用:0\n中途採用:1\nアルバイト:2).BcCcRadio の keitai(正社員:FULL_TIME etc., string keys) has been supported from the start, butdo not misunderstand that a simple newline-separated list is enough. When writing a field-creation command, always specify asource key separated by : in

7-0. Fixed options such as categories and genres may be more correct by design as BcCcSelect than BcCcRelated(a reference to a separate custom_table)

When implementing fixed options like "site genre" or "category" with a filter navigation (a feature that, withCustomContentHelper::getFieldItemList(), displays links only for options that do not have zero matches), considerBcCcSelect(§7 の source static list) but BcCcRelated(a foreign key reference to an entry in a separate custom_table)Reason:

  • BcCcRelated は getFieldItemList()/get()/control() のhas type-specific implementations from the start(§7 の BcCcSelect/BcCcRadio/BcCcMultiple does not have this, andsource falls back to using the text as-is as the filter value, so once you use the "label:index" format from §7, it no longer fits with this fallback, andthe navigation feature itself is always empty)。
  • Because the choices are held as "entries in a separate custom_table (type=2 = a relation-dedicated table that does not require a front-end public URL)", the value is uniquely determined by the actual entry id,source does not depend on text parsing.

Key points of the conversion procedure(when changing an existing field from select to related):

  1. Create a new custom_table for the choices with CustomTablesService::create(['name'=>'xxx_genres','title'=>'...','type'=>2,'display_field'=>'title']) (type=2 does not require front content, and name/title are built-in columns, so no additional custom_links are needed for the choices).
  2. CustomEntriesService::setup($newTableId) → register each choice with create(['custom_table_id'=>$newTableId,'name'=>...,'title'=>ラベル,'status'=>1,'creator_id'=>1,'published'=>DateTime::now()]) , andkeep a map from old index → new entry iddo.
  3. Convert existing data (e.g., the value of custom_entry_1_xxx.field_name is the old index) to the new id: make it a single-pass UPDATE using a CASE expression(UPDATE ... SET field = CASE field WHEN 0 THEN <id0> WHEN 1 THEN <id1> ... END). If you repeatedly run UPDATE WHERE field=0 sequentially, the new ids (starting from 1) collide in number with the old indexes (starting from 0), causing an accident where subsequent conversions mistakenly re-convert rows that have already been converted.
  4. custom_fields Update the type を BcCcRelated に、meta を ['BcCcRelated'=>['custom_table_id'=>$newTableId,'filter_name'=>null,'filter_value'=>null,'display_type'=>'']] .

7-0-1. [Important]BcCcRelatedHelper::getFieldItemList() itself has a vendor bug (it does not filter by field name)

Even after handling 7-0,CustomContentHelper::getFieldItemList() there is another bug in BcCcRelated\View\Helper\BcCcRelatedHelper::getFieldItemList() which is called via. The internal condition closure of CustomFields->find()->contain([...])->first() は、contain() filters onlyrelated records (on the CustomLinks side),CustomFields itselftherefore,->first() always returns "the CustomFieldwith the smallest id" (mistakenly referencing a different field unrelated to the target field). As a result, the custom_links of that mistaken field becomes an empty array,$customField->custom_links[0] で Undefined array key 0 causing the warning and an empty nav.

vendor(vendor/baserproject/bc-custom-content/)は .gitignore Since the patch is lost at composer install/update for the target, do not fix it directly. Instead, on the template side that needs the nav (templates/CustomContent/<template>/index.php etc.), do not call getFieldItemList() and implement it yourself as follows:

$customFieldsTable = \Cake\ORM\TableRegistry::getTableLocator()->get('BcCustomContent.CustomFields');
$targetField = $customFieldsTable->find()->where(['name' => 'field_name'])->first(); // name で正しく絞り込む
$relatedTableId = $targetField->meta['BcCcRelated']['custom_table_id'] ?? null;
if ($relatedTableId) {
    $entriesTable = \Cake\ORM\TableRegistry::getTableLocator()->get('BcCustomContent.CustomEntries');
    $entriesTable->setup($relatedTableId);
    $choices = $entriesTable->find()->where(['status' => 1])->orderBy(['id' => 'ASC'])->all();

    $selfEntriesTable = \Cake\ORM\TableRegistry::getTableLocator()->get('BcCustomContent.CustomEntries');
    $selfEntriesTable->setup($customContent->custom_table_id);
    foreach ($choices as $choice) {
        $count = $selfEntriesTable->find()->where(['status' => 1, 'field_name' => $choice->id])->count();
        if ($count > 0) {
            $genreLinks[] = $this->BcBaser->getLink($choice->title, '/' . $customContent->content->name . '/archives/field_name/' . $choice->id);
        }
    }
}

Possibility of recurrence: this bug is BcCcRelated (a candidate for upstream reporting; currently unreported).

7-0-2. Console (bin/cake command)

When writing a one-off console command for data migration or correction that does not go through admin panel form submission, the following two points trip up code that assumes HTTP requests:

  • CustomFieldsService::update()(internally saveOrFail) throws an exception in the console: custom_fields Some validation rules ofBcValidation::checkWithJson())が Router::getRequest()->getData('validate') , but when run in the console it returns Router::getRequest() が null , so it becomes Fatal at Call to a member function getData() on null . Workaround: do not use CustomFieldsService::update() , and save directly by disabling validation itself, as in$fieldsService->CustomFields->patchEntity($field, $data, ['validate' => false]); $fieldsService->CustomFields->saveOrFail($field, ['checkRules' => false]); .
  • CustomEntriesService::getNew() cannot be used in the console: internally BcUtil::loginUser()->id is called, so it fails in a console context where no logged-in user exists.getNew() without going through,CustomEntriesService::create($postData)(getNew() do not call) to creator_id・published(\Cake\I18n\DateTime::now()) explicitly and call it directly.
  • If the command itself is disposable, bin/cake bake command <Name> generate with,execute() write the processing in and bin/cake <name> execute with,when done, delete the command file and the auto-generated test file togetherThis operation is fine (for one-off migration processing that does not need to remain as a permanent command in the project).

7-1. Enable archive filtering (archives/{field}/{value}) to enable search_target_front explicitly true set to true

CustomEntriesService::createIndexConditions() は、custom_table The filter conditions using each custom field of (archives() in the action, [$field => $value] passed as),custom_links.search_target_front(front) /search_target_admin(admin panel) only include in the WHERE condition those that are true (if (!$link->search_target_front) continue;)。If this value remains unset (NULL), even if a filter value is passed in the URL it is always ignored, and no matter which value is specified, the same result (the same as before filtering) keeps being returned. It does not cause an error and appears to work at a glance, making it easy to miss.CustomLinksService::create() When calling, always explicitly pass 'search_target_front' => true(and if also using it for admin panel search, 'search_target_admin' => true too) for fields you want to use for filtering.

8. Front template view variables and the correct API (do not take the pseudocode in the design document at face value)

bc-custom-content The URL of is not a fixed prefix but follows Contents.url(content tree placement). The template search path is templates/CustomContent/<custom_contents.template の値>/{index,view,archives,year}.php。

CustomContentController/CustomContentFrontService(vendor/baserproject/bc-custom-content/src/Controller/CustomContentController.php・src/Service/Front/CustomContentFrontService.php) actually passes:

  • index.php/archives.php/year.php: $customContent(CustomContent entity)・$customEntries(PaginatedInterface). Only archives/year $archivesName(filter value).
  • view.php: $customContent・$customEntry(singular).

Generate the detail URL using CustomContentHelper::getEntryUrl($entry) (useUrl->build(['controller'=>'CustomContent',...]) not). Pagination requires $this->BcBaser->pagination('simple')(element/paginations/simple.php (should already exist in the theme).Pseudocode in design documents such as "write like this" may be simplified, so before implementation read the actual Controller/Service source and confirm the correct variable names and methods.。

9. CakePHP5 middleware requires an implementation of MiddlewareInterface::process() .

When adding custom middleware,__invoke(Request, Handler): Response With the closure-style writing of the form, CakePHP5's MiddlewareQueue::add() does not satisfy the type required by (MiddlewareInterface|Closure|array|string) and becomes TypeError .Psr\Http\Server\MiddlewareInterface を implements し、process(Request $request, RequestHandler $handler): ResponseInterface Implement as a method.

class XxxMiddleware implements \Psr\Http\Server\MiddlewareInterface
{
    public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
    {
        // ... 独自処理、最後は $handler->handle($request) にフォールバックさせる
    }
}

Registration is done within the src/Application.php の middleware() method (be careful about the order with other middleware. For things you want to process before routing resolution such as URL redirects, add before RoutingMiddleware ).

10. List of standard built-in field types (understanding the scope that requires no additional development)

vendor/baserproject/bc-custom-content/plugins/ Standard built-in under: BcCcText/BcCcTextarea/BcCcWysiwyg/BcCcSelect/BcCcRadio/BcCcCheckbox/BcCcDate/BcCcDateTime/BcCcTel/BcCcEmail/BcCcPassword/BcCcHidden/BcCcFile/BcCcRelated/BcCcMultiple/BcCcAutoZip/BcCcPref. If the required type does not exist, use the existing BcCc* plugin's structure (config.php・config/setting.php・src/View/Helper/BcCcXxxHelper.php・templates/Admin/element/preview.php) as a model to develop a custom field type plugin. There are also cases of additionally introducing externally provided field type extension plugins (rich text editor types, etc.), and most are minimal configurations without Migrations, and like other BcCc* similarly plugins can often be enabled by direct INSERT into the table.