A collection of breaking changes, deprecations, and fix recipes encountered during CakePHP upgrades.By versionOrganize and grow it. For PHP core causes (implicit nullable, dynamic properties, etc.), see the separate skill php-migration .
Covered: 5.0 → 5.1, 5.1 → 5.2. Version attribution is based on actual observations during migration work (there may be discrepancies with the exact introduction version). When supporting a new version, append a new "## X.Y → X.Z changes" section.
General Principles (Common to All Versions)
- Upgrade is
composer require.composer updateis not used(to avoid affecting all dependencies). Example:composer require cakephp/cakephp:"5.2.*" --update-with-dependencies - Distinguish between "fatal errors" and "deprecation warnings". In debug mode, deprecation warnings are displayed at the bottom of the screen and look like "errors", but
logs/debug.logのdebug:is a warning (execution continues).logs/error.logand Fatal/Exception are the real errors. - When a large number of tests fail, classify them by root cause. Even with hundreds of failures, there are only a dozen or so causes. Aggregate exceptions from the full log and eliminate systemic causes first (for aggregation commands and isolation procedures, see the
basercms-unittestskill). - Distinguish between regressions and existing/environment failures。
git diff HEAD -- <file>to check whether it is something your change targets. If it is an unchanged file and depends on fixture/plugin loading (Plugin::isLoaded('X')is false / DI container not registered / English↔Japanese message mismatch), it is not migration-caused but an environmental factor.Because vendor has already been replaced with the new version, a simplegit stashcannot create a clean baseline。
For how to run unit tests and procedures for aggregating and isolating failures, see the
basercms-unittestskill.
5.0 → 5.1 changes
In 5.1, E_USER_DEPRECATED-type deprecations increase.Error.errorLevel can suppress them, but fix them as part of proper support.
1-1. Query::order() / group() deprecation
order()→orderBy()、group()→groupBy()(SelectQuery::group() も)。
// Before
$query->order(['Contents.id'])->group('site_id');
// After
$query->orderBy(['Contents.id'])->groupBy('site_id');
1-2. find('all', $options)(options array) deprecation (Since 5.0.0)
Passing an options array is deprecated. Use named arguments or spread.
// Before
$this->Table->find('all', ['limit' => $n, 'conditions' => $c]);
// After(いずれか)
$query = $this->Table->find()->where($c); if ($n) $query->limit($n);
$this->Table->find('all', ...$options); // $options のキーが finder の名前付き引数に一致する場合
$this->Table->find('all', conditions: $c, order: $o);
- Those that are already
...$options/conditions:etc. are not targeted (beware of false positives). - Warning:
Calling 'findAll' finder with options array is deprecated. Use named arguments instead.
1-3. Pagination results PaginatedResultSet に
paginate() The return value of PaginatedInterface(PaginatedResultSet) and becomesisEmpty() does not directly have ResultSet methods such asdoes not directly have. First, call ->items() .
// Before(テンプレート等)
if (!$sites->isEmpty()) { ... }
// After
if (!$sites->items()->isEmpty()) { ... }
1-4. Renaming of translation cache settings
_cake_core_ → _cake_translations_。config/app.php・config/bootstrap.php・BcUtil Update references such as
config/plugins.phpで'BaserCore' => nullValues such as'BaserCore' => []set to .
5.1 → 5.2 changes
5.2 has many changes that "turn operations previously silently overwritten/allowed into exceptions."
2-1. Returning values from event listeners is deprecated (becomes an error in 5.3)
From the listener, use return without,$event->setResult() / $event->stopPropagation() for the value. The return type is : void can be set to .
// Before
public function beforeSave(EventInterface $event) { return false; }
// After
public function beforeSave(EventInterface $event): void { $event->setResult(false); }
- The return values of Table/Marshaller callbacks were originally ignored, so simply
returnjust delete it (seebeforeMarshalはArrayObject $data; rewrite by reference. The return value is not used). - Controller
beforeFilterstandard fix:// Before $response = parent::beforeFilter($event); if ($response) return $response; ... return $this->redirect(...); // After parent::beforeFilter($event); if ($event->getResult()) return; ... $event->setResult($this->redirect(...)); return; - Within the closure,
return(SEO etc.) is out of scope (beware of false positives).
2-2. AssociationCollection::add() is a duplicate alias and throws an exception
Until 5.1 it was silently overwritten → 5.2 occurs when an association already defined with Association alias 'X' is already set. を throw。
initialize() , is hasMany/belongsTo etc. in a different method/Service/Controller,reconfigured(e.g. replacing the finder).
// 再設定の前に既存を削除してガードする
if ($table->associations()->has('CustomLinks')) {
$table->associations()->remove('CustomLinks');
}
$table->hasMany('CustomLinks')->setFinder('all')->...;
- inline
hasMany('X')is safe to consolidate into a guarded dedicated method (e.g.setHasManyLinksByAll()). - Detection:
grep -rnE "\->(hasMany|belongsTo|belongsToMany|hasOne)\(" --include="*.php"で Scan for association configuration inside Service/Controller(anything other than Table's initialize requires caution).
2-3. Duplicate validation rule names throw an exception
Calling a rule with the same name on the same field twice with add doing so A validation rule with the name 'X' already exists. In 5.1 it was overwritten.
- If blocks with the same name are duplicated,merge(preserving last-wins intent). Example: consolidate multiple validation definitions for
alias,minLengthadd it only once, etc. - Watch out for collisions with the callback's named argument
$context(the second argument of a custom method$contextdo not set to ). - Note: If a test calls "the same method twice on the same validator to verify overwriting", that isa test-side concernand does not happen in the production flow (once per field) = it may be out of scope for app fixes.
2-4. ResultSet type / not cloneable
Query::all()The return value ofCake\ORM\ResultSet(previouslyResultSetDecorator). Loosen the type declaration toResultSetInterfaceかiterable.- ResultSet is not cloneable and its iterator is shared。
clone $resultSet, or double iterating over a live ResultSet causes elements to disappear.->toArray()でmaterialize early before using it (this causes issues such as form fields disappearing). - Absorb helper/service argument types with
ResultSetInterface/iterableset to ,is_array($x) ? $x : $x->toArray().
2-5. Array condition null values throw exceptions
where(['field' => null]) は Expression 'field' has invalid 'null' value. ... operator (IS, IS NOT) is missing. throw. Use the null-safe IS operator.
// Before
$conditions = ['Contents.entity_id' => $entityId]; // $entityId が null だと例外
// After
$conditions = ['Contents.entity_id IS' => $entityId]; // 非nullなら =、nullなら IS NULL
2-6. Entity string casting is deprecated
In templates etc., (string)$entity equivalent (such as direct insertion into HTML attributes) is deprecated.json_encode($entity) use.
$this->BcBaser->js('...', false, ['data-current' => json_encode($content)]);
2-7. PluginCollection::add() throws an exception if already loaded
Plugin named 'X' is already loaded。addPlugin guard before.
if (!$application->getPlugins()->has($name)) {
$application->addPlugin($name);
}
- Theme (
coreFrontThemeetc.) and$themesbeware of cases where same-named entries are added twice in an array (addTheme())。 - Similarly guard other addition points such as Migrations / Authentication / CakephpFixtureFactories.
2-8. FormProtection の _Token not found (postLink + custom JS)
baserCMS uses its own JS (plugins/bc-admin-third/src/js/admin/common/lib/jquery.bcToken.js) and send it. In CakePHP 5.2, postLink's onclick changed to document.{form}.submit() → document.{form}.requestSubmit()(CSP-compatible), so the extraction regex no longer matches and _Token is no longer sent.
// jquery.bcToken.js: 両対応にする
var regex = /document\.(post_.+?)\.(?:request)?[Ss]ubmit\(\)/;
- After fixing, rebuild the admin theme's JS:
cd plugins/bc-admin-third && npm run build(bcToken is included in the common/startup bundle. webpack automatically entry-pointssrc/**/*.js). - Rather than excluding individual actions with
unlockedActions, this regex fix canfix all postLink instances at once(CSRF protection is also maintained). Plugin disabling, theme switching/copying/deleting, cache clearing, etc. are all restored at once.
2-9. PluginCollection A change during live iteration causes subsequent plugins' bootstrap to be skipped
5.2 の BaseApplication::pluginBootstrap() は PluginCollection::with('bootstrap') の live generatoris traversed. If a certain plugin's bootstrap() changes the collection during iteration via addPlugin()/remove()(adding a theme, deleting DebugKit, etc.),the bootstrap() is skippedand,setting.php , etc. of plugins lined up immediately after are not loaded, and Configure::read('BcXxx....') becomes null.
- In production, the problematic plugin (BaserCorePlugin in baserCMS) adds content plugins "later," so there is no element immediately after and the issue does not manifest, butif preloaded in tests (
appPluginsToLoadequivalent), it manifestsdo. - Fix: stop live iteration, and until there are no unbootstrapped items,bootstrap per snapshot (test infrastructure
BcTestCase::setUp, etc.).$booted = []; do { $pending = []; foreach ($app->getPlugins() as $plugin) { if ($plugin->isEnabled('bootstrap') && !in_array($plugin->getName(), $booted, true)) $pending[] = $plugin; } foreach ($pending as $plugin) { $booted[] = $plugin->getName(); $plugin->bootstrap($app); } } while ($pending);
2-10. View::_checkFilePath() second argument becomes the plugin name (MissingPluginException in templates containing.. )
5.2 で View::_checkFilePath(string $file, ?string $plugin) now passes $plugin を _paths($plugin) as the second argument._getTemplateFileName()/_getLayoutFileName() override andIf a filesystem path is passed as the second argument, the path is mistaken for a plugin name, and templates containing .. (such asrender('../element/...') ) return 500.
- Fix: passnot a path but the plugin nameas the second argument.
_checkFilePath($path . $name, $path)→_checkFilePath($path . $name, $plugin)。 ..Normal templates that do not contain this are unaffected due to an early return (it only manifests via..).
2-11. TreeBehavior: at the root parent_id=null Setting and saving causes tree corruption
The TreeBehavior of 5.2, for the root (parent_id=0) entity parent_id=null If set and saved, it is treated as a "move to root" and lft/rght is recalculated. As a result, the tree is broken, and URL generation, etc. for descendants fails.
- Fix: when updating the root,
parent_idをdo not include it in the data(do not change it).$data = ['id' => $id, 'name' => $name, ...]; if (!is_null($rootContentId)) $data['parent_id'] = $rootContentId; // ルート(null)では parent_id を触らない
2-12. Table::get() second argument also becomes a named argument (array options are deprecated)
find() As with Table::get($id, mixed ...$args) , in 5.2 it becomes
// Before → After
$table->get($id, ['contain' => ['Users']]); → $table->get($id, contain: ['Users']);
// 動的に options を組む場合はスプレッドで名前付き引数展開(PHP 8.1+)
$query->find($finder, array_merge(['prefix' => $prefix], $options));
→ $query->find($finder, ...array_merge(['prefix' => $prefix], $options));
- Spread expansion assumesan associative array with string keys(matching the named argument names of finder/get).
2-13. Entity::set([...])(array bulk set) is deprecated
Setting an array to an entity in bulk uses patchEntity() .
// Before(テスト等)
$entity->set(['field' => $v, ...]);
// After
$table->patchEntity($entity, ['field' => $v, ...]);
2-14. Helper's real methods become magic method delegation → method_exists() が false(TextHelper::truncate , etc.)
With CakePHP updates, helper methods that were previously actually declared may be __call replaced by delegation via . Representative example: TextHelper::truncate/tail/highlight/excerpt/toList has no real method, andTextHelper::__call() が Cake\Utility\Text::{$method}() delegates to .
- Impact: Code that checks existence with
method_exists($helper, 'truncate')before callingmethod_exists()がonly makes actually declared methods return true, so it becomes false, and the relevant featuresilently stops working(no exception is raised, and null etc. is returned). - baserCMS real example:
BcBaserHelper::__call()がPluginBaserHelper::methods()mapping ('truncateText' => ['Text','truncate']) was guarded before being called usingmethod_exists(), andtruncateText()became unresponsive. - Fix: guard
is_callable()change to .is_callable([$obj, $method])returns true not only for real methods but also for objects that have__call(since the mapping is the source of truth for the call target, loosening the check still preserves design safety).// Before if (method_exists($pluginBaser->{$helper}, $target)) { ... } // After if (is_callable([$pluginBaser->{$helper}, $target])) { ... } - Detection:
grep -rn "method_exists(" --include="*.php"scan for locations where the target can bea helper/utility delegation method.method_existsとis_callableThe asymmetry of (only the latter makes__callreturn true) is the key point. - Test:
BcBaserHelperDelegated methods via , if there is no enabled plugin,_initPluginBasers()returns early and BaserCore baser is not registered (PluginFactory::make(['name'=>'BcBlog'])->persist()verify after doing ).
Ecosystem: cakephp/migrations 4.5+ (bundled update during the CakePHP 5.2 period)
M-1. AbstractMigration / AbstractSeed deprecation of → BaseMigration / BaseSeed
From migrations 4.5 onward,Migrations\AbstractMigration / Migrations\AbstractSeed is deprecated. In tests that run migrations, Migrations\AbstractMigration is deprecated appears in large numbers (combined with xdebug stack dumps, it looks like a huge number of errors).
- Fix: Change the base class to
BaseMigration/BaseSeed.use Migrations\Table;→use Migrations\Db\Table;(table()match the return typeMigrations\Db\Table). BaseMigration::__construct(int $version)(The argument is one int for version. OldAbstractMigrationchanged from multiple arguments).$this->inputThere is no .- Like baserCMS
table()If you override to add a prefix,$this->inputcannot be used, soobtain from the connection settings of the running adapterdo:public function table(string $tableName, array $options = []): \Migrations\Db\Table { $prefix = $this->getAdapter()->getConnection()->config()['prefix'] ?? ''; return parent::table($prefix . $tableName, $options); } - Inline test migrations (within the test,
$file->write('<?php class X extends AbstractMigration ...')) should also not be forgotten and changed toBaseMigration.
M-2. Migration test's new adapter API (Phinx\Db\Adapter\AdapterFactory abolished)
Old Phinx\Db\Adapter\AdapterFactory + Symfony Console の setInput() is incompatible with the new base. Use the new Migrations\Db\Adapter\AdapterFactory , and derive the adapter type fromthe connection's driver name(same as migrations core Environment::getAdapter() ). Avoiding hardcoding MySQL allows tests to pass on PostgreSQL/SQLite as well.
$connection = ConnectionManager::get('test');
$driverClass = get_class($connection->getDriver());
$adapterType = strtolower(substr($driverClass, (int)strrpos($driverClass, '\\') + 1)); // mysql / postgres / sqlite
$options = ['adapter' => $adapterType, 'connection' => $connection] + (array)$connection->config();
$adapter = \Migrations\Db\Adapter\AdapterFactory::instance()->getAdapter($adapterType, $options);
Version-independent (CakePHP 5.x in general / baserCMS-specific)
3-1. Controllers return a Response (direct output is prohibited)
header() + echo readfile() Direct output such as is headers already sent cause. Return a Response.
$content = file_get_contents($distPath);
// 後始末(unlink / emptyFolder)を先に実施
return $this->getResponse()
->withType('zip')
->withHeader('Cache-Control', 'no-store')
->withHeader('Content-Disposition', 'attachment; filename="' . basename($distPath) . '"')
->withStringBody($content);
- Output such as KCAPTCHA is
ob_start()/ob_get_clean()stringify with andwithStringBody()return with . - Return type
: voidif you have added , change it to: \Cake\Http\Response. - Common in download-related features (theme DL, backup DL, log DL).
3-2. Class overriding and automatic detection tool (baserCMS-specific)
baserCMS overrides some CakePHP classes with plugins/baser-core/src/Routing/(namespace Cake\Routing) (Asset・RouteCollection etc.).
AnalyseController scans the source and ReflectionClass when loading with , if you load with a path-derived class name (\BaserCore\Routing\Asset), it redeclares an already-loaded class in a different namespace (Cake\Routing\Asset) and causes a Fatal with Cannot redeclare class , stopping all tests midway.
→ AnalyseController::CONVERT_CLASS_NAME に '\BaserCore\Routing\Asset' => '\Cake\Routing\Asset' add a correction such as (Always register when you add overrides)。
3-3. Global env() A detector that depends on cannot be reproduced in integration tests → $request->getEnv()
When overriding the 'https' detector with reverse proxy detection etc.Global env() if you are looking at , integration tests ($this->configRequest() で environment injecting ) cannot reproduce it and the branch does not work.$request->getEnv() Using gives production-equivalent behavior and is reproducible in tests too.
// Before: $request->addDetector('https', fn() => env('HTTPS') === 'on' || ...);
// After: $request->addDetector('https', fn($r) => $r->getEnv('HTTPS') === 'on' || ...);
3-4. Time comparison drifts during full test runs (long duration) → do not use real time() do not use
time()(real time) and the DB's modified A tolerance check comparing etc. (in tests, the Chronos-fixed bootstrap time) goes out of range when the full test suite takes tens of minutes,only during long runsfails.\Cake\I18n\DateTime::now()->getTimestamp() Using follows Chronos's fixed/shifted time, and in production it is identical to real time.
3-5. config/routes.php の fallbacks() hides content routing (404 in tests)
baserCMS resolves URLs with content routing (BcContentsRoute = /*) andBaserCorePlugin::disableRootRoutes() が config/routes.php の fallbacks()(/{controller}/{action}/*) is disabled. However, disableRootRoutes() は BcUtil::isConsole() でSkipped in the console (unit tests)so fallbacks remain only in tests and /{controller}/{action}/* が BcContentsRoute match before it, causing blog archives etc. to return 404.
- Safety measure (current):
config/routes.phpの$builder->fallbacks();をDisable(comment out) to match production behavior. Since baserCMS is primarily content-routing based and does not use generic controller fallbacks, this is the most robust. - ⚠️ Anti-pattern: The idea of "running
disableRootRoutes()even in tests (isTest()permitting it with " isavoided。disableRootRoutes()destructively empties the route collection via reflection, but in the test environmentthe route collection accumulates across requests, so on the second and subsequent builds it wipes even API routes etc.,causing all API integration tests to return 404 (confirmed by actual measurement). Even a surgical removal that only deletes fallbacks tends not to offset the side effects of the wipe and easily breaks down.
How to proceed with the investigation (checklist)
- Whether it is fatal or a warningを
logs/debug.log/logs/error.logdetermine by. - When there are many failuresaggregate the exception messagesto identify the systemic cause.
- For each cause,app src originかtest/environment/fixture/i18n origindetermine which (
git diff HEAD/Plugin::isLoaded/ DI container registration / English↔Japanese messages). - Fix app src-origin issues with the recipe in the relevant version section →
php -llint with → run the relevant test with--filterfor unit verification. - Finally, rerun all tests to confirm improvement in the count and whether there are any new regressions.
Related: PHP core-origin issues (implicit nullable, dynamic properties, fgetcsv, Reflection) go to the php-migration skill.