SuperAPI AI Directory

basercms-plugin-4-to-5-upgrade

Frontend & websites

View repository

Updated 10-15 18:51

About this skill

A collection of 4 → 5 migration patterns for baserCMS plugin internal code

BcAddonMigrator converted the plugin template to the 5.x seriesAfterThis is a symptom-based summary of the manual code rewrite patterns needed afterward. The site-wide migration procedure (installing baserCMS 5,BcDbMigrator data migration, theme conversion, plugin conversion procedure = ZIP creation →bc_addon_migrator→ placement, Git/repository operation) is described in the basercms4-to-5-upgrade skill. This skill is called from there and provides concrete measures for getting a plugin's Controller / Table / Entity / View / Helper / forms / Vue・JS running by going through them one screen at a time.

What this skill covers isDetection and conversion of 4.x idiomsis.The authoritative source for correct 5.x writing is basercms5-plugin-development(for themes, basercms5-theme-development); if you are unsure about the target specifications (ORM, controllers, forms, events, Vue integration, dates/numbers, routing), refer to that. Read each of the following sections in the form "detection of 4.x idioms (symptoms, grep patterns) → key points of conversion → the relevant section of the authoritative skill."

If you are migrating a 4.x plugin that retrofits custom fields onto blog posts, etc., to the 5.x standard bc-custom-content, refer not to this skill but to basercms5-custom-content-development(bc-custom-content cannot be used to add retrofit fields to existing blog_posts, and since the design is to rebuild it as an independent content type, a dedicated pattern collection separate from ordinary plugin internal code conversion is needed).

Recommended: Before starting migration, refer once to basercms5-claude-workflow-setup(environment setup) and prepare the environment for how to proceed (design = superpowers brainstorming / permission organization = permissions-audit / Auto mode on top of that / Markdown preview of spec and plan). This is a suggestion; if it is already in place, skip it. The "How to proceed with migration" below is run on top of that environment.

How to proceed with migration (most important, the order to do first)

For plugin 4→5 migration,before fixing screens one by one in an ad hoc way, first clean up the whole thing across the boardis faster and safer. A proven recommended order:

★★[Mandatory gate] Once you start converting a plugin to the 5.x series, before touching even one line of code, first create the "file status ledger" from step 1. While there is no ledger, do not start either the static audit (2) or the syntax conversion (3). This is easy to skip (you will want to jump straight into the audit or Table conversion), but without a ledger you cannot get an overview of "which files are not yet started / deferred," and gaps in progress, duplicate work, and missed deferred items occur. Create the ledger file → preview (markdown-to-html) → then proceed to 2 onward.

  1. Turn all files into a status ledger: manage src/templates/js/migration as one file per line with 未着手/移行中/移行済/見送り/対象外 (with type × status summary, a living document). You can get an overview of what remains. After creating it, update it whenever the status changes (e.g., docs/migration/<plugin>-file-ledger.md)。
  2. ★Cross-cutting code check (static audit) first: cross-check all 5-series code against the 4-series source of truth at the action/method level, and document移行済 / deferred / 4系残骸 / 未実装 + bugs (severity) + browser verification points as an audit document.Before writing testsBy doing it first, you can map out leftovers/immediate Fatals/places requiring design decisions, and rework drops dramatically. It is faster to divide the work by type/domain using parallel subagents (see the cross-cutting audit notes inbasercms-unittest ).
  3. ★Next, cross-cutting "syntax-only 5-series conversion": bulk-convert the 4-series leftovers found in the audit to 5-series syntax ($this->Model->→fetchTable、find('first',配列)→builder、getDataSource→getConnection、Event の bindModel/$event->data , etc. Apply the catalog in C-0/C-A/§8 below).Completion conditions are "php -l all clean + zero grep hits for 4-series API leftovers (excluding deferred TODOs) + zero regressions in the existing full suite". At this stage, do not write new tests. Eliminate Fatals and build the foundation for "being valid as 5-series syntax". For external dependencies (Slack/email/CSV/Excel/aggregation), do not move their contents; explicitly mark them as deferred with // TODO baserCMS5移行: か NotImplementedException .
  4. Verify meaning with tests & browser: things that syntax conversion alone cannot guarantee: entity↔array, date marshal, afterSave chain, FormProtection, view variable shapes, JS integration (C-F2) ; nail these down with rendering integration tests + browser checks, then raise to 移行済 .php -l only looks at syntax and does not catch rendering/JS breakage, so this stage is mandatory.

Why do 2→3 first: syntax leftovers like "the screen opens, but pressing delete/ajax/CSV gives a 500" do not become apparentuntil they are called. If you first sweep everything and convert the syntax to 5-series, subsequent tests & browser work can focus on "verifying meaning on a foundation that should work", and Fatals and logic errors will not get mixed together.

★★[Operational principle] If, during cross-cutting work, you decide not to force an implementation because "the corresponding event/API does not exist in 5-series" or "redesign is required", then on the spot, not only leave // TODO baserCMS5移行: <理由> in the code, but also be sure to append/update the relevant row in the "file status ledger" from 1(set the status to "deferred" and add a brief note on the reason and the key points of alternatives). If you only put a TODO in the code, later readers of the ledger will not understand; conversely, if you only put it in the ledger, it will not reach people reading the code——only when it is written in both does the practice of "do not force an implementation; record it" work. If you are unsure, the correct move is to stop implementing and switch to this record (safer than forcing it to work and breaking it). Example: in the PopularBlogPost plugin, Model.afterFind(ranking injection into the list), which does not exist in CakePHP5, and the abolished bindModelWhen (dynamic binding of settings) was made deferred, the reason (no corresponding event, alternative means) was recorded in both the code TODO comment and docs/migration/popular-blog-post-file-ledger.md .

Principle of cross-cutting handling (scattered instances of the same cause handled in bulk)

Among the bugs found while fixing one screen, those whose same cause is scattered across the entire plugin are handled on the spotacross the boardin bulk (do not repeat fixing only one place and then hitting the same error on the next screen). Procedure: ① Once fixed, find all instances of the same pattern with grep -rn (e.g. $this->Form->input(、'multiple' => 'checkbox', singular get('Sample.Sample...')、searches/、Time->format($x) without the second argument, etc.) → ② Mechanically unique conversions are applied in bulk with perl -pi → ③ Verify all changed files with php -l → ④ Handle only non-trivial parts individually. Representative examples that can be handled in bulk across the board are cataloged in C-0 (append as found). When a new cross-cutting pattern is found, add it to C-0 before executing in bulk.

Concrete code conversion rules

1. File and folder operations

  • Detect: 4.x File/Folder classes, and Cake\Filesystem\File/Folder(none usable in 5.x).
  • Convert: baserCMS-specific processing to BaserCore\Utility\BcFile/BcFolder, general file operations to PHP standard (SplFileInfo/FilesystemIterator , etc.). For common rules, refer to basercms5-development.

2. Request handling (Controller, View)

Points of 4.x idiom → conversion (for correct 5.x request operations, redirect, exceptions, and Component usage, refer to basercms5-plugin-development §3 "Controllers"):

  • $this->request->data/query/params・Array access (query['key']/data['key'])→ getData('key')/getQuery('key')/getParam('key')/getQueryParams()。
  • $this->setMessage('msg')/setMessage('msg', true) → $this->BcMessage->setSuccess('msg')/setError('msg')。
  • $components の 'BcAuth'/'Cookie'/'BcAuthConfigure' is deleted (if it becomes empty, delete the definition entirely).
  • $name/$uses/$subMenuElements/$crumbs property is deleted.array() → []。
  • $this->layout = null/false・$this->autoLayout = false → $this->viewBuilder()->disableAutoLayout()。
  • URL construction by string concatenation (Router::fullBaseUrl() . '/admin/...') → assignment to the array-form \Cake\Routing\Router::url([...], true)。
  • $this->response->statusCode(400) → $this->setResponse($this->getResponse()->withStatus(400))。
  • getter (create a new request withgetData('key') = 'val'/getQuery('key') = 'val')→ request は immutable。withData()/withQueryParams() and setRequest()(§3)。
  • $this->ajaxError(500, 'msg') → throw new \Cake\Http\Exception\InternalErrorException('msg') etc., into an appropriate exception.
  • ClassRegistry::init()・$this->ModelName format model call → fetchTable('PluginName.ModelNames')(pluralrequired. The singular form is not loaded correctly). Inside Table/Lib, T-H.
  • App::uses() all deleted,App::import() は use to statement.App::path('View', 'P') → \Cake\Core\Plugin::templatePath('P')。
  • Method return value isset() is not allowed in PHP 8 → !== null to comparison. Classes that use dynamic properties #[\AllowDynamicProperties](PHP 8.2)。
  • clearAllCache() → BcUtil::clearAllCache()、convertSize() → BcUtil::convertSize()、getEnablePlugins() → BcUtil::getEnablePlugins()。findExpanded() abolished → BcKeyValue behaviors +getKeyValue()。$useTable abolished → initialize() の setTable()。
  • ConnectionManager::getDataSource('default') → ConnectionManager::get('default')。
  • References to core classes within a namespace are given \ or use statement (catch (Exception $e) → catch (\Exception $e))。
  • $this->request->url(no leading slash) → getPath()(with leading slash. For string comparison locations, account for the slash difference).$this->request->base → getAttribute('base')。$this->viewPath → getTemplatePath()。
  • URL array generation: 'plugin' => null → 'BaserCore'(core plugins are Bc prefix: blog→BcBlog、mail→BcMail)、'admin' => true → 'prefix' => 'Admin'(false is deleted along with its key), controller is CamelCase (special case: search_indices → SearchIndexes)。
  • is('ssl') → is('https')。$this->search = 'id' → setSearch('id')。$this->help = 'id' → setHelp('id')。
  • $this->_checkSubmitToken() is abolished → deleted (replaced on the CSRF side).
  • Named parameters (/action/name:value・$this->passedArgs['key']) abolished → query parameters (getQuery('key'). URL generation also to query strings).
  • Password compatibility: set to 4.x app/Config/install.php の Security.salt を .env の SECURITY_SALT . Theme application: 4.x site_configs の theme to 5-series sites.theme (manually if automation is difficult).

3. Date and time

  • FrozenTime → Cake\I18n\DateTime is recommended. For the proper use of ICU patterns and PHP date() formats and null safety, refer to basercms5-plugin-development §7 "Date, numbers, and strings".

★[Most important] datePicker dates are not saved (datetime column becomes NULL)

  • Detection: Date-related columnsdisappear (NULL) even after saving. The cause is a combination of two things: ① 4-series BcFormHelper::datePicker はslash format(e.g. 2026/06/28), ② 5-series DateTimeType::marshal は Y-m-d H:i:s is required, and both the slash format and the hyphenated "date only" result in NULL. Conversely, pure date columns (DateType) makes "with time" NULL——the requirements are exactly opposite depending on the column type, so always check whether the migration destination is date or datetime using the migration's SHOW CREATE TABLE .
  • Key conversion points: Normalize on the saving side (before patchEntity). For datetime, use '/'→'-'+ if date only, 00:00:00 completion + empty becomes null; for date, use '/'→'-'+ empty becomes null (do not attach time). Centralize this in a plugin-common Util, andif you notice it on one screen, roll out the same kind of datePicker saving to all forms. Also align the edit screen display (GET) with the add screen default values and unify on slashes ($v->format('Y/m/d')= PHP format) (safe because normalization on the saving side accepts both formats).
  • Verification: Fix it with an integration test of "POST a slash date → assertRedirect → re-fetch and assert up to the date value" (if you do not assert up to the date value, you will miss a silently NULL. See basercms-unittest).
  • ★Another trap: func()->min()/max() When used on a datetime column, the result becomes a numeric type (2026.0 a float like that), and assignment to the datetime column becomes NULL through marshalling. Do not use an aggregate function; get the oldest/newest row with orderBy()->first() ——see basercms5-plugin-development §2 "ORM / Table / Entity" for the correct way to write it.

4. Database/Model (Table, Entity)

  • 4-series Model は Table(validation logic) and Entity(row behavior).
  • find('all', ['conditions' => ...])(array format) → query builder,$this->save($data)(array) → newEntity()/patchEntity() → save($entity)。
  • ClassRegistry::isKeySet()/getObject() → TableRegistry::getTableLocator()->get()(also serves as instantiation, so no existence check is needed).
  • For the correct 5-series way to write query builder, save, and associations, refer to basercms5-plugin-development §2 "ORM / Table / Entity".

5. View/Helper (View)

  • $this->Helpers->loaded('name') → $this->helpers()->has('name')。CakePlugin::loaded('name') → \Cake\Core\Plugin::isLoaded('name')。
  • In-plugin elements are specified in the PluginName.element_name format.BcException は use BaserCore\Error\BcException; or with a fully qualified name.
  • $this->BcBaser->siteConfig['name'] Direct property references such as that should be changed to helper methods/static methods (for the same kind of pattern on the theme side, basercms-theme-4-to-5-upgrade TH-1).
  • $helpers Invalid entries mixed into the array (4-series remnants such as method names. Example: 'afterRender') should be deleted——in 5-series this causes a MissingHelperException during helper resolution (there are actual cases).
  • Templates that depend on reading a helper's public property directly ($this-><Helper>->settings[...] etc.) were sometimes filled in by the "side effect of the getter being called first" in the 4.x series— In the 5.x migration, initialize in the helper's constructor so that even direct reads are not empty. Request-dependent settings (per-site settings, etc.) cannot be determined at the site level in bootstrap, so read them on the helper side from the currentSite attribute.

6. Plugins and Namespaces

  • Assign an appropriate namespace to all classes (e.g., BcBlog\Controller)。
  • External plugins are auto-loaded if enabled in the DB (plugins table) (no composer registration orcomposer dump-autoload required). For the 5.x conventions of plugin classes and setting.php/config.php, refer to basercms5-plugin-development §1 "How to Start New Development".

7. Tests

  • FixtureManager(array definitions) is abolished → rewrite to FixtureFactory . For execution procedures and infrastructure setup, refer to basercms-unittest.

8. Events (Event) — listener (src/Event/*EventListener.php) from 4 to 5 (verified)

Key points of detection and conversion (for the authoritative source on 5.x event base classes, naming conventions, and handler conventions, refer to basercms5-plugin-development §5 "Events"):

  • $event->data['key'] → getData('key')、$event->subject → getSubject()(Table is returned. The saved entity is obtained with getData('entity') —not 4.x's $model->data ). Writing is setData('key', $v)。
  • The handler name is the CamelCase of the event name (User.beforeFind → userBeforeFind(EventInterface $event)). The type hint is \Cake\Event\Event rather than EventInterface。
  • ★Event names qualified by other plugins remain as in 4.x will result in "no firing (no error either)"(verified). Model-related Blog.BlogPost.afterSave → BcBlog.BlogPosts.afterSave(5.x plugin name + Table plural), View-related Blog.Blog.afterRender → BcBlog.Blog.afterRender, email sending is Mail.Mail.beforeSendMail → BcMail.Mail.beforeSendEmail(the event name itself is also renamed. Sending options such as from are passed with $event->setData('sendEmailOptions', [...]) —confirm on the firing side MailController). To verify existence, check the firing side with grep -rn "dispatchLayerEvent\|createEvent" vendor/baserproject/...。Model.afterFind the trigger itself does not exist in 5.x—do not invent it; defer it (record in both TODO and the ledger).
  • bindModel()/unbindModel() is abolished in 5.x → add contain([...]) to the caller or declare it in initialize() . As a temporary measure, use return;(noop) + TODO to avoid only Fatal.
  • 4.x ORM remnants (find('first', 配列)・save(配列)・$model->data) → 5.x migration (§4 / T-F).
  • request は immutable: $view->request->data['x'] = ... etc. is invalid → to pass to the view use $controller->set('x', $v), the action name is $view->getRequest()->getParam('action')。
  • BcForm::input() → BcAdminForm::control(). 4.x action name admin_add/admin_edit → add/edit(prefixes are handled with prefix). The redirect array's 'admin' => true → 'prefix' => 'Admin'(common to controllers/listeners).
  • the listener is the plugin's BcPlugin Auto attach because it does, if broken it will immediately Fatal on event firing = fix it as the highest priority across the board.

Table / ORM Layer Migration Patterns (BcAddonMigrator does not convert these, so manual work is required)

BcAddonMigrator does move/rename files from Model to Table, butthe 4.x ORM notation inside the class is almost never convertedIn large plugins (e.g. Sample), entire tables with hundreds of rows remain on 4.x. Mechanically eliminate the following.First, fix the association declarations in the table classesis the prerequisite for everything (because controllers/helpers/templates depend on it).

T-A. Association declarations (most important / most frequent): public $belongsTo/$hasMany/... → initialize()

  • Detection: 4.x class property declarations (public $belongsTo/$hasMany/$hasAndBelongsToMany). In 5.x they arecompletely ignored(no error is raised either, the association simply does not exist = it fails withUndefined property / association ).
  • Key points of conversion: initialize() to method calls inside.Aliases are pluralto (contain()・This affects result array keys and chained access as well = see T-D).className is prefixed with the plugin prefix and plural (core User is 'BaserCore.Users')。HABTM → belongsToMany(associationForeignKey → targetForeignKey)。dependent is in 5.x hasMany/hasOne only is valid (belongsTo の dependent is dropped).
  • For the correct way to write declarations in 5.x (including code examples), see basercms5-plugin-development §2 "ORM / Table / Entity".

T-B. public $actsAs → $this->addBehavior()(inside initialize)

  • Detection: public $actsAs = ['BcUpload' => [...]] etc. Conversion: initialize() で $this->addBehavior('BaserCore.BcUpload', [...])。BcCache often does not exist in 5.x, so delete it. (See basercms5-plugin-development §2)

T-C. public $validate(array validation) → validationDefault(Validator $validator)

  • Detection: 4.x's public $validate = [...](invalid in 5.x). Conversion: validationDefault() へ。notBlank → notEmptyString('field', 'msg'), custom rules are add() register with and change the signature to ($value, $context) . For the correct way to write it, see basercms5-plugin-development §2.

T-D. public $name and chained access via singular alias

  • public $name = '...'; is deleted.
  • Direct access to related tables ($this-><Assoc>->...) in 5.x is done by fetching individually with fetchTable('<Plugin>.<Assocs>') rather than via associations (dynamic properties do not exist). For deep chains too, fetch each table individually.
  • Result array access $result['<Model>']['field'] → entity $result->field, associations are snake_case properties (belongsTo singular, hasMany plural). For how to reference entities, see basercms5-plugin-development §2.

T-E. Callbacks

  • afterFind() はremoved in 5.x. Aggregation/processing should be moved to finder methods or explicit execution on the caller side (see also C-B).
  • beforeSave/afterSave/beforeFind has a signature change: afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)。$this->data['<Model>']['x'] → $entity->x. (See basercms5-plugin-development §2)

T-F. Search/save/raw SQL/data sources

Key points of detection and conversion (for the correct way to write query builder, IN/IS, innerJoinWith, BcKeyValue, see basercms5-plugin-development §2 "ORM / Table / Entity"):

  • find('all'/'first'/'list', ['conditions'=>,'recursive'=>,'fields'=>,'order'=>,'joins'=>]) → query builder (list is find('list', keyField:, valueField:))。
  • $this->create($data); $this->save(); → newEntity($data)+save($entity)。$this->id → $e->id。
  • $this->save($array, ['callbacks'=>false,'validate'=>false]) → save($entity, ['checkRules'=>false])+ at patch time the validate:false。callbacks option does not exist in 5.x(see T-J).
  • $this->query($sql)(4.x nested array) → getConnection()->execute($sql)->fetchAll('assoc')(the return isflat associative array)。Remove old table prefixes hardcoded in raw SQL(T-G). If possible, switch to the query builder.
  • getDataSource()+$db->begin()/commit()/rollback() → getConnection()->begin()/commit()/rollback()。
  • reduceAssociations()/unbindModel()/bindModel(), baserCMS 2.x proprietary methods (createArrayForJoin() etc.) are removed → contain/matching/innerJoinWith rewrite with.
  • IN is not automatically added to array values: v4 automatically ['field' => [1,2]] converted to IN, but v5 explicitly uses InvalidArgumentException: Cannot convert value Array ... to int becomes → 'field IN' => (array)$values . IN with an empty array raises an exception, so guard against empty arrays.null matching uses the IS operator。To filter by a related table's column, use innerJoinWith/matchingplus plural alias(contain cannot be used in WHERE).
  • Unmigrated base classes: Table uses the v4 base (extends <Plugin>AppModel) as-is, Class "...AppModel" not found. Migrate to the migrated common base (AppTable inheritance).grep -rn "extends <Plugin>AppModel\b" detect all at once. Tables that use shared methods of the common base AppTable をdirectlyif inheriting BadMethodCallException: Unknown method ...(ORM の __call via) → align extends to the common base.
  • Singular table references and v4 find inside helpers: grep -rnE "get\('<Plugin>\.[A-Za-z]+[^s']'\)" scan all helpers with this, and convert to plural + query builder.
  • KVS (name/value) table: v4 magic finder findByName($key)(treating the return with $row['<Model>']['value'] ) returns a Query in v5, so code that assumes an array breaks → find()->where(['name'=>$key])->first()->value. For saving, BcKeyValue behaviors +saveKeyValue()(§2)。
  • The v4 users.user_group_id column does not exist in v5 → see T-K.

T-H. Inside Table / Lib, fetchTable() cannot be used (controller-only)

  • Detection: $this->fetchTable(...) → BadMethodCallException: Unknown method 'fetchTable'in Table, Lib, and services. Conversion: \Cake\ORM\TableRegistry::getTableLocator()->get('<Plugin>.<Models>'). (See basercms5-plugin-development §2)

T-I. getControlSource()(form option generation) from v4 to v5

  • Detection: ① v4 contents remaining in Table ($this->Assoc->find('all', recursive)・$row['<Model>']['field']・unbindModel・createArrayForJoin・$loginUser['X']['y']). ② From templates/helperssingular model namecalls → MissingTableClassException: Table class for alias '<Plugin>.<Model>' could not be found. They tend to be scattered in form templates and search elements, so detect them all at once with grep "getControlSource('<Plugin>.<Model>[^s]." .
  • Key points of conversion: convert the contents to TableRegistry + query builder / entity references, and filtering to contain/matching/innerJoinWith (for complex ones, you may temporarily return all records with a TODO and prioritize display). Change calls to Plugin.複数形モデル.field . For correct v5 definitions and calls, see basercms5-plugin-development §2 "ORM / Table / Entity".

T-J. Aggregation (SUM/aggregate) methods and converting afterSave to v5 (proven pattern)

Detection and key points when porting v4 aggregation Model methods and afterSave.Proceed while locking in the same values as v4 with unit tests(basercms-unittest). For correct v5 ways of writing (func()->sum, avoiding datetime MIN/MAX, re-entry guard, deletion cascade), see basercms5-plugin-development §2 "ORM / Table / Entity".

  • v4 find('first', ['fields'=>['SUM(x) AS total'], 'conditions'=>...]) → func()->sum+->first()->alias ?? 0(§2). If v4 cast to int, follow that (decimal is returned as a numeric string).
  • Empty array IN raises an exception(IN () is generated and thrown. v4 silently returned 0 records) → for methods where the aggregation target IDs can be empty,guard against empty at the beginningand return 0.
  • MIN()/MAX()(especially datetime) should not use aggregation functions; substitute with orderBy()->first() (§2).
  • the return value of the aggregation orchestration keeps the array shape of the 4.x series: if the consumer stays as Hash::extract($rows, '{n}.<Model>') , assemble each row with ['<Model>' => エンティティのtoArray()+集計値] and return it. Lower-level methods that are complete with array input alone can be reused without modification.
  • virtualFields(old afterFind computed values) do not exist in the 5.x series → consolidate the computation into an explicit method, apply it to each row after find, and then aggregate.
  • afterSave's bulk re-save becomes the 5.x signature +re-entry guard flag(§2). Replace the 4.x series' $this->data $this->data reference,saveAll・recursive saveAll, and recursive.
  • 4.x $Model->delete($id, true) cascade deletion of deleteAll() is not reproduced by the 5.x series'(children remain as orphans without going through the ORM's cascade) → get($id) → delete($entity) replace with ( §2). In tests for replacement saves, asserting that "the child was also deleted" prevents regressions.
  • Copy's cascading duplication: the child copy's return value in the 5.x series is EntityInterface|false(not an array). The parent side uses ->id+ boolean check. Transactions are getConnection()->begin()/commit()/rollback()。
  • When deferring the callback body to subsequent processing, do not make it a "silently empty stub": while switching to the 5.x signature, if the body is unimplemented, place a TODO comment + one tripwire test that pins down "that it is currently a stub (no processing)" (so it fails and you notice when it is implemented in the future).
  • ★[Most important] The 5.x series' save() に callbacks option does not exist (the 4.x series' ['callbacks'=>false] is silently ignored and afterSave always fires): afterSave methods that save each otherinfinite loopdo.grep -rn "'callbacks'" src/ to identify invalid options originating from the 4.x series and replace them with re-entry guard flags (for how to write guards, see §2).
  • The 4.x series' multi-table raw SELECT (SELECT A.*, B.*) in the 5.x series fetchAll('assoc') is flattened by, and columns with the same name (id/name etc.) collide with later-wins behavior (if the same flat array is put into both keys, the values get mixed) → re-fetch with the ORM, or add column aliases (A.id AS A__id) to the SELECT and rebuild.

T-K. User↔unit (group) is a 5-series BTM. Do not fabricate columns in tests that do not exist in production

  • Detection: baser5's users に user_group_id column isabsent(user↔group は users_user_groups many-to-many). The 4-series belongsTo/hasMany(foreignKey:'user_group_id') breaks.
  • Conversion: belongsToMany+joinTable:'users_user_groups' redesign to, and filtering with innerJoinWith+->groupBy(['<起点>.id'])(avoiding duplication when one user belongs to multiple groups). For declaration conventions, see basercms5-plugin-development §2.
  • [Most important admonition] Do not add columns in a migration that do not exist in production just to make tests pass(this conceals incompatibilities). Build the test schema faithfully to the production SHOW CREATE TABLE .

NumberHelper::currency() does not allow null

  • Detection: 4-series templates that pass a nullable value to $this->Number->currency()/format() → in the 5-series this is a TypeError. Conversion: ?? 0 guard with. For details, see basercms5-plugin-development §7 “Dates, Numbers, Strings”.

NumberHelper's custom formats (Number::addFormat('yen', ...)) are abolished → format() へ

  • Detection: 4-series bootstrap's CakeNumber::addFormat(...) registration + template's currency($v, '<独自名>'). The 5-series has no addFormat , and unknown codes are output as-is as prefixes (resulting in displays like “YEN 550,000”).
  • Conversion: currency($v, '<独自名>', $opts) → format($v, $opts)(for the bulk-replacement regular expressions, see the C-0 table). The 4-series old options (misspelled thounsands・negative etc.) are harmlessly ignored. Places where the template side separately adds a currency unit still work as-is. The authoritative source for 5-series number formatting is basercms5-plugin-development §7.

T-Operations. For large-scale plugins, it is safe to convert the table layer “declarations only, all at once” first

For large-scale plugins with dozens of tables (e.g., Sample has about 30 tables and over 7000 lines), (1) first, only the “declarations” of T-A through T-D (associations/behaviors/validations/$namedeletion) for all tables in bulk to initialize()/validationDefault() conversionto make the model layer loadable, and (2) leave the method bodies' find()/query()/連鎖アクセス etc. with a // TODO baserCMS5移行: marker on each line, and in the subsequentend-to-end screen passfix reliably only the places actually called, in a safe and efficient two-stage approach. Fixing declarations and method bodies at the same time tends to break business logic. Declaration conversion is mechanical, so parallel execution per file (1 agent = 1 table,php -l self-verification) is fast.

T-G. Change in the assumption of table name prefixes

The 4-series sometimes uses table prefixes such as mysite_ (check withSHOW TABLES ).The 5-series (standard installation) has no prefix. Raw SQL/joinTable・setTable() Fix all places where the prefix is hardcoded to the unprefixed form. In data migration, mysite_<plugin>_x → <plugin>_x When creating as shown, follow the same policy (prefix removal).


Controller / screen end-to-end (admin screen) layer migration patterns

After solidifying the table layer foundation (T-A to T-G), this is the process of going through and eliminating screens (controller + template + related elements + Lib + dependent plugins) one by one. One screen has wide-ranging effects (real example: Sample project management index = controller + table calcBalance + SampleUtil(Lib) + index_row/index_list templates + dependency on another plugin (SampleDep)). The reliable approach is to trace and eliminate errors one by one in the browser.

Standard practice for the screen integration phase (after table layer completion, proven per domain): Before manual browser work, controller integration tests with login(basercms-unittest reference) for each controller, (1) automatically detect migration omissions by having GET reach rendering 200 for index/edit/add →(2) eliminate template Form->create('Model')strings / raw $this->Form-> / nested array access $row['Model']['x'] →(3) convert controller passedArgs/recursive/reduceAssociations/find('first'|'all'|'list',配列)/field()/delete($id,true)/singular$this->Model to 5.x →(4) assert DB changes in POST flows (add/edit/delete, ajax confirmation). For delete, common to all screens, get($id)→delete($entity)+ nonexistent ID try/catch RecordNotFoundException to reproduce the 4.x graceful branch ("Invalid operation." → index). Missing configuration view variables are resolved cross-cuttingly in AppController::beforeRender (row set(Configure::read(...)) in the table below).Note: 200 OK in rendering tests Undefined variable swallows warnings such as, so after testing, grep tests/TestApp/logs/error.log to confirm pristine.

C-0. Patterns that can be mechanically batch-converted (efficient when applied in advance across the entire plugin)

Before going through screens one by one,does not break syntax and meaning is uniquely determinedconversions across the entire plugin with perl -pi if applied first, back-and-forth is reduced (after each conversion, always php -l verify all changed files). Results in the Sample plugin (templates + src):

Target Conversion Notes
->Form->input( → ->Form->control( CakePHP5 で input() abolished. Helper left as is ($this->Formunchanged). Whether to move to BcAdminForm for value binding is decided per screen
->element('admin/...') → ->element('...') 5.x is under templates/Admin/element/ , so the admin/ prefix is unnecessary.js()/css() の Sample.admin/... assets are not touched
getControlSource('Sample.SampleProject. such assingularmodel → Sample.SampleProjects.(plural) Sample.SamplePractice.→SamplePractices.、Sample.SampleProduct.→SampleProducts.. Singular is MissingTableClass.control('SampleProject.field') form data keys remain singular and are not changed(only the first argument of getControlSource)
currency($v, 'yen', $opts) → format($v, $opts) Number::addFormat abolished. The regular expression includes () in the value, so ->currency\((.*?),\s*'yen'(\s*,\s*\[[^\]]*\])?\s*\)→->format($1$2)
getRequest()->action → getRequest()->getParam('action') Magic property removal
getRequest()->query(Array usage) → getRequest()->getQueryParams() Single key is getQuery('k')。Assignment to getter getQuery('x') = ... must be separately converted to withQueryParams() (Fatal)
Search form searches/(Multiple) → search/to (Singular)Move What setSearch reads is search/。Not just moving, but also cross-converting the contents: $this->Form->→$this->BcAdminForm->(SampleForm/BcAdminForm is out of scope),create('Model', [...])→create(null, [...]). Duplicate (already migrated) files are discarded
Time->format($x)(No second argument /'Y-m-d') → Time->format($x, 'yyyy-MM-dd') If the second argument is omitted,datetimedisplay (2026-01-01 00:00:00). Date-only uses ICU 'yyyy-MM-dd'(not PHP date format).Time->format\(([^,)]+)\)→Time->format($1, 'yyyy-MM-dd')
control(['type'=>'select','multiple'=>'checkbox',...]) → checkbox loop (see C-G) Breaks in baser5 (empty select/< class="">)。grep -rn "'multiple' => 'checkbox'" to scan all records with, loop through options and set to control('field[]', type=checkbox) . The values (OPTS) differ by location, so individual Edit is recommended rather than batch perl
Singular table get('Sample.Sample<単数>')(including src/helpers) → plural form get('Sample.Sample<単数>s') Helper/Table/Controller all src.grep -rnE "get\('(Sample|SampleDep)\.[A-Za-z]+[^s']'\)"
$this->Form->create('Model', ...)(String model) → $this->BcAdminForm->create(null, [..., 'valueSources'=>['data','context']]) Forms/edit templates in general. For string models, also No context provider found for value of type string(CakeException)。create\('[A-Za-z]+',→create(null,。$this->Form->→$this->BcAdminForm-> together
$this->action(Template) /'admin_xxx' → $this->getRequest()->getParam('action')/'xxx' View の $this->action removed. In v5, with prefix=Admin, to the action name admin_ is not added(admin_add→add、admin_index→index). Affects getControlSource's mode determination and create's action branching
$this->BcAuth->user() → \BaserCore\Utility\BcUtil::loginUser() The return is UserInterface|false(When not logged in, null not false)。?-> は false does not work ontherefore BcUtil::loginUser()?->id when not logged in, Attempt to read property "id" on bool warning + null (becomes apparent during test execution). Robust form is (\BaserCore\Utility\BcUtil::loginUser() ?: null)?->id(false ?: null→null→null?->id) or explicit branch $u = BcUtil::loginUser(); $u ? $u->id : null;。$user['id'] Array access such as to entity references
getData()(...)(Double parentheses) → getData(...) Due to a mechanical conversion mistake from v4 to v5, $this->getRequest()->getData()('Model.x') like () is doubled and remains in templates.sed 's/getData()(/getData(/g' batch with
ConnectionManager::get('default') + $db->begin/commit/rollback → $table->getConnection()->begin/commit/rollback Transactions are from the connection of the target table
$this->Form->year('Model.field', ...) / ->month(...) → control('Model.field.year', ['type'=>'select','options'=>$years,'label'=>false]) such as FormHelper::year()/month() is removed in CakePHP5。$years/$months is self-generated at the beginning of the template.getData('Model.field') は ['year'=>,'month'=>] passes with
$this->postConditions($data)(Controller) → explicitly build the search conditions Controller::postConditions() is removed in v5。Call to undefined method。if(!empty($d['Model']['x'])) $conditions['Models.x']=... hand-write
$this->redirect(...) に return is missing (Controller) → return $this->redirect(...) In v4, redirect exited, but in v5 it only returns a Response and subsequent code continues to execute. Guard clauses (argument check → redirect) do not work, and the immediately following processing causes TypeError (null argument), etc.perl -pi -e 's/^(\s+)\$this->redirect\(/$1return \$this->redirect(/' in bulk. ★Exception: initialize()/beforeFilter() etc. : void inside a declared method, returning causes a TypeError so after the bulk replacement, check that it has not been mixed into void methods
$this->set(Configure::read('Sample')) missing → plugin Admin AppController's beforeRender() set once, and each controller inherits it config view variables referenced by the template ($billingStatuses/$taxRateList/$saleTypes/$estimateTypes etc.)'s Undefined variable. In 4.x, the plugin AppController::beforeRender automatically set them on every screen was doing. ★5.x pitfall: bin/cake bake Admin controllers derived from or manually ported from BaserCore\Controller\Admin\BcAdminAppController directly inherittend to, and do not go through the plugin's SampleAppController(= the layer that should have the 4.x set) → config view variables are missing on every screen.The correct fix is not per-action set, but to placesrc/Controller/Admin/SampleAppController.php に public function beforeRender(EventInterface $event): void { parent::beforeRender($event); $this->set(\Cake\Core\Configure::read('Sample')); } and make each Admin controller extends SampleAppController to(delete the duplicate per-action sets). It can be resolved cross-cuttingly in one go.Configure::read('Sample') the value of BcPlugin::bootstrap()/config/setting.php check whether it is already loaded with (if not loaded, restore with C-D)
$this->Xxx = new XxxHelper(new View());(direct helper instantiation as in 4.x) → $this->Xxx = $this->loadHelper('Plugin.Xxx'); CakePHP2's direct helper new is invalid in 5.x(Class "XxxHelper" not found — the class itself does exist under the plugin namespace, butnew cannot resolve the namespace, and it must go through View's normal helper loading convention (loadHelper()). Example: MailHelper(BcMail\View\Helper\MailHelper) in the template with new MailHelper(new View()) the places that were doing /lp_maintenance/ were returning 500 withgrep -rn "new [A-Z][A-Za-z]*Helper(" テンプレ全体 can be detected cross-cuttingly with

Note: 'div'/'between'/'after' old options dedicated to input such as may leak into HTML attributes with control, but do not cause a Fatal (clean up individually when going through screens).searches/→search/(C-G) involves directory moves plus the other 4.x notations in each form, so do it screen by screen rather than in bulk.

C-A. Controller 4.x idioms

Key points for detection and conversion (for the correct 5.x way to write the five idioms paginate, fetchTable, redirect, Component, and delete, refer to basercms5-plugin-development §3 "Controllers"):

  • $this->ModelName(4.x automatic model property) → fetchTable('<Plugin>.<Models>')(plural). For chained access, fetchTable each table individually.
  • $this->siteConfigs['admin_list_num'] → \BaserCore\Utility\BcSiteConfig::get('admin_list_num')。
  • $this->passedArgs(named arguments) abolished → sorting is handled automatically by the Paginator's query string, others with getQuery()。$this->RequestHandler->isAjax()/$this->params['url']['ajax'] → is('ajax')。
  • ServerRequest's magic properties are abolished: ->action → getParam('action')、->query → getQueryParams()(for a single key, getQuery('key'))、->data → getData(). The template is the same. Bulk replacement is possible.
  • Requests are immutable: leftover assignments to getters (getQuery('x') = .../->query['k']=...)は Can't use method return value in write context の Fatal (detectable with php -l) → withQueryParams()/withData() with the new request setRequest()(§3)。
  • 4.x $this->paginate = [配列]; $this->paginate('Model') → Build a query builder $this->paginate($query)(§3)。recursive/unbindModel → contain([...])、joins → leftJoinWith/innerJoinWith/matching、group/having → groupBy()/having(), and condition keys areplural aliases。
  • getDataSource()→buildStatement([...]) Generation of 4.x queries such as etc. is abolished → Query builder, totals, etc. should be reimplemented with "get IDs matching the conditions →func()->sum()," (§2).
  • [Important] Missed relocation of protected helpers: protected methods called by admin_index (search condition generation, etc.) may not be moved by BcAddonMigrator to the Admin controller (they remain on the front side or are nowhere). Port the original 4.x implementation to the Admin controller and upgrade it to 5.x by using plural aliases, query builders, and removing old prefixes.
  • Components are not autoloaded: 4.x public $components = [...] disappears in 5.x → the initialize() で loadComponent()of the controller that uses it. If missing, the corresponding property is null/undefined and causes a Fatal error.
  • delete action: 4.x passing id directly $this->Model->delete($id) is abolished → for the 5.x standard (allowMethod → get($id) の try/catch → delete($entity)+unlockedActions registration) see §3.
  • Cross-cutting "residue = Fatal as soon as called" pattern: in large plugins, only index/add/edit is migrated, anddelete/ajax_*/CSV/Excel/email/notification-related parts tend to be left as 4.x (the screen opens but operations return 500). ① Enumerate all residue with static checks → ② Batch "upgrade only the syntax to 5.x" (php -l+ zero regressions in existing tests is the completion condition. Do not write new tests) → ③ Test & verify meaning in the browser, in that order (see "Migration Procedure"). For external dependencies, // TODO baserCMS5移行: か NotImplementedException explicitly indicates deferred (if left as is, it will silently Fatal when called).

C-A2. Abolished components (PaginatorComponent / RequestHandlerComponent, etc.)

  • Detection: $this->loadComponent("Paginator") → MissingComponentException: PaginatorComponent could not be found(abolished in CakePHP 5).
  • Conversion: initialize() remove from and $this->paginate($query) use directly (§3).RequestHandlerComponent is also abolished (is('ajax') replaced by etc.).initialize() の loadComponent() Check .

C-A3. initialize signature of custom Components

  • Detection: 4.x public function initialize(Controller $controller) → incompatible with 5.x and Fatal(Declaration ... must be compatible)。
  • Conversion: initialize(array $config): void+ controller reference is $this->getController()(also note false resolution due to missing import of type hint Controller ). For 5.x Component conventions, see basercms5-plugin-development §3.

C-B. Calculated values from afterFind must be explicitly set in the controller

The per-row calculations of the old afterFind are not automatically executed in 5.x → prepare a calculation method in the Table, loop after paginate, and set it on each entity (see basercms5-plugin-development §3 "Controller"). If related columns were flattened with paginate's fields , also expand them from related properties inside the loop.

C-C. Beware of half-converted templates (including elements)

BcAddonMigrator The template after isEntity access and 4.x array access are often mixedoften. Key points for detection and conversion (for the correct 5.x way of writing forms and displays, see basercms5-plugin-development §4 "Admin Panel Forms, Lists, Search" and §7 "Dates, Numbers, Strings"):

  • Hash::get($data, 'Model.field')(including with a default value for the third argument) → $data->field. Related access $data['Assoc'] → snake_case properties (belongsTo singular, hasMany plural). For null safety, use ?->/?? '' together.
  • Static calls to global Lib classes should be changed to namespaced ones (\<Plugin>\Lib\<Util>::method())。
  • Lib static methods are sometimes called with both "4.x arrays" and "entities"(list templates use entities, edit forms use 4.x nested arrays). If you fix it assuming one, the other will Attempt to read property "x" on array → place normalization at the beginning that handles both: $p = isset($x['<Model>']) ? $x['<Model>'] : $x; thereafter reference via $p['field'] ?? null (entities implement ArrayAccess, so both are supported). When passing dates to strtotime() etc., cast with (string) .
  • $this->Form->input(...) is deprecated in CakePHP5 → control(...). Elements inside forms opened with BcAdminForm should beunified to the same instance's BcAdminForm->control() ($this->Form is a separate instance and does not share context).
  • The admin/ prefix in element paths is unnecessary: エレメントテンプレート「admin/...」が見つかりませんでした → element('foo/bar') should be changed (asset paths for CSS/JS are different, so do not touch them).
  • For an empty array, IN () is an exception: Impossible to generate condition with empty list of values for field (...)(4.x silently returns 0 results) → empty guard (§2).
  • The controller/plugin name in link arrays is CamelCase(unmatched is MissingRouteException)。
  • In View, referencing the $View->request/$this->data property triggers "helper auto-loading": 5.x View has no such property,<Plugin>.requestHelper could not be found/<Plugin>.dataHelper could not be found(MissingHelperException).If it remains in an event listener that fires on all forms, it will affect unrelated screens as wellso prioritize converting to 5.x: $view->getRequest()->getParam(...)/$this->getRequest()->getData('Model.x')。grep -rn '\$this->data\b' to eliminate it. For guard controller comparisons use CamelCase, and action has no admin_ .
  • Controller raw SQL fetchAll('assoc') is a flat associative array= in templates that assume the 4.x nested form ($row[0]['total']/$row['<Model>']['x'])values become empty / total 0→ change to flat references. A total of 0 triggers ($v/$total)*100 の DivisionByZeroError so guard it. Output SQL aliases with plain names.
  • Time->format() is an ICU pattern + omitting the second argument means datetime: 'Y-m-d'/'Y/m/d' if written this way, the month becomes minutes (0) (2026-0-31 -like display).List display (TimeHelper=ICU) and edit form formatting (entity's format()=PHP date format) are different——the fix differs depending on which path the symptom is on. For correct usage and null safety, see §7.
  • Text::truncate()/Number::format() cannot take null as the first argument (TypeError) → (string) Cast/?? 0(§7). This occurs frequently in amount/remarks-style templates, so grep them all at once.
  • When the controller uses a 4.x nested array, set() view variables set this way should also be aligned to array access on the template side (if you leave them assuming entities, Attempt to read property "x" on array)。
  • css()/js() the second argument of is F-14 (['inline'=>false] → false)。

C-D. Missing setting.php (setting key = Division by zero/adminNavigation = menu/UI changes)

BcAddonMigrator in conversion, or in the subsequent "array cleanup" work,config/setting.php the contents ofmay be thinned outSometimes. Be aware of the two patterns, and always compare with the 4.x Config/setting.php and restore:

  • Missing setting keys: If a key referenced by the code (e.g. Sample.operatingDays/productTypes/practiceTypes/monthlyUnitPricePartner/mitsumoriKessai) is missing, it causes Division by zero・array_merge(): Argument #2 must be of type array, null givenor an undefined reference.Do not fix them one by one per screen; identify them across the board: Compare referenced keys grep -rohE "Configure::read\('Sample\.[a-zA-Z0-9_]+" templates/ src/ | sed "s/.*Sample\.//" | sort -u and defined keys grep -oE "'[a-zA-Z0-9_]+'\s*=>" config/setting.php | … を comm -23 to find undefined keys from the 4.x Config/setting.php and restore them all at once.
  • Missing or altered adminNavigation (admin menu): If menu items are dropped/turned into direct links,the menu structure (UI) of the admin screen changes from 4.x. Example: 4.x transitions to aggregation from the "Analysis" menu (which includessample_menus/analysis、currentRegex で sample_aggregate/sample_units etc.), but after conversion, in setting.php "Aggregation" has become a direct link and "Analysis" and other menus (Accounting/Man-hour Management/Master, etc.) have disappeared, etc.Do not change the UI on your ownIf that is your policy, also match adminNavigation with 4.x and restore the original structure (menu URLs remain in 4.x lowercase notation 'controller' => 'sample_projects' and baserCMS5 admin resolves them). Links to unmigrated screens will error when clicked, but this will be resolved as screen migration progresses.
  • ★If the 4.x setting.php is "executable type" (assembles values by accessing the DB or other plugins' classes at load time), it cannot be simply converted to 5.x return 配列 cannot be simply converted to-- Detection: in setting.php, ClassRegistry/model calls/other plugin Util class references. Conversion: Separate static settings into the return array of setting.php, and dynamic assembly into <Plugin>Plugin::bootstrap()(with try/catch guard)(source: basercms5-plugin-development §1 "How to start new development").

C-F. Ajax called from Vue/JS should return a Response, and URLs should be changed to the 5.x admin path

Key points for detection and conversion (for the correct 5.x way to write ajax Response, adminBaseUrl, and webpack rebuild, see basercms5-plugin-development §6 "Vue / jQuery Integration"):

  • Return value of ajax actions: 4.x $this->autoRender=false; return json_encode(...) → In 5.x, returning a string outputs nothing → return a JSON Response (§6).
  • Hardcoded admin URLs in JS/bundles (4.x /admin/...) will result in 403/404(breaks when subdirectory/prefix changes). Hardcoding fixed deployment paths is also forbidden → $.bcUtil.adminBaseUrl (concatenate to) (§6). For prefixes of another base ($.baseUrl + '/admin/...') replace the entire prefix to remove the double base. From Vue2 template expressions, the global $ cannot be referenced, so go through computed (§6).
  • Other than your own plugin, /admin/<plugin>/ is also targeted: grep -rn "['\"]/admin/" webroot/js/src webroot/js/admin でSweep all hardcoded URLs across the board, and if you notice it on one screen, convert all Vue/JS together → Always rebuild with webpack(§6. Verify the reflection in the bundle with grep, and force-reload the browser).
  • The bundle is stale if [Vue warn] Property "xxx" is not defined(present in the source but not in the bundle) etc. appears → .bundle.js Do not hand-patch, fix the source → rebuild (§6).
  • Vue prop type mismatch: Invalid prop: Expected String, got Number → [String, Number] to allow multiple types (fix the source → rebuild).
  • Expected Object/Array, got String with value "R"/"u"... appears one character at a time in large amounts = a sign that ajax is broken: a leftover 4.x server-side action ($this->ModelName=null・query()) causes a Fatal, and error HTML/strings are returned instead of JSON, so v-for is repeating character by character. The cause is not Vue but the unmigrated server side → migrate the endpoint to 5.x and return a flat array of the fields Vue references (§6).
  • Manual helper generation in the 4.x style inside Table methods should be namespaced (new \BaserCore\View\Helper\BcTextHelper(new \Cake\View\View()))。
  • Leftover 4.x methods such as CSV output that are "not used for screen display" should be migrated individually at the stage where they are called (if left alone, they will fail at runtime).

C-F2. ★ The DOM id / name referenced by the bundle JS conflicts with the 5.x generation rules (rendering succeeds but JS integration silently dies)

JS/bundles premised on 4.x use4.x DOM namingto grab the DOM, so the selector matches nothing andevents, autofill, and calculations silently stop working(often neither a PHP error nor a JS error appears, and the screen looks normal).

  • Two discrepancies (check both): ① id — 4.x CamelCase (#ModelField) ⇔ 5.x control() はlowercase hyphen(model alias + field). ② name — 4.x data[Model][field] ⇔ 5.x Model[field](data[...] no prefix). The authoritative source for the 5.x id/name generation rules is basercms5-plugin-development §4 "Admin screen forms, lists, search". Exception: helper-generated fields that explicitly assign an id themselves remain CamelCase—both can coexist in the same form, making it easy to miss.
  • Investigation method (always do this once when migrating a screen): enumerate the id/name that JS grabs with grep -roE "#[A-Z][A-Za-z]+|name=['\"]?data\[[A-Za-z]+\]" webroot/js/src and cross-check against the id/name in the actually rendered HTML using an integration test with login.Rendering 200 and green PHP tests cannot detect this inconsistency(Because JS is not executed)—close it out with either manual browser verification or an assert that "the expected id/name actually exists in the HTML" (the gatekeeper pattern in §4).
  • How to fix (the principle is to align JS with the 5.x-generated DOM): The recommendation is to match the JS side to the 5.x id/name and rebuild with webpack. The alternative is to explicitly specify in the template control id to restore the 4.x CamelCase (no rebuild required, but note the label's for mismatch, references to the old id in CSS/other JS, and duplicate ids on the same screen.name is a POST key, so do not change it). For the explicit id convention, see §4.
  • ★POSTs for form rows dynamically generated by Vue (such as line items) fail with Unexpected field '...' in POST data(BadRequestException): The 5.x FormProtection rejects fields that are not in the DOM at render time (the behavior that used to pass through in 4.x because SecurityComponent was not used).Allowed memory size exhausted may appear as—it is just that rendering the exception trace for a huge POST is heavy; the real cause is in the stack's Unexpected field . The fix is a set of two things (change Vue's :name to the 5.x format 'Model[' + i + '][field]' and register the target action in unlockedActions . See §6). Even if 4.x names with data[...] are not rejected, they cannot be picked up by getData() andare not saved。
  • Verification: Lock it down with an integration test: "a POST containing line items does assertRedirectand the line items are actually saved" (basercms-unittest). CSRF is separate middleware, so it is not disabled by unlockedActions.

C-G. Search forms (setSearch) and custom pagination with raw SQL

Key points for detection and conversion (for the correct 5.x way to write search forms, control(), GET submission, valueSources, and custom pagination, see basercms5-plugin-development §4 "Admin screen forms, lists, and search"):

  • Placement of search forms: In 4.x/converted searches/(plural) if left as is, エレメントテンプレート search/xxx が見つかりませんでした(5.x setSearch() は search/reads (singular)).Recurs on every screen = a typical cross-cutting fix: ①searches/*.php Apply $this->Form->→$this->BcAdminForm->・create('Model',…)→create(null,…) in bulk to all entries → ②search/move all to (singular) (git mv) → ③ For already migrated files, discard the searches/ side → ④ Delete the empty searches/ → ⑤ Run php -l。
  • on all entries for the string model create('Model', ...) は No context provider found for value of type string → contextless create(null, [...]) to. GET search and redisplay of input values (valueSources に query)・control() The mandatory convention is §4.
  • 4.x's control(['multiple'=>'checkbox'])/multiCheckbox() はbreaks(scan everything with empty selects or < class="">)→ grep -rn "'multiple' => 'checkbox'" and move to individual control in the options loop (§4). The receiving side's condition is 'field IN' => (array)$values(T-F)。
  • If a custom form helper uses BcForm , it breaks in the admin screen → BcAdminForm へ(§4)。
  • FormHelper::domId() was removed in CakePHP5(Call to undefined method)→ \Cake\Utility\Inflector::camelize(str_replace('.', '_', $fieldName)) reproduce with , and to match the id referenced by JS, specify 'id' explicitly in control.
  • 4-series JS CamelCase id selectors do not match 5-series lowercase hyphen idso the handler becomes unresponsive (on screens with no search button and only change-triggered auto submit, "nothing happens when you change the filter") → eliminate the id dependency via form delegation or use explicit ids (§4).
  • List bulk processing/bulk selection (ListTool.batch/checkall)・submit()/button() も BcAdminForm unify to (do not change field names because existing JS references them).control() When passing HTML to the label of , use array format +escape: false(§4)。
  • $this->FormTable は MissingHelperException(it currently searches the plugin for an unloaded helper name) → the 5-series registered name is BcFormTable。dispatchAfterForm() は BcAdminForm(§4)。
  • HABTM save (4-series saveAll)→ _ids(§2). GET display is contain()+ selected ID extraction + checkbox loop.
  • Raw SQL custom pagination: 4-series paginate()/paginateCount() override is not called in 5-series (abolished) → fetch the total count + current page rows yourself in the controller and set PaginatedResultSet to a view variable (§4. For sort, build ORDER BY using a whitelist).SELECT * When wrapping a join UNION in a subquery, SQLSTATE[42S21] Duplicate column name — count the total by executing the UNION SQL,ORDER BY ... LIMIT はappend directly to the enddo. Template is 4-series nested form → fetchAll('assoc') to the flat form.

C-I. Common migration pattern for edit/add form screens (edit/add)

4-series edit/add (read(null,$id)/find('first',...) → set() → save(), the template assumes 4-series nested arrays) migration boilerplate:

  • GET (display): fetch the entity → prepare the convert*ToFormData() that formats into a 4-series compatible nested array, and set it with withParsedBody() (map belongsTo=singular snake and hasMany=plural snake to 4-series keys). If date/datetime objects are passed as-is, they appear in input fields with hours/minutes/seconds, so stringify them during convert (for unifying display formats, see the datePicker section in §3 "Dates and Times").
  • POST (save): 4-series formatting methods (convert*FormToDb etc.) can be reused as-is → get($id) or newEmptyEntity() → patchEntity() → save() → return $this->redirect([...])。
  • template: create('Model') → create(null, ['valueSources'=>['data','context']])、Form->value('Model.x') → getData('Model.x')、pass[1] → getParam('pass')[1] ?? null、$this->Form-> → $this->BcAdminForm->(see C-0. The correct 5-series way to write forms is basercms5-plugin-development §4).
  • Unmigrated deep side effects (afterSave related aggregation, notification components, 4-series-only methods) are deferred with TODO markers, prioritizing display and basic saving (migrate individually when used).
  • [Pitfall] Custom validation rules "do not run unless you save": if the rule body remains the 4-series ORM, GET display passes but Fatal on POST save. Do not check only GET and call it "OK". Convert the rule body to 5-series (§2).$context['data']['id'] and the like are fetched defensively.
  • Reuse existing already-migrated methods: instead of re-migrating complex 4-series queries, replacing them with equivalent methods already converted to 5-series is safer and faster.
  • "Related record list" coexisting in the edit screen: Only the main body withParsedBody() makes the related listempty without errorsand not rendered (easy to miss) → separately fetch the related data in the controller and set() then fix the template to entity references + null guards.
  • BcBaser->link() When passing images/HTML to ['escape' => false](otherwise it displays as raw text). The 4.x admin screen asset images (admin/btn_*.png etc.) do not exist in 5.x and return 404 → replace with the bca-btn-icon convention (both in §4).

C-J. getControlSource etc. with disableHydration returns a flat array

disableHydration() 's result rows area flat associative array($row['Table']['field'] ), so attach an explicit alias to the select and reference it flat (see basercms5-plugin-development §2). When filtering by WHERE on a related column while also wanting to include rows with no relation, leftJoinWith()(contain cannot be used in WHERE).

C-H. Embedding a list element into a form breaks PaginatorHelper

  • Detection: When reusing a list element (Paginator->sort()/element('pagination')/list_num) in a screen that is not paginated (e.g., an edit form), You must set a pagination instance using setPaginated() first(4.x rendered silently, but 5.x throws if the view variable lacks PaginatedInterface ).
  • Fix: Guard pager rendering so it runs "only when on the dedicated list controller" (see basercms5-plugin-development §4). Guard embedding-only elements the same way.
  • [Most important pitfall]getParam('controller')/params['controller'] は CamelCase: The snake_case comparison left over from 4.x conversion (=== '<snake_case名>')はalways false , causing the list element to fall into the form-embedding branch, andthe screen opens without errors but no data appears(only the total shows and the list is empty, etc.) → switch to CamelCase comparison.grep -rn "=== '<plugin接頭辞>_" templates to check all rows.

C-E. Dependent plugins are also needed in a chain

contain() If the plugin that owns the related <PluginA>→<PluginB>is disabled, Table class for alias '<PluginB>.<PluginB>Xxx' could not be found。enable the dependent plugin (status=1) and create the table from 4.x. If the dependent plugin's event listeners etc. are not yet migrated, warnings will appear, but the display of the target screen itself often is not blocked (handle it when migrating the dependent plugin itself).

Concrete example patterns of front-end display errors (symptom → cause → fix)

Frequent concrete examples when restoring the front-end display of a theme/plugin.BcAddonMigrator Typical defects that remain even after automatic conversion withSearch by the error message and match it to the relevant pattern. Since fixing one makes the next error appear in an "onion-peeling" fashion,curl -sk -o /dev/null -w "%{http_code}" <URL> work through them one by one while checking the status with

The F patterns for template/theme rendering (F-1 to F-5, F-9, F-11 to F-17, F-19, F-20) have been moved to the basercms-theme-4-to-5-upgrade skill.

F-6. The plugin's config/routes.php breaks all commands by involving the CLI

$request = \Cake\Routing\Router::getRequest(); は null when running the CLI because it returns, the immediately following $request->getPath() で Error: Call to a member function getPath() on null becomes, andbin/cake stops working entirely (because route loading also runs in the CLI). Also, Router::connect() は deprecated in CakePHP 5。

  • Fix: At the beginning, add a null guard for if (!$request) return; and change the route definition to RouteBuilder's $routes->connect(...) (in the plugin's routes.php, $routes is passed into scope). Controller names are CamelCase (onemind_files → OnemindFiles)。

F-7. MissingRouteException: /files/... could not be found(upload files not migrated)

BcDbMigrator/ Data restoration only creates core-related files/ subdirectories (blog, contents, editor, mail, theme_configs, uploads, etc.), andplugin-specific upload directories (e.g., onemind_configs) are missingdoes. Without the physical file, /files/... falls back to the root and throws an exception.

  • Fix: The entire v4 files/(or app/webroot/files/) is v5/webroot/files/ にmerge-copiedto.rsync -a files/ v5/webroot/files/。
  • ★cp nesting trap: v5/webroot/files is partially generated at restore time, so withcp -a files v5/webroot/files , it nests into files/files/ nested indoes. If usingcp , use cp -a files/. v5/webroot/files/(trailing /.)。
  • While the actual file is missing, upload-related helpers may fall back and output **root-relative /files/...(no subdirectory)** URLs, and after migration they return to the correct /v5/files/... (do not misdiagnose this as a bug on the URL generation side).

F-8. Call to undefined method(BcAddonMigrator method not ported)

BcAddonMigrator converts classes such as Helper, butsome methods are not ported and are missingcan happen (e.g., isTopMainVisualUseBanner() inPlugin() react()). When called from a template, it becomes Call to undefined method becomes.

  • Fix: Compare the method lists of the v4 and v5 classes with grep -nE "function " でdiff comparison, and manually port the missing methods by rewriting them in v5 syntax.
    • getEnablePlugins()(global function) → \BaserCore\Utility\BcUtil::getEnablePlugins(). The return value is an array of Plugin entities, soHash::extract($plugins, '{n}.name') retrieve the name with '{n}.Plugin.name' (not the v4

F-10. Shortcodes are not executed and remain as raw text of [Plugin.method ...] remains as raw text

Shortcodes are registered in each plugin's config/setting.php の 'BcShortCode' => [...] , butif the plugin is disabled (plugins.status=0), setting.php is not loaded and they become unregistered. Since BcShortCode outputs unregistered shortcodesas raw text without raising an error, the screen shows [<Plugin>.methodName] etc. exposed.

  • Identification: Check the status of the plugin for the prefix of the exposed shortcode ([Plugin.xxx] Plugin part) check the plugin's status.
  • Fix: Migrate and enable the relevant plugin (see F-5). While unmigrated, it remaining as raw text is expected.

F-22. A 4-series plugin's Vendor/ external library bundled in (App::import('Vendor', ...)) has not been ported to the 5-series, so the functionality dies entirely (additional fixes for PHP8 incompatibility are also needed)

In the CakePHP2 era, composer was not common, and external libraries (e.g., leafo/scssphp) were packaged with their source inside the plugin's Vendor/<lib>/ and loaded withApp::import('Vendor', '<Plugin>.<lib>/<bootstrap>') — this pattern was common.BcAddonMigrator converts the plugin's Controller/Model/View etc., butthis kind of bundled external library (underVendor/ is not subject to conversion/portingand is simply left behind. When the relevant functionality is called, class_exists() remains false and it fails (an exception or warning merely appears in the log, and the functionality stops, making it hard to notice).

  • Identification: On the 4-series source side, grep -rn "App::import('Vendor'" app/Plugin/<Plugin>/ and check for the presence of the corresponding Vendor/<lib>/ directory. On the 5-series side, execute the relevant functionality and check whether an error log (class '...' not found etc.) appears.
  • There are two fix approaches:
    1. (Recommended) Install the current successor package via composer(e.g., leafo/scssphp → scssphp/scssphp. This is a fork whose maintenance was transferred by the same author, and the class namespace often changes (Leafo\ScssPhp → ScssPhp\ScssPhp), so on the calling side write a class_exists() branch supporting both).
    2. (When porting the bundled version as-is) Copy the entire 4-series Vendor/<lib>/ source set as-is into the 5-series plugin's vendor_bundled/<lib>/(vendor/ avoid the name — baserCMS 5-series standard.gitignoreが /plugins/*/vendor/ is ignored as a dependency under the plugin, so if you want it tracked, use another name), and from the consuming code include_once the old manual scss.inc.phpbootstrap (e.g., require_once to do.If you choose this method, the old library itself often assumes an old PHP version (5.x–7.x), so additional patches are needed to run it on PHP8. The types of incompatibility actually encountered (both are runtime errors that only become apparent when executed, andphp -l cannot be detected by the syntax check alone):
      • Calls to each() functions removed in PHP8(Call to undefined function ...\each())。list($k,) = each($arr) は array_key_first($arr)(when only the keys are needed), etc., rewrite to a substitute suited to the purpose.
      • Invalid escape sequence inside a regex character class(preg_match(): Compilation failed: escape sequence is invalid in character class). The full-width ¥ symbol and \A(originally intended as "start of string", but due to typos/OCR corruption, etc.) is [...] is mixed into, PCRE in PHP 7 and earlier silently allows it, but PCRE2 in PHP 8 throws a syntax error exception.preg_match/preg_replace fails, it returns null , so subsequent processing generates broken output (NULL concatenation, etc.). Instead of a character class, (?:^|\s)(match check) or (?<=\s)(fixed-length lookbehind), etc., rewrite according to the intent.
    • With either method,actually run a test command that exercises that feature (Cake\Command\Command a temporary bin/cake command that extends, etc.), write it and visually check the execution result.php -l syntax check alone cannot detect this kind of incompatibility that only manifests at runtime (each(), invalid regular expressions, removed functions, etc.). After checking, delete the temporary command.

F-21. Extracting the str_replace domain from a URL string breaks under subdirectory installation, causing "all requests 404"

When 4.x code (such as an EventListener startup guard) extracts the domain via string manipulation like str_replace('/', '', str_replace('https://', '', $url)) and compares it with HTTP_HOST , under subdirectory installation (SITE_URL=https://host/subdir/) it becomes "hostsubdir"permanently mismatched = the moment the plugin is enabled, all pages 404(in the case of a guard that throws an exception) (measured).

  • Detection: grep -rn "str_replace.*https\|HTTP_HOST" src/。
  • Fix: parse_url($url, PHP_URL_HOST) compare only the host (faithful to the 4.x intent = domain match check).

F-18. With wide tables (many columns), "the entire page scrolls horizontally" = flex's min-width:0 missing

The layout of the admin theme bc-admin-third is .bca-container { display:flex }+.bca-main { flex-basis:100% } but .bca-main に min-width:0 is missing. Because of the flex item's default min-width:auto , it does not shrink below the content (such as a wide table with many columns), and .bca-main extends to the content width,horizontal scrolling appears on the entire page(the centered save button shifts to the right, etc.). Even if you add overflow:auto to the child element, it has no effect because the parent flex item extends.

  • How symptoms appear: Did not appear in 4.x / only becomes apparent once data is present (immediately after migration, no data is fetched so there are no columns and it goes unnoticed). It is easy to reproduce on screens where monthly columns increase based on the current date (such as man-hour simulation).
  • Fix (without modifying the core theme): In that screen's CSS (the Vue bundle's non-scoped <style> etc.), add .bca-main { min-width: 0; } and wrap the table in ascroll wrapperto give it overflow:auto !important; max-width:100% !important; width:100% . With this, .bca-main fits within the screen width, and the table scrolls horizontally inside the wrapper (position:sticky column pinning also works). Once CSS is fixed, webpack rebuild(bundled CSS is MiniCssExtractPlugin で ../css/[name].bundle.css output to).

Theme migration patterns (separate skill)

Theme-specific migration patterns (templates/layout, element, Blog/Pages/Mail templates) (TH-series), and among frontend display errors, template/theme rendering patterns (F-1 to F-5, F-9, F-11 to F-17, F-19, F-20) are basercms-theme-4-to-5-upgrade split into skills. When working on themes, refer to those.