Skip to content

Commit 5a61c1f

Browse files
authored
Merge pull request #8254 from cakephp/docs-5.4-features
Document CakePHP 5.4 features
2 parents 1b6d702 + aa6060f commit 5a61c1f

File tree

6 files changed

+321
-32
lines changed

6 files changed

+321
-32
lines changed

docs/en/appendices/5-4-migration-guide.md

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,49 +18,55 @@ bin/cake upgrade rector --rules cakephp54 <path/to/app/src>
1818

1919
### I18n
2020

21-
``Number::parseFloat()`` now returns ``null`` instead of ``0.0`` when parsing
22-
fails. Previously, when ``NumberFormatter::parse()`` failed it returned ``false``,
23-
which was cast to ``0.0``. This silently converted invalid input like ``"abc"``
24-
to ``0.0``, making it impossible to distinguish from valid ``"0"`` input.
25-
26-
This also affects ``FloatType`` and ``DecimalType`` database types which use
27-
``Number::parseFloat()`` internally. Invalid locale-formatted form input will
28-
now result in ``null`` entity values instead of ``0``.
21+
`Number::parseFloat()` now returns `null` instead of `0.0` when parsing
22+
fails. This also affects `FloatType` and `DecimalType` database types.
2923

3024
### ORM
3125

3226
The default eager loading strategy for `HasMany` and `BelongsToMany` associations
33-
has changed from ``select`` to ``subquery``. The ``subquery`` strategy performs
34-
better for larger datasets as it avoids packet size limits from large ``WHERE IN``
35-
clauses and reduces PHP memory usage by keeping IDs in the database.
36-
37-
If you need the previous behavior, you can explicitly set the strategy when
38-
defining associations:
39-
40-
```php
41-
$this->hasMany('Comments', [
42-
'strategy' => 'select',
43-
]);
44-
```
27+
has changed from `select` to `subquery`. If you need the previous behavior,
28+
explicitly set `'strategy' => 'select'` when defining associations.
4529

4630
## Deprecations
4731

4832
- WIP
4933

5034
## New Features
5135

36+
### Controller
37+
38+
- Added `#[RequestToDto]` attribute for automatic mapping of request data to
39+
Data Transfer Objects in controller actions.
40+
See [Request to DTO Mapping](../development/dependency-injection#request-to-dto-mapping).
41+
- Added `unlockActions()` and `unlockFields()` convenience methods to
42+
`FormProtectionComponent`.
43+
See [Form Protection Component](../controllers/components/form-protection).
44+
45+
### Database
46+
47+
- Added `notBetween()` method for `NOT BETWEEN` expressions.
48+
See [Query Builder](../orm/query-builder#advanced-conditions).
49+
- Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`.
50+
- Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons.
51+
5252
### I18n
5353

54-
- `Number::toReadableSize()` now calculates decimal units (KB, MB, GB and TB)
55-
using an exponent of ten, meaning that 1 KB is 1000 Bytes. The units from the
56-
previous calculation method, where 1024 Bytes equaled 1 KB, have been changed
57-
to KiB, MiB, GiB, and TiB as defined in ISO/IEC 80000-13. It is possible to
58-
switch between the two units using a new optional boolean parameter in
59-
`Number::toReadableSize()`, as well as the new global setter `Number::setUseIecUnits()`.
54+
- `Number::toReadableSize()` now uses decimal units (KB = 1000 bytes) by default.
55+
Binary units (KiB = 1024 bytes) can be enabled via parameter or `Number::setUseIecUnits()`.
56+
57+
### ORM
58+
59+
- The `associated` option in `newEntity()` and `patchEntity()` now supports
60+
nested array format matching `contain()` syntax.
61+
See [Converting Request Data into Entities](../orm/saving-data#converting-request-data-into-entities).
6062

6163
### Utility
6264

63-
- New `Cake\Utility\Fs\Finder` class provides a fluent, iterator-based API for
64-
discovering files and directories with support for pattern matching, depth
65-
control, and custom filters. The `Cake\Utility\Fs\Path` class offers
66-
cross-platform utilities for path manipulation.
65+
- Added `Cake\Utility\Fs\Finder` class for fluent file discovery with pattern matching,
66+
depth control, and custom filters. Added `Cake\Utility\Fs\Path` for cross-platform
67+
path manipulation.
68+
69+
### View
70+
71+
- Added `{{inputId}}` template variable to `inputContainer` and `error` templates
72+
in FormHelper. See [Built-in Template Variables](../views/helpers/form#built-in-template-variables).

docs/en/controllers/components/form-protection.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,51 @@ class WidgetController extends AppController
140140

141141
This example would disable all security checks for the edit action.
142142

143+
You can also use the convenience method ``unlockActions()``:
144+
145+
```php
146+
public function beforeFilter(EventInterface $event): void
147+
{
148+
parent::beforeFilter($event);
149+
150+
// Unlock a single action
151+
$this->FormProtection->unlockActions('edit');
152+
153+
// Unlock multiple actions
154+
$this->FormProtection->unlockActions(['edit', 'api', 'webhook']);
155+
156+
// Replace existing unlocked actions instead of merging
157+
$this->FormProtection->unlockActions(['newAction'], merge: false);
158+
}
159+
```
160+
161+
::: info Added in version 5.4.0
162+
:::
163+
164+
## Unlocking fields
165+
166+
To unlock specific fields from validation, you can use the ``unlockFields()``
167+
convenience method:
168+
169+
```php
170+
public function beforeFilter(EventInterface $event): void
171+
{
172+
parent::beforeFilter($event);
173+
174+
// Unlock a single field
175+
$this->FormProtection->unlockFields('dynamic_field');
176+
177+
// Unlock multiple fields
178+
$this->FormProtection->unlockFields(['optional_field', 'ajax_field']);
179+
180+
// Dot notation for nested fields
181+
$this->FormProtection->unlockFields('user.preferences');
182+
}
183+
```
184+
185+
::: info Added in version 5.4.0
186+
:::
187+
143188
## Handling validation failure through callbacks
144189

145190
If form protection validation fails it will result in a 400 error by default.

docs/en/development/dependency-injection.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,106 @@ database. Because this service is injected into our controller, we can easily
425425
swap the implementation out with a mock object or a dummy sub-class when
426426
testing.
427427

428+
## Request to DTO Mapping
429+
430+
CakePHP supports automatic mapping of request data to Data Transfer Objects (DTOs)
431+
using the `#[RequestToDto]` attribute. This provides a clean, type-safe way to
432+
handle form data in controller actions:
433+
434+
```php
435+
use Cake\Controller\Attribute\RequestToDto;
436+
437+
class UsersController extends AppController
438+
{
439+
public function create(#[RequestToDto] UserCreateDto $dto): void
440+
{
441+
// $dto is automatically populated from request data
442+
$user = $this->Users->newEntity([
443+
'email' => $dto->email,
444+
'name' => $dto->name,
445+
]);
446+
447+
if ($this->Users->save($user)) {
448+
$this->Flash->success('User created');
449+
return $this->redirect(['action' => 'index']);
450+
}
451+
}
452+
}
453+
```
454+
455+
Your DTO class must implement a static `createFromArray()` method:
456+
457+
```php
458+
namespace App\Dto;
459+
460+
class UserCreateDto
461+
{
462+
public function __construct(
463+
public string $email,
464+
public string $name,
465+
public ?string $phone = null,
466+
) {
467+
}
468+
469+
public static function createFromArray(array $data): self
470+
{
471+
return new self(
472+
email: $data['email'] ?? '',
473+
name: $data['name'] ?? '',
474+
phone: $data['phone'] ?? null,
475+
);
476+
}
477+
}
478+
```
479+
480+
### Configuring the Data Source
481+
482+
By default, the attribute auto-detects the data source based on the request method
483+
(query params for GET, body data for POST/PUT/PATCH). You can explicitly configure
484+
the source using the `RequestToDtoSource` enum:
485+
486+
```php
487+
use Cake\Controller\Attribute\RequestToDto;
488+
use Cake\Controller\Attribute\Enum\RequestToDtoSource;
489+
490+
class ArticlesController extends AppController
491+
{
492+
// Use query string parameters
493+
public function search(
494+
#[RequestToDto(source: RequestToDtoSource::Query)] SearchCriteriaDto $criteria
495+
): void {
496+
$articles = $this->Articles->find()
497+
->where(['title LIKE' => "%{$criteria->query}%"])
498+
->limit($criteria->limit);
499+
}
500+
501+
// Use POST body data explicitly
502+
public function create(
503+
#[RequestToDto(source: RequestToDtoSource::Body)] ArticleCreateDto $dto
504+
): void {
505+
// ...
506+
}
507+
508+
// Merge query params and body data (body takes precedence)
509+
public function update(
510+
int $id,
511+
#[RequestToDto(source: RequestToDtoSource::Request)] ArticleUpdateDto $dto
512+
): void {
513+
// ...
514+
}
515+
}
516+
```
517+
518+
The available source options are:
519+
520+
- `RequestToDtoSource::Auto` - Auto-detect based on request method (default)
521+
- `RequestToDtoSource::Query` - Use query string parameters
522+
- `RequestToDtoSource::Body` - Use POST/PUT body data
523+
- `RequestToDtoSource::Request` - Merge query params and body data
524+
525+
::: info Added in version 5.4.0
526+
:::
527+
428528
## Command Example
429529

430530
```php

docs/en/orm/query-builder.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,29 @@ conditions:
12481248
# WHERE country_id NOT IN ('AFG', 'USA', 'EST')
12491249
```
12501250

1251+
- `inOrNull()` Create a condition for `IN` combined with `IS NULL`:
1252+
1253+
```php
1254+
$query = $cities->find()
1255+
->where(function (QueryExpression $exp, SelectQuery $q) {
1256+
return $exp->inOrNull('country_id', ['AFG', 'USA', 'EST']);
1257+
});
1258+
# WHERE (country_id IN ('AFG', 'USA', 'EST') OR country_id IS NULL)
1259+
```
1260+
1261+
::: info Added in version 5.4.0
1262+
:::
1263+
1264+
- `notInOrNull()` Create a condition for `NOT IN` combined with `IS NULL`:
1265+
1266+
```php
1267+
$query = $cities->find()
1268+
->where(function (QueryExpression $exp, SelectQuery $q) {
1269+
return $exp->notInOrNull('country_id', ['AFG', 'USA', 'EST']);
1270+
});
1271+
# WHERE (country_id NOT IN ('AFG', 'USA', 'EST') OR country_id IS NULL)
1272+
```
1273+
12511274
- `gt()` Create a `>` condition:
12521275

12531276
```php
@@ -1318,6 +1341,19 @@ conditions:
13181341
# WHERE population BETWEEN 999 AND 5000000,
13191342
```
13201343

1344+
- `notBetween()` Create a `NOT BETWEEN` condition:
1345+
1346+
```php
1347+
$query = $cities->find()
1348+
->where(function (QueryExpression $exp, SelectQuery $q) {
1349+
return $exp->notBetween('population', 999, 5000000);
1350+
});
1351+
# WHERE population NOT BETWEEN 999 AND 5000000
1352+
```
1353+
1354+
::: info Added in version 5.4.0
1355+
:::
1356+
13211357
- `exists()` Create a condition using `EXISTS`:
13221358

13231359
```php
@@ -1352,6 +1388,43 @@ conditions:
13521388
# WHERE NOT EXISTS (SELECT id FROM cities WHERE countries.id = cities.country_id AND population > 5000000)
13531389
```
13541390

1391+
- `isDistinctFrom()` Create a null-safe inequality comparison using `IS DISTINCT FROM`:
1392+
1393+
```php
1394+
$query = $cities->find()
1395+
->where(function (QueryExpression $exp, SelectQuery $q) {
1396+
return $exp->isDistinctFrom('status', 'active');
1397+
});
1398+
# WHERE status IS DISTINCT FROM 'active'
1399+
# MySQL uses: NOT (status <=> 'active')
1400+
```
1401+
1402+
This is useful when you need to compare values where `NULL` should be treated
1403+
as a distinct value. Unlike regular `!=` comparisons, `IS DISTINCT FROM`
1404+
returns `TRUE` when comparing `NULL` to a non-NULL value, and `FALSE` when
1405+
comparing `NULL` to `NULL`.
1406+
1407+
::: info Added in version 5.4.0
1408+
:::
1409+
1410+
- `isNotDistinctFrom()` Create a null-safe equality comparison using `IS NOT DISTINCT FROM`:
1411+
1412+
```php
1413+
$query = $cities->find()
1414+
->where(function (QueryExpression $exp, SelectQuery $q) {
1415+
return $exp->isNotDistinctFrom('category_id', null);
1416+
});
1417+
# WHERE category_id IS NOT DISTINCT FROM NULL
1418+
# MySQL uses: category_id <=> NULL
1419+
```
1420+
1421+
This is the null-safe equivalent of `=`. It returns `TRUE` when both values
1422+
are `NULL` (unlike regular `=` which returns `NULL`), making it useful for
1423+
comparing nullable columns.
1424+
1425+
::: info Added in version 5.4.0
1426+
:::
1427+
13551428
Expression objects should cover many commonly used functions and expressions. If
13561429
you find yourself unable to create the required conditions with expressions you
13571430
can may be able to use `bind()` to manually bind parameters into conditions:

docs/en/orm/saving-data.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,47 @@ $articles = $this->fetchTable('Articles');
187187
$entity = $articles->newEntity($this->request->getData(), [
188188
'associated' => [
189189
'Tags', 'Comments' => ['associated' => ['Users']],
190-
]
190+
],
191191
]);
192192
```
193193

194194
The above indicates that the 'Tags', 'Comments' and 'Users' for the Comments
195-
should be marshalled. Alternatively, you can use dot notation for brevity:
195+
should be marshalled.
196+
197+
You can also use a nested array format similar to ``contain()``:
198+
199+
```php
200+
// Nested arrays (same format as contain())
201+
$entity = $articles->newEntity($this->request->getData(), [
202+
'associated' => [
203+
'Tags',
204+
'Comments' => [
205+
'Users',
206+
'Attachments',
207+
],
208+
],
209+
]);
210+
211+
// Mixed with options
212+
$entity = $articles->newEntity($this->request->getData(), [
213+
'associated' => [
214+
'Tags' => ['onlyIds' => true],
215+
'Comments' => [
216+
'Users',
217+
'validate' => 'special',
218+
],
219+
],
220+
]);
221+
```
222+
223+
CakePHP distinguishes associations from options using naming conventions:
224+
association names use PascalCase (e.g., ``Users``), while option keys use
225+
camelCase (e.g., ``onlyIds``).
226+
227+
::: info Added in version 5.4.0
228+
:::
229+
230+
Alternatively, you can use dot notation for brevity:
196231

197232
```php
198233
// In a controller

0 commit comments

Comments
 (0)