diff --git a/docs/en/appendices/6-0-migration-guide.md b/docs/en/appendices/6-0-migration-guide.md index 6eda85a3f3..8f08b9bcd0 100644 --- a/docs/en/appendices/6-0-migration-guide.md +++ b/docs/en/appendices/6-0-migration-guide.md @@ -122,6 +122,13 @@ Some properties have also been renamed to better reflect their purpose. These ar ## New Features +### ORM + +- Entities can now map fields to real class properties, including typed + properties and PHP 8.4 asymmetric visibility/property hooks. When changing + declared fields inside entity methods, continue to use `set()`/`patch()` if + you need dirty tracking and related entity bookkeeping. + ### Router - Attribute routing is now available via `RouteBuilder::connectAttributes()` and the diff --git a/docs/en/index.md b/docs/en/index.md index 45116e5c4e..355bce9cd4 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -315,7 +315,7 @@ use Cake\ORM\Entity; class Article extends Entity { - protected array $_accessible = [ + protected array $patchable = [ 'title' => true, 'slug' => true, 'body' => true, @@ -627,7 +627,7 @@ declare(strict_types=1); // ✓ Mass assignment (protected fields) $article = $this->Articles->newEntity($data); -// Only $_accessible fields can be set +// Only patchable fields can be set ``` ```bash [Code Generation] diff --git a/docs/en/orm/behaviors/translate.md b/docs/en/orm/behaviors/translate.md index 37933e97e1..8bccb0100f 100644 --- a/docs/en/orm/behaviors/translate.md +++ b/docs/en/orm/behaviors/translate.md @@ -544,8 +544,8 @@ $this->Articles->save($article); ``` This will result in your article, the french and spanish translations all being -persisted. You'll need to remember to add `_translations` into the -`$_accessible` fields of your entity as well. +persisted. You'll need to remember to add `_translations` to the `patchable` +fields of your entity as well. ### Validating Translated Entities diff --git a/docs/en/orm/entities.md b/docs/en/orm/entities.md index e8ae9bce5c..dccf476e39 100644 --- a/docs/en/orm/entities.md +++ b/docs/en/orm/entities.md @@ -1,6 +1,6 @@ --- title: "Entities" -description: "Manage CakePHP entities: access data, implement accessors/mutators, handle mass assignment, virtual fields, and custom entity logic." +description: "Manage CakePHP entities: access data, use typed properties and property hooks, handle mass assignment, virtual fields, and custom entity logic." --- # Entities @@ -11,6 +11,8 @@ While [Table Objects](../orm/table-objects) represent and provide access to a co objects, entities represent individual rows or domain objects in your application. Entities contain methods to manipulate and access the data they contain. Fields can also be accessed as properties on the object. +In CakePHP 6.0 those fields can be stored as dynamic fields or mapped onto +real class properties in your entity class. Entities are created for you each time you iterate the query instance returned by `find()` of a table object or when you call `all()` or `first()` method @@ -171,6 +173,87 @@ $article->has('links'); // true $article->hasValue('links'); // false ``` +### Declaring Concrete Properties + +In CakePHP 6.0 entity fields can be mapped onto real class properties instead +of being stored only as dynamic fields. This lets you use native PHP types in +your entities while continuing to use CakePHP's entity features such as +`get()`, `set()`, `patch()`, dirty tracking, original values, and mass +assignment. + +```php +namespace App\Model\Entity; + +use Cake\ORM\Entity; + +class User extends Entity +{ + public protected(set) int $id; + public protected(set) string $email; + public protected(set) ?string $first_name; + public protected(set) ?string $last_name; + public protected(set) bool $is_active; +} +``` + +Using `public protected(set)` lets outside code read the property directly +while writes still go through the entity API. If you use `protected` +properties instead, both reads and writes from outside the entity continue to +use CakePHP's magic property handling. + +Properties can also use PHP property hooks: + +```php +namespace App\Model\Entity; + +use Cake\ORM\Entity; + +class User extends Entity +{ + public protected(set) ?string $password { + set (?string $value) { + $this->password = $value === null + ? null + : password_hash($value, PASSWORD_DEFAULT); + } + } +} +``` + +Property `set` hooks are bypassed when the ORM hydrates database rows with +setters disabled, so persisted values are assigned without being transformed a +second time. + +You can also use `get` hooks to define virtual computed properties without +backing storage: + +```php +namespace App\Model\Entity; + +use Cake\ORM\Entity; + +class User extends Entity +{ + public protected(set) string $first_name; + public protected(set) string $last_name; + + public string $full_name { + get => trim($this->first_name . ' ' . $this->last_name); + } +} +``` + +> [!WARNING] +> When changing declared fields from inside entity methods, use `$this->set()` +> or `$this->patch()` instead of assigning `$this->field` directly. Direct +> assignment inside the entity bypasses CakePHP's dirty tracking and original +> value bookkeeping. + +> [!NOTE] +> If a database column name conflicts with a built-in `Entity` property such as +> `patchable`, `dirty`, or `errors`, keep that column as a dynamic field instead +> of declaring it as a concrete property. + If you often partially load entities you should enable strict-property access behavior to ensure you're not using properties that haven't been loaded. On a per-entity basis you can enable this behavior: @@ -195,8 +278,9 @@ Accessors let you customize how fields are read. They use the convention of words are joined together to a single word with the first letter of each word capitalized) of the field name. -They receive the basic value stored in the `_fields` array as their only -argument. For example: +They receive the basic value stored for the field as their only argument, +whether that value comes from a concrete property or a dynamic field. For +example: ```php namespace App\Model\Entity; @@ -402,9 +486,9 @@ into an entity allows the user to modify any and all columns. When using anonymous entity classes or creating the entity class with the [Bake Console](../bake) CakePHP does not protect against mass-assignment. -The `_accessible` property allows you to provide a map of fields and -whether or not they can be mass-assigned. The values `true` and `false` -indicate whether a field can or cannot be mass-assigned: +The `patchable` property allows you to provide a map of fields and whether or +not they can be mass-assigned. The values `true` and `false` indicate whether +a field can or cannot be mass-assigned: ```php namespace App\Model\Entity; @@ -413,7 +497,7 @@ use Cake\ORM\Entity; class Article extends Entity { - protected array $_accessible = [ + protected array $patchable = [ 'title' => true, 'body' => true, ]; @@ -430,7 +514,7 @@ use Cake\ORM\Entity; class Article extends Entity { - protected array $_accessible = [ + protected array $patchable = [ 'title' => true, 'body' => true, '*' => false, @@ -452,26 +536,25 @@ use App\Model\Entity\Article; $article = new Article(['id' => 1, 'title' => 'Foo'], ['guard' => false]); ``` -### Modifying the Guarded Fields at Runtime +### Modifying the Patchable Fields at Runtime -You can modify the list of guarded fields at runtime using the `setAccess()` -method: +You can modify the list of patchable fields at runtime using the +`setPatchable()` method: ```php -// Make user_id accessible. -$article->setAccess('user_id', true); +// Make user_id patchable. +$article->setPatchable('user_id', true); // Make title guarded. -$article->setAccess('title', false); +$article->setPatchable('title', false); ``` > [!NOTE] -> Modifying accessible fields affects only the instance the method is called -> on. +> Modifying patchable fields affects only the instance the method is called on. When using the `newEntity()` and `patchEntity()` methods in the `Table` objects you can customize mass assignment protection with options. Please refer -to the [Changing Accessible Fields](../orm/saving-data#changing-accessible-fields) section for more information. +to the [Changing Patchable Fields](../orm/saving-data#changing-patchable-fields) section for more information. ### Bypassing Field Guarding @@ -482,8 +565,8 @@ fields: $article->patch($fields, ['guard' => false]); ``` -By setting the `guard` option to `false`, you can ignore the accessible -field list for a single call to `patch()`. +By setting the `guard` option to `false`, you can ignore the patchable field +list for a single call to `patch()`. ### Checking if an Entity was Persisted @@ -618,7 +701,7 @@ use Cake\ORM\Entity; class User extends Entity { - protected array $_virtual = ['full_name']; + protected array $virtual = ['full_name']; } ``` @@ -642,7 +725,7 @@ use Cake\ORM\Entity; class User extends Entity { - protected array $_hidden = ['password']; + protected array $hidden = ['password']; } ``` diff --git a/docs/en/orm/retrieving-data-and-resultsets.md b/docs/en/orm/retrieving-data-and-resultsets.md index 17b82233d7..fd6e9dd983 100644 --- a/docs/en/orm/retrieving-data-and-resultsets.md +++ b/docs/en/orm/retrieving-data-and-resultsets.md @@ -341,10 +341,10 @@ list finds. ```php // In your Authors Entity create a virtual field to be used as the displayField: -protected function _getLabel() +protected function _getLabel(): string { - return $this->_fields['first_name'] . ' ' . $this->_fields['last_name'] - . ' / ' . __('User ID %s', $this->_fields['user_id']); + return $this->first_name . ' ' . $this->last_name + . ' / ' . __('User ID %s', $this->user_id); } ``` diff --git a/docs/en/orm/saving-data.md b/docs/en/orm/saving-data.md index 82c47801f1..5a5601aa91 100644 --- a/docs/en/orm/saving-data.md +++ b/docs/en/orm/saving-data.md @@ -144,9 +144,9 @@ $entity = $articles->newEntity($this->request->getData()); ``` > [!NOTE] -> If you are using newEntity() and the resulting entities are missing some or -> all the data they were passed, double check that the columns you want to -> set are listed in the `$_accessible` property of your entity. See [Entities Mass Assignment](../orm/entities#entities-mass-assignment). +> If you are using `newEntity()` and the resulting entities are missing some or +> all the data they were passed, double check that the columns you want to set +> are listed in the `patchable` property of your entity. See [Entities Mass Assignment](../orm/entities#entities-mass-assignment). The request data should follow the structure of your entities. For example if you have an article, which belonged to a user, and had many comments, your @@ -430,12 +430,12 @@ $articles->saveMany($entities); $articles->saveManyOrFail($entities); ``` -### Changing Accessible Fields +### Changing Patchable Fields -It's also possible to allow `newEntity()` to write into non accessible fields. -For example, `id` is usually absent from the `_accessible` property. In -such case, you can use the `accessibleFields` option. It could be useful to -keep ids of associated entities: +It's also possible to allow `newEntity()` to write into non-patchable fields. +For example, `id` is usually absent from the `patchable` property. In that +case, you can use the `patchableFields` option. It can be useful to keep ids +of associated entities: ```php // In a controller @@ -446,7 +446,7 @@ $entity = $articles->newEntity($this->request->getData(), [ 'Tags', 'Comments' => [ 'associated' => [ 'Users' => [ - 'accessibleFields' => ['id' => true], + 'patchableFields' => ['id' => true], ], ], ], @@ -458,9 +458,9 @@ The above will keep the association unchanged between Comments and Users for the concerned entity. > [!NOTE] -> If you are using newEntity() and the resulting entities are missing some or -> all the data they were passed, double check that the columns you want to -> set are listed in the `$_accessible` property of your entity. See +> If you are using `newEntity()` and the resulting entities are missing some or +> all the data they were passed, double check that the columns you want to set +> are listed in the `patchable` property of your entity. See > [Entities Mass Assignment](../orm/entities#entities-mass-assignment). ### Merging Request Data Into Entities @@ -553,14 +553,14 @@ The same can be said about hasMany and belongsToMany associations, with an important caveat: > [!NOTE] -> For belongsToMany associations, ensure the relevant entity has -> a property accessible for the associated entity. +> For belongsToMany associations, ensure the relevant entity has a patchable +> property for the associated entity. If a Product belongsToMany Tag: ```php // in the Product Entity -protected array $_accessible = [ +protected array $patchable = [ // .. other properties 'tags' => true, ]; diff --git a/docs/en/tutorials-and-examples/cms/articles-model.md b/docs/en/tutorials-and-examples/cms/articles-model.md index a75d28278c..509b08961e 100644 --- a/docs/en/tutorials-and-examples/cms/articles-model.md +++ b/docs/en/tutorials-and-examples/cms/articles-model.md @@ -65,7 +65,7 @@ use Cake\ORM\Entity; class Article extends Entity { - protected array $_accessible = [ + protected array $patchable = [ 'user_id' => true, 'title' => true, 'slug' => true, @@ -79,7 +79,7 @@ class Article extends Entity } ``` -Right now, our entity is quite slim; we've only set up the `_accessible` +Right now, our entity is quite slim; we've only set up the `patchable` property, which controls how properties can be modified by [Entities Mass Assignment](../../orm/entities#entities-mass-assignment). diff --git a/docs/en/tutorials-and-examples/cms/authorization.md b/docs/en/tutorials-and-examples/cms/authorization.md index ce48383fef..43c44ddade 100644 --- a/docs/en/tutorials-and-examples/cms/authorization.md +++ b/docs/en/tutorials-and-examples/cms/authorization.md @@ -249,7 +249,7 @@ public function edit($slug) if ($this->request->is(['post', 'put'])) { $this->Articles->patchEntity($article, $this->request->getData(), [ // Added: Disable modification of user_id. - 'accessibleFields' => ['user_id' => false], + 'patchableFields' => ['user_id' => false], ]); if ($this->Articles->save($article)) { $this->Flash->success(__('Your article has been updated.')); @@ -264,7 +264,7 @@ public function edit($slug) ``` Here we're modifying which properties can be mass-assigned, via the options -for `patchEntity()`. See the [Changing Accessible Fields](../../orm/saving-data#changing-accessible-fields) section for +for `patchEntity()`. See the [Changing Patchable Fields](../../orm/saving-data#changing-patchable-fields) section for more information. Remember to remove the `user_id` control from **templates/Articles/edit.php** as we no longer need it. diff --git a/docs/en/tutorials-and-examples/cms/tags-and-users.md b/docs/en/tutorials-and-examples/cms/tags-and-users.md index 24c8c53831..e0dbcfce4a 100644 --- a/docs/en/tutorials-and-examples/cms/tags-and-users.md +++ b/docs/en/tutorials-and-examples/cms/tags-and-users.md @@ -351,16 +351,16 @@ can add a virtual/computed field to the entity. In // the Collection class use Cake\Collection\Collection; -// Update the accessible property to contain `tag_string` -protected array $_accessible = [ +// Update the patchable property to contain `tag_string` +protected array $patchable = [ //other fields... 'tag_string' => true, ]; -protected function _getTagString(): string +protected function _getTagString(?string $tagString): string { - if (isset($this->_fields['tag_string'])) { - return $this->_fields['tag_string']; + if ($tagString !== null) { + return $tagString; } if (!$this->tags) { return ''; @@ -418,10 +418,10 @@ public function view($slug = null) ### Persisting the Tag String Now that we can view existing tags as a string, we'll want to save that data as -well. Because we marked the `tag_string` as accessible, the ORM will copy that -data from the request into our entity. We can use a `beforeSave()` hook method -to parse the tag string and find/build the related entities. Add the following -to **src/Model/Table/ArticlesTable.php**: +well. Because we marked `tag_string` as patchable, the ORM will copy that data +from the request into our entity. We can use a `beforeSave()` hook method to +parse the tag string and find/build the related entities. Add the following to +**src/Model/Table/ArticlesTable.php**: ```php public function beforeSave(EventInterface $event, $entity, $options): void diff --git a/docs/en/views/helpers/form.md b/docs/en/views/helpers/form.md index bb04a8c5c9..2203002ccc 100644 --- a/docs/en/views/helpers/form.md +++ b/docs/en/views/helpers/form.md @@ -1662,7 +1662,7 @@ echo $this->Form->file('submittedfile'); > is displayed, the value inside will be empty. To prevent the `submittedfile` from being over-written as blank, remove it -from `$_accessible`. Alternatively, you can unset the index by using +from `patchable`. Alternatively, you can unset the index by using `beforeMarshal`: ```php