What Is New in CakePHP 5.4
CakePHP 5.4 ships a focused set of upgrades: a smarter default for association loading, long-requested database query helpers, expanded collection utilities, and a more ergonomic command API. Nothing is ripped out -- this is an additive release with a few deliberate behavior changes worth knowing before you upgrade.
| Category | Change |
|---|---|
| Behavior Change | Default eager loading strategy for HasMany / BelongsToMany switched from select to subquery |
| Behavior Change | afterSaveCommit and afterDeleteCommit now fire inside outer transactions |
| New Feature | New Collection methods: keys(), values(), implode(), when(), unless() |
| New Feature | New query operators: notBetween(), inOrNull(), notInOrNull(), isDistinctFrom(), isNotDistinctFrom(), except(), exceptAll(), stringAgg() |
| New Feature | PostgreSQL index support extended: brin, hash, gin, spgist |
| New Feature | TestCase::mockModel() added for Mockery-based table mocking |
| New Feature | New filesystem utilities for fluent file discovery and cross-platform path handling |
| New Feature | Text::mask() method added |
| Improvement | Commands get $this->io and $this->args set by the framework (preview of 6.0 API) |
| Improvement | Security::encrypt() now supports longer keys with derived encryption + authentication keys |
How Does the New Default Association Strategy Affect Your Queries?
HasMany and BelongsToMany associations now default to subquery strategy instead of select. In practice, this produces more correct results when pagination or custom conditions are applied to the parent query -- the old select strategy could silently return wrong counts in those scenarios.
If you need the old behavior, set it explicitly when defining the association:
$this->hasMany('Comments', [
'strategy' => 'select',
]);
Review any association that depends on query result ordering or pagination before upgrading. The subquery strategy is generally safer, but it does add a subquery to the SQL, so benchmark if you have performance-sensitive reads on very large tables.
What Changed About afterSaveCommit and afterDeleteCommit Events?
Previously, Model.afterSaveCommit and Model.afterDeleteCommit were not fired when save() or delete() ran inside an outer transaction. That was a gap -- if you wrapped multiple saves in a transaction and relied on those events for side effects (sending emails, syncing caches), those side effects silently never ran.
In 5.4, those events fire correctly when the outer transaction commits. If you have code that deliberately worked around this by triggering side effects elsewhere, audit that logic -- you may now get duplicate execution.
What New Database Query Operators Does 5.4 Add?
Eight new query-building helpers landed in 5.4, covering gaps that previously required raw SQL fragments:
notBetween($field, $from, $to)-- negated range checkinOrNull($field, $values)-- matches the field when it equals one of the values OR isNULLnotInOrNull($field, $values)-- inverse of the aboveisDistinctFrom($field, $value)-- NULL-safe not-equal (IS DISTINCT FROMin SQL)isNotDistinctFrom($field, $value)-- NULL-safe equal (IS NOT DISTINCT FROMin SQL)except()/exceptAll()-- set subtraction between two SELECT queriesstringAgg($field, $separator)-- aggregate string concatenation, useful for grouped string rollups
The isDistinctFrom family is particularly useful for update-detection logic where NULL != NULL would otherwise break a simple equality check. In practice these methods replace a lot of $query->where(['field IS' => null]) workarounds.
What New Collection Methods Shipped in 5.4?
Five methods were added to the Collection class:
keys()-- returns a collection of the current keysvalues()-- re-indexes the collection discarding existing keysimplode($glue, $path = null)-- joins values into a string, optionally extracting a nested path firstwhen($condition, $callback)-- applies a transformation only when the condition is truthyunless($condition, $callback)-- inverse ofwhen()
when() and unless() are quality-of-life additions for conditional pipeline building -- they let you keep a fluent chain instead of breaking out into an if/else block mid-chain.
$result = collection($data)
->when($applyDiscount, function ($col) {
return $col->map(fn($item) => $item * 0.9);
})
->values()
->toList();
How Is the Command API Changing in 5.4?
Commands now have $this->io and $this->args injected by the framework before execute() is called. This is a forward-compatibility move -- in CakePHP 6.0, execute() will stop receiving $io and $args as parameters entirely.
In 5.4, both styles work, so you can migrate at your own pace. Start using $this->io and $this->args in new commands now to avoid a breaking change when 6.0 lands.
// 5.4+ style (forward-compatible with 6.0)
class MyCommand extends Command
{
public function execute(Arguments $args, ConsoleIo $io): int
{
// $this->io and $this->args are also available
$this->io->out('Hello from ' . $this->args->getArgument('name'));
return static::CODE_SUCCESS;
}
}
What Else Is New -- Testing, Filesystem, Security, and PostgreSQL
Testing: TestCase::mockModel()
TestCase::mockModel() lets you replace a table class with a Mockery mock directly from the test case, without manually wiring up the registry. This is the missing piece for unit-testing code that calls table methods -- you no longer need to set up a full fixture or a real DB connection just to assert that a save was called.
Filesystem Utilities
New fluent filesystem helpers cover two common pain points: discovering files by pattern (glob-style matching without raw glob() calls) and constructing paths that work correctly on both Windows and Unix. In practice this matters for console commands and plugins that manipulate files at runtime.
Security::encrypt() Key Improvements
Security::encrypt() can now derive separate encryption and authentication keys from a single provided key, and supports longer key material. This strengthens the default posture without requiring you to store two keys yourself -- pass a longer key and the derivation happens internally.
Text::mask()
Text::mask($str, $start, $end, $char) masks a substring with a repeated character -- useful for displaying partial credit card numbers, phone numbers, or email addresses in logs or UIs without exposing the full value.
// Mask the middle of a phone number
echo Text::mask('0987654321', 3, 7, '*');
// Output: 098****321
PostgreSQL Index Types
Schema migrations can now define brin, hash, gin, and spgist index types for PostgreSQL tables. Previously these required raw SQL or manual migration steps. gin is particularly useful for full-text search and JSONB columns; brin suits time-series or append-only tables where physical ordering matters.
FAQ
Will switching to subquery strategy break my existing pagination?
In most cases it actually fixes it. The old select strategy could return incorrect paginated results when the parent query had LIMIT applied, because associated records were fetched in a follow-up query that did not respect that limit correctly. If you see different result counts after upgrading, check whether you were relying on the broken behavior. Add 'strategy' => 'select' to the specific association to restore the old behavior while you investigate.
Do I need to update all my Command classes right now because of the $this->io change?
No -- the old signature still works in 5.4. The change is additive: $this->io and $this->args are now available as properties, but execute() still receives them as parameters too. The breaking removal happens in 6.0, so you have the full 5.x lifecycle to migrate. If you write new commands, use the property style from the start.
Does afterSaveCommit now fire multiple times if I nest transactions?
It fires once, when the outermost transaction commits. CakePHP tracks transaction depth and only dispatches commit events after the root transaction completes. The fix in 5.4 is specifically that the event was previously skipped entirely in nested contexts -- it was not a double-fire issue, it was a no-fire issue.
Can I use the new PostgreSQL index types (gin, brin) in existing migrations without rebuilding the table?
Yes -- these are index-only changes. You add them via a new migration that calls addIndex() with the type specified, and PostgreSQL builds the index without rewriting the table. For large tables, CREATE INDEX CONCURRENTLY (which CakePHP migrations do not use by default) is safer for production; consider running those manually outside a migration if downtime is a concern.
Is isDistinctFrom() supported on all databases or just PostgreSQL?
The SQL standard IS DISTINCT FROM operator is natively supported in PostgreSQL and SQLite. For MySQL, CakePHP emulates the behavior using an equivalent expression. You can use isDistinctFrom() in a database-agnostic way and CakePHP handles the translation -- you do not need separate code paths per database.