diff --git a/.vitepress/config.js b/.vitepress/config.js index b26cb36475..3b4e47b027 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -19,6 +19,32 @@ const versions = { ], }; +const plugins = { + text: "Plugins", + items: [ + { + text: "Core Plugins", + items: [ + { text: "Authentication", link: "https://book.cakephp.org/authentication/", target: "_self" }, + { text: "Authorization", link: "https://book.cakephp.org/authorization/", target: "_self" }, + { text: "Bake", link: "https://book.cakephp.org/bake/", target: "_self" }, + { text: "Chronos", link: "https://book.cakephp.org/chronos/", target: "_self" }, + { text: "Debug Kit", link: "https://book.cakephp.org/debugkit/", target: "_self" }, + { text: "Elasticsearch", link: "https://book.cakephp.org/elasticsearch/", target: "_self" }, + { text: "Migrations", link: "https://book.cakephp.org/migrations/", target: "_self" }, + { text: "Phinx", link: "https://book.cakephp.org/phinx/", target: "_self" }, + { text: "Queue", link: "https://book.cakephp.org/queue/", target: "_self" }, + ], + }, + { + text: "Community", + items: [ + { text: "Community Plugins", link: "https://plugins.cakephp.org/", target: "_self" }, + ], + }, + ], +}; + const substitutions = { '|phpversion|': { value: '8.5', format: 'bold' }, '|minphpversion|': { value: '8.4', format: 'italic' }, @@ -53,7 +79,7 @@ export default { nav: [ { text: "Guide", link: "/intro" }, { text: "API", link: "https://api.cakephp.org/" }, - { text: "Documentation", link: "/" }, + { ...plugins }, { ...versions }, ], versionBanner: { @@ -75,6 +101,7 @@ export default { { text: "ガイド", link: "/ja/intro" }, { text: "API", link: "https://api.cakephp.org/" }, { text: "ドキュメント", link: "/ja/" }, + { ...plugins }, { ...versions }, ], sidebar: toc_ja, diff --git a/.vitepress/toc_en.json b/.vitepress/toc_en.json index a68016425f..5359622ae9 100644 --- a/.vitepress/toc_en.json +++ b/.vitepress/toc_en.json @@ -56,6 +56,24 @@ ] } ] + }, + { + "text": "Migration Guides", + "collapsed": true, + "items": [ + { + "text": "Upgrading CakePHP", + "link": "/appendices/migration-guides" + }, + { + "text": "6.0 Upgrade Guide", + "link": "/appendices/6-0-upgrade-guide" + }, + { + "text": "6.0 Migration Guide", + "link": "/appendices/6-0-migration-guide" + } + ] } ] }, @@ -398,24 +416,11 @@ "text": "Appendices", "items": [ { "text": "Appendices", "link": "/appendices" }, - { - "text": "Migration Guides", - "link": "/appendices/migration-guides" - }, - { - "text": "6.0 Migration Guide", - "link": "/appendices/6-0-migration-guide" - }, - { - "text": "6.0 Upgrade Guide", - "link": "/appendices/6-0-upgrade-guide" - }, { "text": "CakePHP Development Process", "link": "/appendices/cakephp-development-process" }, - { "text": "Glossary", "link": "/appendices/glossary" }, - { "text": "PHPUnit Upgrade", "link": "/appendices/phpunit-upgrade" } + { "text": "Glossary", "link": "/appendices/glossary" } ] } ] diff --git a/.vitepress/toc_ja.json b/.vitepress/toc_ja.json index ca3bb0bd70..ad614b8e84 100644 --- a/.vitepress/toc_ja.json +++ b/.vitepress/toc_ja.json @@ -394,29 +394,12 @@ "text": "依存性注入", "link": "/ja/development/dependency-injection" }, + { "text": "プラグイン", "link": "/ja/plugins" }, { "text": "エラー", "link": "/ja/development/errors" }, { "text": "REST", "link": "/ja/development/rest" }, { "text": "テスト", "link": "/ja/development/testing" } ] }, - { - "text": "プラグイン", - "collapsed": true, - "items": [ - { "text": "プラグインの作成", "link": "/ja/plugins" }, - { - "text": "コアプラグイン", - "items": [ - { "text": "Bake", "link": "https://book.cakephp.org/bake/2/en/index.html" }, - { "text": "Chronos", "link": "https://book.cakephp.org/chronos/" }, - { "text": "Debug Kit", "link": "https://book.cakephp.org/debugkit/" }, - { "text": "Elasticsearch", "link": "https://book.cakephp.org/elasticsearch/" }, - { "text": "マイグレーション", "link": "https://book.cakephp.org/migrations/" }, - { "text": "Phinx", "link": "https://book.cakephp.org/phinx/" } - ] - } - ] - }, { "text": "セキュリティ", "collapsed": true, @@ -479,14 +462,14 @@ "text": "移行ガイド", "link": "/ja/appendices/migration-guides" }, - { - "text": "5.0 移行ガイド", - "link": "/ja/appendices/5-0-migration-guide" - }, { "text": "5.0 アップグレードガイド", "link": "/ja/appendices/5-0-upgrade-guide" }, + { + "text": "5.0 移行ガイド", + "link": "/ja/appendices/5-0-migration-guide" + }, { "text": "5.1 移行ガイド", "link": "/ja/appendices/5-1-migration-guide" diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index 2fd657d026..7f23cfc5c2 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -20,6 +20,42 @@ bin/cake upgrade rector --rules cakephp54 - `BaseCommand::initialize()` is now being triggered **AFTER** arguments and options have been parsed. +### Console + +Running `bin/cake` without providing a command name no longer displays the +"No command provided" error message. Instead, the `help` command is shown +directly. + +Unknown tokens after a parent command with subcommands are now rejected +(e.g. `bin/cake i18n nonsense`) instead of silently invoking the parent. +See [Subcommand Validation](../console-commands/commands#subcommand-validation). + +The `help` command is now hidden from command listings (via +`CommandHiddenInterface`). It remains accessible by running `bin/cake help` or +`bin/cake help `. + +The CakePHP version header in help output is now only shown when the CakePHP +version can be determined. When used outside a CakePHP application (where the +version is reported as `unknown`), the header is omitted. + +### Events + +Events being registered in either `Application::events()` or `Plugin::events()` +now work in both web and CLI contexts. It is therefore highly recommended to +move your event listeners from the `config/bootstrap.php` file to the +`eventListeners()` method in your `Application` or `Plugin` class. Use the +`events()` method when you need custom registration logic or anonymous +listeners. +See [Application and Plugin Events](../core-libraries/events#registering-event-listeners) for more details. + +`Application::eventListeners()` and `Plugin::eventListeners()` were added to +register event listener classes declaratively. These listeners are resolved +through the application's dependency injection container, so they can use +constructor-injected dependencies. + +`EventAwareApplicationInterface::pluginEvents()` has been deprecated. Plugin +events are now registered while each plugin is bootstrapped. + ### I18n - `Number::parseFloat()` now returns `null` instead of `0.0` when parsing @@ -30,12 +66,32 @@ bin/cake upgrade rector --rules cakephp54 - The default eager loading strategy for `HasMany` and `BelongsToMany` associations has changed from `select` to `subquery`. If you need the previous behavior, explicitly set `'strategy' => 'select'` when defining associations. + See [Associations](../orm/associations#has-many-associations) for more details. +- `Model.afterSaveCommit` and `Model.afterDeleteCommit` events are now fired + when `save()` or `delete()` is called inside an outer transaction. Previously, + these events were silently suppressed. They are now deferred until the + outermost transaction commits, and discarded on rollback. + See [Table Objects](../orm/table-objects#aftersavecommit) for more details. +- Table methods `save()`, `delete()`, `patchEntity()`, `patchEntities()` and `loadInto()` + will now throw an exception if the entity being passed down does not belong to the table instance. + This will prevent accidental data corruption or deleted records. If you don't want this new behavior, + you can disable it by calling `$this->disableEntityClassAssertion();` in your `initialize()` method. ### Controller - Loading a component with the same alias as the controller's default table now triggers a warning. See [Component Alias Conflicts](../controllers/components#component-alias-conflicts). +### View + +- `FormHelper` now wraps hidden form blocks (CSRF, FormProtection, + `postLink()` / `postButton()`) with the HTML5 boolean `hidden` attribute + instead of an inline `style="display:none;"`. This makes the default markup + compatible with a strict Content-Security-Policy (no need for + `style-src 'unsafe-inline'`). If you previously selected those wrappers via + CSS (e.g. `div[style="display:none;"]`), switch to `[hidden]` or set the + `hiddenClass` template option to opt out and emit a class instead. + ## Deprecations ### Command Helpers @@ -47,6 +103,13 @@ bin/cake upgrade rector --rules cakephp54 - The `Mailer::$name` property is unused and has been deprecated. +### ORM + +- `SelectQuery::disableHydration()` has been deprecated. Use + [`Table::unhydratedFind()`](../orm/retrieving-data-and-resultsets#getting-arrays-instead-of-entities) + instead, which returns an `UnhydratedSelectQuery` whose static type matches the + array result shape. `disableHydration()` will be removed in 6.0. + ## New Features ### Core @@ -56,6 +119,14 @@ bin/cake upgrade rector --rules cakephp54 - A backwards compatible Container implementation has been added to the core. You can opt-in to use it instead of the current `league/container` implementation by setting `App.container` to `cake` inside your `config/app.php`. See [Dependency Injection Container](../development/dependency-injection) for more details. +- Added the `Cake\Lock\Lock` facade with pluggable lock engines for Redis, + Memcached, local files, and testing/no-op usage. See [Locking](../core-libraries/locking). + +### Collection + +- Added [`keys()`](../core-libraries/collections#keys) and [`values()`](../core-libraries/collections#values) methods for extracting keys or re-indexing values. +- Added [`implode()`](../core-libraries/collections#implode) method to concatenate elements into a string. +- Added [`when()`](../core-libraries/collections#when) and [`unless()`](../core-libraries/collections#unless) methods for conditional method chaining. ### Commands @@ -63,6 +134,12 @@ bin/cake upgrade rector --rules cakephp54 without needing to pass them down from the `execute()` method. **This will be the default in CakePHP 6.0** as those arguments will be removed from the `execute()` method signature. +### Console + +- Added `ConsoleHelpHeaderProviderInterface` to allow host applications to + provide a custom header in console help output. + See [Customizing the Help Header](../console-commands/commands#customizing-the-help-header). + ### Controller - Added `#[RequestToDto]` attribute for automatic mapping of request data to @@ -72,29 +149,85 @@ bin/cake upgrade rector --rules cakephp54 `FormProtectionComponent`. See [Form Protection Component](../controllers/components/form-protection). +### Http + +- Added `JsonStreamResponse` class for memory-efficient streaming of large JSON + datasets using generators. Supports standard JSON arrays and NDJSON formats, + envelope structures with metadata, transform callbacks, and graceful mid-stream + error handling. See [Streaming JSON Responses](../controllers/request-response#streaming-json-responses). + ### Database - Added `notBetween()` method for `NOT BETWEEN` expressions. See [Query Builder](../orm/query-builder#advanced-conditions). - Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`. - Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons. +- Added `FunctionsBuilder::stringAgg()` for portable string aggregation. + Translates to `STRING_AGG` or `GROUP_CONCAT` per driver. + See [Query Builder](../orm/query-builder#string-aggregation). +- Added `Connection::afterCommit()` to register callbacks that run after the + outermost transaction commits. Callbacks are discarded on rollback. + See [Database Basics](../orm/database-basics#aftercommit) for more details. +- Added `except()` and `exceptAll()` methods on `SelectQuery` for `EXCEPT` + and `EXCEPT ALL` set operations. `EXCEPT ALL` is supported on PostgreSQL + and recent MySQL/MariaDB versions; it is not supported on SQLite or SQL Server. + See [Query Builder](../orm/query-builder#except). +- Added PostgreSQL index access method reflection. Non-btree indexes (`gin`, + `gist`, `spgist`, `brin`, `hash`) are now reflected with an `accessMethod` + field and regenerated with the correct `USING` clause. The `Index` class + provides constants (`Index::GIN`, `Index::GIST`, `Index::SPGIST`, + `Index::BRIN`, `Index::HASH`) for these access methods. + See [Reading Indexes and Constraints](../orm/schema-system#reading-indexes-and-constraints). +- Added `Cake\Database\Type\EnumLabelTrait` and the + `Cake\Database\Type\Attribute\Label` attribute. The trait provides a default + `label()` implementation backed by the translator and the attribute lets + individual cases override the derived label. See + [EnumLabelTrait and the Label Attribute](../orm/database-basics#enumlabeltrait-and-the-label-attribute). +- Added `LoggedQuery::setRedactor()` to scrub sensitive values from query + logs. Applies to `__toString()`, `getContext()`, and `jsonSerialize()`, + so file logs, structured loggers, and anything that JSON-encodes a + `LoggedQuery` all see the redacted shape. + See [Redacting Sensitive Values from Query Logs](../orm/database-basics#redacting-sensitive-values-from-query-logs). + +### Http + +- Added PSR-13 Link implementation with `Cake\Http\Link\Link` and `Cake\Http\Link\LinkProvider` + classes for hypermedia link support. Links added to responses are automatically emitted + as HTTP `Link` headers. See [Hypermedia Links](../controllers/request-response#hypermedia-links). ### I18n - `Number::toReadableSize()` now uses decimal units (KB = 1000 bytes) by default. Binary units (KiB = 1024 bytes) can be enabled via parameter or `Number::setUseIecUnits()`. +- Added `TranslatorRegistry::setCacheKeyPrefix()` to isolate translator caches + per tenant when a custom loader produces different messages for the same + domain and locale. Accepts a static string or a `Closure` resolved on every + lookup. See [Isolating Translations Per Tenant](../core-libraries/internationalization-and-localization#isolating-translations-per-tenant). +- Added `TranslatorRegistry::clear()` to drop the in-memory translator map + without touching the persistent cacher. Intended for long-running workers + that switch tenants between jobs. +- Added `I18n::setCacheConfig()` to route translator persistence to a Cache + config other than the default `_cake_translations_`. +- The `cake i18n extract` command now also extracts enum labels using the #[Label] attribute. +- Added `PluralRules::setRule()` to register a custom Gettext plural rule for + a locale whose built-in form is missing or differs from the layout used by + your .po/.mo files. See + [Customizing Plural Rules](../core-libraries/internationalization-and-localization#customizing-plural-rules). ### ORM - The `associated` option in `newEntity()` and `patchEntity()` now supports nested array format matching `contain()` syntax. See [Converting Request Data into Entities](../orm/saving-data#converting-request-data-into-entities). +- Added `Table::unhydratedFind()` and the `UnhydratedSelectQuery` class for + type-safe non-hydrated reads. Unlike `find()->disableHydration()`, the + returned query's static type matches its array result shape, so static + analyzers no longer see `entity|array` on `first()`, `all()`, `toArray()` + and iteration. See [Getting Arrays Instead of Entities](../orm/retrieving-data-and-resultsets#getting-arrays-instead-of-entities). -### Http +### Testsuite -- Added PSR-13 Link implementation with `Cake\Http\Link\Link` and `Cake\Http\Link\LinkProvider` - classes for hypermedia link support. Links added to responses are automatically emitted - as HTTP `Link` headers. See [Hypermedia Links](../controllers/request-response#hypermedia-links). +- `TestCase::mockModel()` has been added to allow mocking of model classes in tests using Mockery mocks. ### Utility @@ -103,14 +236,13 @@ bin/cake upgrade rector --rules cakephp54 path manipulation. See [Filesystem Utilities](../core-libraries/filesystem.md). - `Security::encrypt()` can now be configured to use longer keys with separate encryption and authentication keys that are derived from the provided key. You can set `Security.encryptWithRawKey` to enable this behavior. See [here](https://github.com/cakephp/cakephp/pull/19325) for more details. - -### Collection - -- Added [`keys()`](../core-libraries/collections#keys) and [`values()`](../core-libraries/collections#values) methods for extracting keys or re-indexing values. -- Added [`implode()`](../core-libraries/collections#implode) method to concatenate elements into a string. -- Added [`when()`](../core-libraries/collections#when) and [`unless()`](../core-libraries/collections#unless) methods for conditional method chaining. +- Added `Text::mask()` method which masks a portion of a string with a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. +- Added `Text::maskValue()` method which masks all occurrences of given substrings within a string using a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. ### View - Added `{{inputId}}` template variable to `inputContainer` and `error` templates in FormHelper. See [Built-in Template Variables](../views/helpers/form#built-in-template-variables). +- `FormHelper::enumOptions()` is now public. This lets you build `select` + options from a backed enum class even when the form was created without + an entity context. See [Creating Select Pickers](../views/helpers/form#creating-select-pickers). diff --git a/docs/en/appendices/migration-guides.md b/docs/en/appendices/migration-guides.md index cd33d4b30d..e11424bbd3 100644 --- a/docs/en/appendices/migration-guides.md +++ b/docs/en/appendices/migration-guides.md @@ -1,5 +1,5 @@ --- -title: "Migration Guides" +title: "Upgrading CakePHP" description: "Upgrade CakePHP versions: follow migration guides for each release, understand breaking changes, and smoothly transition between versions." --- @@ -31,5 +31,5 @@ bin/cake upgrade rector --rules cakephp60 Run rector before updating your `composer.json` dependencies to ensure the tool can resolve class names correctly. -- [6 0 Upgrade Guide](6-0-upgrade-guide) -- [6 0 Migration Guide](6-0-migration-guide) +- [6.0 Upgrade Guide](6-0-upgrade-guide) +- [6.0 Migration Guide](6-0-migration-guide) diff --git a/docs/en/console-commands/commands.md b/docs/en/console-commands/commands.md index f83215794d..eb463cb843 100644 --- a/docs/en/console-commands/commands.md +++ b/docs/en/console-commands/commands.md @@ -318,6 +318,30 @@ Usage: cake user [-h] [-q] [-v] ``` +## Subcommand Validation + +::: info Added in version 5.4.0 +Strict validation for unknown subcommands was added in 5.4.0. +::: + +When a parent command has registered subcommands (e.g. `i18n extract`, +`i18n init`), CakePHP rejects unknown positional tokens that follow the +parent name. Previously, typos such as `bin/cake i18n nonsense` silently +invoked the parent command and discarded the trailing token; now you get a +clear error listing the available subcommands: + +```text +$ bin/cake i18n nonsense +Error: Unknown command `cake i18n nonsense`. +Available subcommands: `i18n extract`, `i18n init`. +Run `cake i18n --help` to see usage. +``` + +This only kicks in when the parent command has sibling subcommands. Commands +that accept arbitrary positional arguments (e.g. `routes generate`) are +unaffected, and option-like tokens (`--help`, `-v`) following the command +name continue to be forwarded to the parser. + ## Grouping Commands By default, in the help output CakePHP will group commands into core, app, and @@ -360,6 +384,44 @@ public function console(CommandCollection $commands): CommandCollection `CommandCollection::replace()` was added. ::: +## Customizing the Help Header + +By default, `bin/cake help` displays a CakePHP version header at the top of +command listings. When the CakePHP version cannot be determined (e.g. when the +console package is used outside a CakePHP application), the header is omitted +automatically. + +You can replace the default header with your own by implementing +`Cake\Core\ConsoleHelpHeaderProviderInterface` on the application class passed +to `CommandRunner`: + +```php +MyApp: 1.4.0 (env: prod)'; + } +} +``` + +When this interface is implemented, `CommandRunner` passes the return value of +`getConsoleHelpHeader()` to `HelpCommand`, replacing the default CakePHP header. +Console markup tags such as `` and `` are supported in the +returned string. + +::: info Added in version 5.4.0 +`ConsoleHelpHeaderProviderInterface` was added. +::: + ## Tree Output Helper The `TreeHelper` outputs an array as a tree structure. This is useful for diff --git a/docs/en/console-commands/option-parsers.md b/docs/en/console-commands/option-parsers.md index 87d84a4e01..63a618ceb3 100644 --- a/docs/en/console-commands/option-parsers.md +++ b/docs/en/console-commands/option-parsers.md @@ -158,7 +158,7 @@ of the option: - `multiple` - The option can be provided multiple times. The parsed option will be an array of values when this option is enabled. - `separator` - A character sequence that the option value is split into an - array with. + array with. Requires `multiple` set to `true`. - `choices` - An array of valid choices for this option. If left empty all values are valid. An exception will be raised when parse() encounters an invalid value. diff --git a/docs/en/contents.md b/docs/en/contents.md index 38dfa124fa..81d7091c36 100644 --- a/docs/en/contents.md +++ b/docs/en/contents.md @@ -32,6 +32,7 @@ - [Error & Exception Handling](development/errors) - [Events System](core-libraries/events) - [Internationalization & Localization](core-libraries/internationalization-and-localization) +- [Locking](core-libraries/locking) - [Logging](core-libraries/logging) - [Modelless Forms](core-libraries/form) - [Pagination](controllers/pagination) diff --git a/docs/en/controllers/request-response.md b/docs/en/controllers/request-response.md index 9f2e0a64b4..b0403d28a5 100644 --- a/docs/en/controllers/request-response.md +++ b/docs/en/controllers/request-response.md @@ -448,8 +448,7 @@ There are several built-in detectors that you can use: - `is('options')` Check to see whether the current request is OPTIONS. - `is('ajax')` Check to see whether the current request came with X-Requested-With = XMLHttpRequest. -- `is('ssl')` Check to see whether the request is via SSL. -- `is('flash')` Check to see whether the request has a User-Agent of Flash. +- `is('https')` Check to see whether the request is via HTTPS. - `is('json')` Check to see whether the request URL has 'json' extension or the `Accept` header is set to 'application/json'. - `is('xml')` Check to see whether the request URL has 'xml' extension or the `Accept` header is set to @@ -790,6 +789,143 @@ public function sendIcs() } ``` + + +### Streaming JSON Responses + +`class` Cake\\Http\\Response\\**JsonStreamResponse** + +When working with large datasets, loading everything into memory before encoding +to JSON can exhaust available memory. `JsonStreamResponse` provides memory-efficient +streaming of JSON data using generators, keeping only one item in memory at a time. + +::: info Added in version 5.4.0 +::: + +#### Basic Usage + +```php +use Cake\Http\Response\JsonStreamResponse; + +public function index() +{ + $query = $this->Articles->find(); + + // Simple array streaming + return new JsonStreamResponse($query); + // Output: [{"id":1,"title":"First"},{"id":2,"title":"Second"},...] +} +``` + +#### Constructor Options + +The `JsonStreamResponse` constructor accepts an iterable and an options array: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `root` | `string\|null` | `null` | Wrap data in `{"root": [...]}` | +| `envelope` | `array` | `[]` | Static metadata merged with streaming data | +| `dataKey` | `string` | `'data'` | Key for streaming data when envelope is used | +| `format` | `string` | `'json'` | Output format: `'json'` or `'ndjson'` | +| `transform` | `callable\|null` | `null` | Transform each item before encoding | +| `flags` | `int` | `DEFAULT_JSON_FLAGS` | JSON encode flags | + +#### With Root Wrapper + +Wrap the array in an object with a named key: + +```php +return new JsonStreamResponse($query, ['root' => 'articles']); +// Output: {"articles":[{"id":1,"title":"First"},{"id":2,"title":"Second"}]} +``` + +#### With Envelope (Metadata) + +Include static metadata alongside the streaming data: + +```php +$total = $this->Articles->find()->count(); + +return new JsonStreamResponse($query, [ + 'envelope' => ['meta' => ['total' => $total, 'page' => 1]], + 'dataKey' => 'articles', +]); +// Output: {"meta":{"total":100,"page":1},"articles":[{"id":1,"title":"First"},...]} +``` + +#### NDJSON Format + +[NDJSON](http://ndjson.org/) (Newline Delimited JSON) outputs one JSON object per +line, useful for streaming to clients that process data incrementally: + +```php +return new JsonStreamResponse($query, ['format' => 'ndjson']); +// Output: +// {"id":1,"title":"First"} +// {"id":2,"title":"Second"} +``` + +The content type is automatically set to `application/x-ndjson; charset=UTF-8`. + +#### Transform Callback + +Transform each item before JSON encoding. Useful for selecting specific fields +or formatting data: + +```php +return new JsonStreamResponse($query, [ + 'transform' => fn($article) => [ + 'id' => $article->id, + 'title' => $article->title, + 'url' => Router::url(['action' => 'view', $article->id]), + ], +]); +``` + +#### Immutability + +`JsonStreamResponse` follows PSR-7 immutability patterns. Use `withStreamOptions()` +to create a modified copy: + +```php +$response = new JsonStreamResponse($query); +$newResponse = $response->withStreamOptions(['root' => 'articles']); +``` + +#### Error Handling + +`JsonStreamResponse` uses a three-layer error handling strategy: + +1. **Pre-validation**: The first item is encoded before output starts. If encoding + fails, an exception is thrown and a proper error response can be returned. + +2. **Mid-stream error marker**: If item N (where N > 1) fails to encode, an error + marker is output to maintain valid JSON structure: + + ```json + [{"id":1},{"__streamError":{"message":"Type is not supported","index":1}}] + ``` + +3. **Server-side logging**: All encoding failures are logged via `Log::error()`. + +#### ORM Integration + +For true memory-efficient streaming, use unbuffered queries and avoid result +formatters: + +```php +// Good - streams one row at a time +$query = $this->Articles->find()->bufferResults(false); +return new JsonStreamResponse($query); + +// Avoid - formatters like map(), combine() buffer results internally +$query = $this->Articles->find()->map(fn($row) => $row); // Breaks streaming +``` + +> [!NOTE] +> Result formatters (`map()`, `combine()`, etc.) buffer results internally, +> which defeats the memory-efficient streaming purpose. + ### Setting Headers `method` Cake\\Http\\Response::**withHeader**(string $name, string|array $value): static @@ -887,6 +1023,31 @@ $response = $response->withType('application/json') ->withStringBody(json_encode(['Foo' => 'bar'])); ``` +::: warning +Setting a string body alone is not enough to send it. If your action neither +returns the response nor disables view rendering, the controller still calls +`render()` and overwrites the body you set. To make the string body take effect, +either return the response from the action: + +```php +public function export() +{ + return $this->response->withStringBody('My Body'); +} +``` + +or set it and disable auto-render: + +```php +public function export() +{ + $this->setResponse($this->response->withStringBody('My Body')); + $this->disableAutoRender(); +} +``` + +::: + `method` Cake\\Http\\Response::**withBody**(StreamInterface $body): static To set the response body, use the `withBody()` method, which is provided by the diff --git a/docs/en/core-libraries/caching.md b/docs/en/core-libraries/caching.md index 2e5c121b65..a648e0031a 100644 --- a/docs/en/core-libraries/caching.md +++ b/docs/en/core-libraries/caching.md @@ -59,7 +59,7 @@ process. Cache engine configurations are defined in **config/app.php**. For optimal performance CakePHP requires two cache engines to be defined. -- `_cake_core_` is used for storing file maps, and parsed results of +- `_cake_translations_` is used for storing file maps, and parsed results of [Internationalization & Localization](../core-libraries/internationalization-and-localization) files. - `_cake_model_`, is used to store schema descriptions for your applications models. @@ -213,6 +213,19 @@ bytecode is never served. > PhpEngine does not support `increment()` or `decrement()`. Use APCu, > Redis, or Memcached for counter-based caching. + + +### ApcuEngine Options + +ApcuEngine does not have any engine specific options. It requires the +[APCu](https://php.net/apcu) extension to be installed and enabled. + +Because APCu stores values in the local webserver process shared memory, it is +best suited for data that can be regenerated and does not need to be shared +between servers. When clearing cache data, ApcuEngine removes entries matching +the cache configuration's `prefix`, so use unique prefixes for each cache +configuration that uses APCu. + ### RedisEngine Options diff --git a/docs/en/core-libraries/email.md b/docs/en/core-libraries/email.md index 8d26de55e8..5aca778ef2 100644 --- a/docs/en/core-libraries/email.md +++ b/docs/en/core-libraries/email.md @@ -103,39 +103,39 @@ Defining delivery profiles allows you to consolidate common email settings into re-usable profiles. Your application can have as many profiles as necessary. The following configuration keys are used: -- `'from'`: Mailer or array of sender. See `Mailer::setFrom()`. -- `'sender'`: Mailer or array of real sender. See `Mailer::setSender()`. -- `'to'`: Mailer or array of destination. See `Mailer::setTo()`. -- `'cc'`: Mailer or array of carbon copy. See `Mailer::setCc()`. -- `'bcc'`: Mailer or array of blind carbon copy. See `Mailer::setBcc()`. -- `'replyTo'`: Mailer or array to reply the e-mail. See `Mailer::setReplyTo()`. -- `'readReceipt'`: Mailer address or an array of addresses to receive the +- `from`: Mailer or array of sender. See `Mailer::setFrom()`. +- `sender`: Mailer or array of real sender. See `Mailer::setSender()`. +- `to`: Mailer or array of destination. See `Mailer::setTo()`. +- `cc`: Mailer or array of carbon copy. See `Mailer::setCc()`. +- `bcc`: Mailer or array of blind carbon copy. See `Mailer::setBcc()`. +- `replyTo`: Mailer or array to reply the e-mail. See `Mailer::setReplyTo()`. +- `readReceipt`: Mailer address or an array of addresses to receive the receipt of read. See `Mailer::setReadReceipt()`. -- `'returnPath'`: Mailer address or an array of addresses to return if have +- `returnPath`: Mailer address or an array of addresses to return if have some error. See `Mailer::setReturnPath()`. -- `'messageId'`: Message ID of e-mail. See `Mailer::setMessageId()`. -- `'subject'`: Subject of the message. See `Mailer::setSubject()`. -- `'message'`: Content of message. Do not set this field if you are using rendered content. -- `'priority'`: Priority of the email as numeric value (usually from 1 to 5 with 1 being the highest). -- `'headers'`: Headers to be included. See `Mailer::setHeaders()`. -- `'viewRenderer'`: If you are using rendered content, set the view classname. +- `messageId`: Message ID of e-mail. See `Mailer::setMessageId()`. +- `subject`: Subject of the message. See `Mailer::setSubject()`. +- `message`: Content of message. Do not set this field if you are using rendered content. +- `priority`: Priority of the email as numeric value (usually from 1 to 5 with 1 being the highest). +- `headers`: Headers to be included. See `Mailer::setHeaders()`. +- `viewRenderer`: If you are using rendered content, set the view classname. See `ViewBuilder::setClassName()`. -- `'template'`: If you are using rendered content, set the template name. See +- `template`: If you are using rendered content, set the template name. See `ViewBuilder::setTemplate()`. -- `'theme'`: Theme used when rendering template. See `ViewBuilder::setTheme()`. -- `'layout'`: If you are using rendered content, set the layout to render. See +- `theme`: Theme used when rendering template. See `ViewBuilder::setTheme()`. +- `layout`: If you are using rendered content, set the layout to render. See `ViewBuilder::setTemplate()`. -- `'autoLayout'`: If you want to render a template without layout, set this field to +- `autoLayout`: If you want to render a template without layout, set this field to `false`. See `ViewBuilder::disableAutoLayout()`. -- `'viewVars'`: If you are using rendered content, set the array with +- `viewVars`: If you are using rendered content, set the array with variables to be used in the view. See `Mailer::setViewVars()`. -- `'attachments'`: List of files to attach. See `Mailer::setAttachments()`. -- `'emailFormat'`: Format of email (html, text or both). See `Mailer::setEmailFormat()`. -- `'transport'`: Transport configuration name. See [Email Transport](#email-transport). -- `'log'`: Log level to log the email headers and message. `true` will use +- `attachments`: List of files to attach. See `Mailer::setAttachments()`. +- `emailFormat`: Format of email (html, text or both). See `Mailer::setEmailFormat()`. +- `transport`: Transport configuration name. See [Email Transport](#email-transport). +- `log`: Log level to log the email headers and message. `true` will use LOG_DEBUG. See [Logging Levels](../core-libraries/logging#logging-levels). Note that logs will be emitted under the scope named `email`. See also [Logging Scopes](../core-libraries/logging#logging-scopes). -- `'helpers'`: Array of helpers used in the email template. +- `helpers`: Array of helpers used in the email template. `ViewBuilder::setHelpers()`/`ViewBuilder::addHelpers()`. > [!NOTE] diff --git a/docs/en/core-libraries/events.md b/docs/en/core-libraries/events.md index 24885525bc..4c7e917377 100644 --- a/docs/en/core-libraries/events.md +++ b/docs/en/core-libraries/events.md @@ -298,16 +298,86 @@ As you can see in the above code, the `on()` function will accept instances of the `EventListener` interface. Internally, the event manager will use `implementedEvents()` to attach the correct callbacks. -::: info Added in version 5.1.0 -The `events` hook was added to the `BaseApplication` as well as the `BasePlugin` class +::: info Added in version 5.4.0 +The `eventListeners` hook was added to `BaseApplication` and `BasePlugin`. ::: -As of CakePHP 5.1 it is recommended to register event listeners by adding them via the `events` hook in your application or plugin class: +As of CakePHP 5.4, applications and plugins can register listener classes with +the `eventListeners()` hook. Listener classes are resolved through the +application's dependency injection container before they are attached to the +global event manager. This lets listeners declare constructor dependencies: ```php namespace App; use App\Event\UserStatistic; +use Cake\Http\BaseApplication; + +class Application extends BaseApplication +{ + // The rest of your Application class + + /** + * @return list> + */ + public function eventListeners(): array + { + return [ + UserStatistic::class, + ]; + } +} +``` + +Plugins can define event listeners the same way in their plugin class: + +```php +namespace ContactManager; + +use Cake\Core\BasePlugin; +use ContactManager\Event\UserStatistic; + +class ContactManagerPlugin extends BasePlugin +{ + /** + * @return list> + */ + public function eventListeners(): array + { + return [ + UserStatistic::class, + ]; + } +} +``` + +If your listener has constructor dependencies, register the listener and its +dependencies in `Application::services()` or `Plugin::services()`: + +```php +use App\Event\UserStatistic; +use App\Service\StatisticsClient; +use Cake\Core\ContainerInterface; + +public function services(ContainerInterface $container): void +{ + $container->addShared(StatisticsClient::class); + $container->addShared(UserStatistic::class) + ->addArgument(StatisticsClient::class); +} +``` + +::: info Added in version 5.1.0 +The `events` hook was added to the `BaseApplication` as well as the `BasePlugin` class. +::: + +Use the `events()` hook in your application or plugin class when you need +imperative registration logic, or want to register anonymous functions: + +```php +namespace App; + +use Cake\Event\EventInterface; use Cake\Event\EventManagerInterface; use Cake\Http\BaseApplication; @@ -317,8 +387,9 @@ class Application extends BaseApplication public function events(EventManagerInterface $eventManager): EventManagerInterface { - $statistics = new UserStatistic(); - $eventManager->on($statistics); + $eventManager->on('Order.afterPlace', function (EventInterface $event): void { + // Code to update statistics + }); return $eventManager; } diff --git a/docs/en/core-libraries/internationalization-and-localization.md b/docs/en/core-libraries/internationalization-and-localization.md index f4b2c66c86..c3aa0bed2c 100644 --- a/docs/en/core-libraries/internationalization-and-localization.md +++ b/docs/en/core-libraries/internationalization-and-localization.md @@ -425,6 +425,65 @@ msgstr[2] "{0} datoteka je uklonjeno" Please visit the [Launchpad languages page](https://translations.launchpad.net/+languages) for a detailed explanation of the plural form numbers for each language. +#### Customizing Plural Rules + +::: info Added in version 5.4.0 +`PluralRules::setRule()` and `PluralRules::resetRules()` were added in 5.4.0. +::: + +When `__n()` / `__dn()` and the other Gettext-style plural functions resolve a +message, CakePHP picks the plural form via `Cake\I18n\PluralRules::calculate()`. +The built-in rules cover most CLDR locales, but they can lag behind upstream +CLDR releases and they do not cover every minority language. If you hit a +locale whose plural form is missing or wrong, you can register a custom rule +without patching CakePHP: + +```php +use Cake\I18n\PluralRules; + +// Breton: 5 plural forms (CLDR) +PluralRules::setRule('br', function (int $n): int { + if ($n % 10 === 1 && $n % 100 !== 11 && $n % 100 !== 71 && $n % 100 !== 91) { + return 0; + } + if ($n % 10 === 2 && $n % 100 !== 12 && $n % 100 !== 72 && $n % 100 !== 92) { + return 1; + } + if (in_array($n % 10, [3, 4, 9], true) + && !in_array($n % 100, [13, 14, 19, 73, 74, 79, 93, 94, 99], true) + ) { + return 2; + } + if ($n !== 0 && $n % 1_000_000 === 0) { + return 3; + } + + return 4; +}); +``` + +The closure receives the integer count and must return the zero-based plural +form index that matches the `msgstr[N]` entries in your **.po** / **.mo** +files. Custom rules take precedence over the built-in map, so they can also +be used to override a built-in rule that does not match the form layout used +by your translation files. + +Register rules in **config/bootstrap.php** so they are available before any +translation is requested. The locale string is normalized via +`Locale::canonicalize()` and an invalid locale throws an +`InvalidArgumentException`. To drop all registered custom rules (typically +between tests), call: + +```php +PluralRules::resetRules(); +``` + +> [!NOTE] +> `PluralRules` is only consulted for Gettext-style messages +> (`__n()`, `__dn()`, `msgstr[0]` / `msgstr[1]` / …). The ICU plural selector +> shown above resolves its own forms via `MessageFormatter` and is unaffected +> by `setRule()`. + ## Creating Your Own Translators If you need to diverge from CakePHP conventions regarding where and how @@ -562,6 +621,94 @@ I18n::config('_fallback', function ($domain, $locale) { }); ``` +### Isolating Translations Per Tenant + +::: info Added in version 5.4.0 +The cache key prefix and configurable cache config APIs were added in 5.4.0. +::: + +If your application needs to serve tenant specific translated content for a given domain & locale, you need to use a cache key prefix to scope both translator cache data to the tenant. + +#### Cache Key Prefix + +`TranslatorRegistry::setCacheKeyPrefix()` adds a segment to both the persistent +cache key and the in-memory lookup bucket. It accepts either a static string or +a `Closure` that returns one. When given a `Closure`, it is evaluated on every +`get()` call, so the current tenant identifier is pulled *from* user-land +instead of being *pushed into* the registry: + +```php +use Cake\I18n\I18n; + +I18n::translators()->setCacheKeyPrefix( + fn (): string => TenantContext::current()?->id ?? '' +); +``` + +With a non-empty prefix the cache key becomes +`translations.{prefix}.{domain}.{locale}`. An empty resolved value disables +prefixing and keeps the legacy key format, so the API is fully backwards +compatible for non-multi-tenant applications. + +The `Closure` receives the requested package name and resolved locale +(`function (string $name, string $locale): string`), which lets you skip +prefixing for shared packages or vary the prefix per locale: + +```php +I18n::translators()->setCacheKeyPrefix( + function (string $name, string $locale): string { + // Shared packages (e.g. validation messages) stay un-prefixed. + if ($name === 'cake' || str_starts_with($name, 'shared/')) { + return ''; + } + + return TenantContext::current()?->id ?? ''; + } +); +``` + +Prefix values must match `[A-Za-z0-9._-]+` to stay safe across every built-in +cache engine. + +> [!NOTE] +> `setCacheKeyPrefix()` is unrelated to the gettext message context used by +> [`__x()`](#using-translation-functions). The "context" in `__x()` disambiguates +> two messages with the same source text; the cache key prefix isolates the +> *cache* of resolved messages. + +#### Resetting the In-Memory Registry + +Long-running workers (e.g. queue runners) that switch tenants between jobs +should drop the in-memory translator map between batches to bound memory +growth and ensure freshly-resolved tenants don't read another tenant's +in-memory translator. The persistent cacher and configured prefix/cacher +are left untouched: + +```php +foreach ($jobsByTenant as $tenantId => $jobs) { + TenantContext::set($tenantId); + // ... process jobs ... + I18n::translators()->clear(); +} +``` + +#### Choosing a Different Cache Config + +By default, translators are persisted to the `_cake_translations_` Cache +config. If you want a separate config — for example, to give translations +their own TTL or storage engine — call `I18n::setCacheConfig()` before any +translator is resolved: + +```php +// in config/bootstrap.php, before any __() / I18n call +I18n::setCacheConfig('_my_translations_'); +``` + +`setCacheConfig()` throws a `RuntimeException` if it is called after the +translators registry has been built, to surface ordering bugs loudly instead +of silently ignoring the setting. To swap the cacher *after* translators +have been built, use `I18n::translators()->setCacher()` directly. + ### Plurals and Context in Custom Translators The arrays used for `setMessages()` can be crafted to instruct the translator diff --git a/docs/en/core-libraries/locking.md b/docs/en/core-libraries/locking.md new file mode 100644 index 0000000000..1e2ddfb7a5 --- /dev/null +++ b/docs/en/core-libraries/locking.md @@ -0,0 +1,186 @@ +# Locking + +`class` Cake\\Lock\\**Lock** + +Locking helps you coordinate access to shared resources across concurrent +requests, CLI commands, queue workers, or background jobs. Use locks when you +need to ensure that only one process performs a critical section at a time. + +CakePHP provides the `Cake\Lock\Lock` facade together with pluggable lock +engines for Redis, Memcached, local files, and no-op/testing usage. + +## Configuring Lock Engines + +Lock engine configurations are typically defined in **config/app.php**: + +```php +'Lock' => [ + 'default' => [ + 'className' => 'Redis', + 'host' => '127.0.0.1', + 'port' => 6379, + 'prefix' => 'myapp_lock_', + 'ttl' => 60, + ], +], +``` + +You can also configure lock engines at runtime: + +```php +use Cake\Lock\Lock; + +Lock::setConfig('orders', [ + 'className' => 'Redis', + 'host' => '127.0.0.1', + 'port' => 6379, + 'prefix' => 'orders_', + 'ttl' => 30, +]); +``` + +Lock configurations use the same `className` conventions as other registry-based +CakePHP services: + +```php +Lock::setConfig('default', ['className' => 'Redis']); +Lock::setConfig('default', ['className' => 'Cake\Lock\Engine\RedisLockEngine']); +``` + +If an engine cannot be initialized, CakePHP falls back to the noop +`NullLockEngine` and emits a warning. + +## Acquiring and Releasing Locks + +Use `Lock::acquire()` to attempt a non-blocking lock: + +```php +use Cake\Lock\Lock; + +$lock = Lock::acquire('invoice-' . $invoiceId, ttl: 60); +if ($lock === null) { + return; +} + +try { + $this->Invoices->send($invoiceId); +} finally { + $lock->release(); +} +``` + +The returned `AcquiredLock` object represents the held lock. You can: + +- call `release()` to release it explicitly +- call `refresh()` to extend the TTL for long-running work +- rely on its best-effort destructor cleanup if the handle is dropped + +Explicit release is still recommended for predictable behavior. + +## Preferred Usage with `synchronized()` + +In many cases, `Lock::synchronized()` is the simplest and safest API because it +guarantees prompt release: + +```php +$result = Lock::synchronized( + 'reports-daily', + function () { + return $this->Reports->buildDaily(); + }, + ttl: 120, + timeout: 10, +); +``` + +If the lock cannot be acquired before the timeout expires, `synchronized()` +returns `null`. + +## Blocking Acquisition + +Use `Lock::acquireBlocking()` when you want to wait for a lock to become +available: + +```php +$lock = Lock::acquireBlocking( + 'payment-' . $paymentId, + ttl: 60, + timeout: 10, + retryInterval: 100, +); + +if ($lock === null) { + return; +} + +try { + $this->Payments->capture($paymentId); +} finally { + $lock->release(); +} +``` + +The `retryInterval` value is expressed in milliseconds. + +## Inspecting and Managing Locks + +CakePHP provides additional helper methods for lock lifecycle management: + +```php +if (Lock::isLocked('imports-products')) { + return; +} + +$lock = Lock::acquire('imports-products'); + +if ($lock) { + $lock->refresh(120); + $lock->release(); +} + +Lock::forceRelease('imports-products'); +``` + +- `isLocked()` performs a point-in-time check using the underlying engine +- `refresh()` extends the lock TTL if the current owner still holds the lock +- `forceRelease()` bypasses ownership checks and should only be used for + administrative recovery flows + +## Available Engines + +CakePHP ships with the following lock engines: + +- `Redis` Recommended for distributed systems and multi-node deployments +- `Memcached` Suitable when Memcached is already part of your infrastructure +- `File` Useful for single-server deployments using local filesystem locks +- `Null` Useful for tests, local development, and intentional no-op behavior + +### Shared Engine Options + +All lock engines support these common options: + +- `prefix` Prefix added to lock keys +- `ttl` Default lock time-to-live in seconds + +### RedisLockEngine Options + +- `host` Redis server host +- `port` Redis server port +- `password` Redis password +- `database` Redis database index +- `timeout` Connection timeout +- `persistent` Whether to use persistent connections + +### MemcachedLockEngine Options + +- `servers` Array of Memcached servers +- `persistent` Persistent connection identifier + +### FileLockEngine Options + +- `path` Directory used to store lock files + +The `FileLockEngine` is local to a single host. Unlike Redis or Memcached, it +does not provide true TTL-based expiration in the backend. Locks are normally +released explicitly, when the lock handle is destroyed, or when the process +terminates. diff --git a/docs/en/core-libraries/text.md b/docs/en/core-libraries/text.md index 9aa39ac0d9..ff8703372e 100644 --- a/docs/en/core-libraries/text.md +++ b/docs/en/core-libraries/text.md @@ -453,4 +453,52 @@ Output: red, orange, yellow, green, blue, indigo and violet +## Text Masking + +### Text::mask() + +`method` Cake\\Utility\\Text::**mask**(string $string, int $offset, ?int $length = null, string $maskCharacter = '*'): string + +Masks a portion of a string with a repeated character. + +Replaces characters starting at `$offset` for `$length` characters with `$maskCharacter`. +If `$length` is `null`, masking continues to the end of the string. +Negative offsets are supported and are calculated from the end of the string. + +```php +$creditCardNumber = '4909090909091234'; + +// Called as TextHelper +echo $this->Text->mask($creditCardNumber, 0, 12, '*'); + +// Called as Text +use Cake\Utility\Text; + +echo Text::mask($creditCardNumber, 0, 12, '*'); +``` + +Output: + + ************1234 + +### Text::maskValue() + +`method` Cake\\Utility\\Text::**maskValue**(string $string, array $needles, string $maskCharacter = '*'): string + +Masks all occurrences of given substring(s) within a string using a repeated character. +Each occurrence of the provided substring(s) will be replaced by a sequence of the masking character. + +```php +// Called as TextHelper +echo $this->Text->maskValue('4111111111111234', ['411', '112'], '*'); + +// Called as Text +use Cake\Utility\Text; + +echo Text::maskValue('4111111111111234', ['411', '112'], '*'); +``` + +Output: + + ***11111111***34 diff --git a/docs/en/development/application.md b/docs/en/development/application.md index 1b65cbebd4..97355e8dce 100644 --- a/docs/en/development/application.md +++ b/docs/en/development/application.md @@ -16,13 +16,17 @@ methods: - `bootstrap` Used to load [configuration files](../development/configuration), define constants and other global functions. By default, this will include **config/bootstrap.php**. This is the ideal place - to load [Plugins](../plugins) and global [event listeners](../core-libraries/events). + to load [Plugins](../plugins) and application configuration. - `routes` Used to load [routes](../development/routing). By default, this will include **config/routes.php**. - `middleware` Used to add [middleware](../controllers/middleware) to your application. - `console` Used to add [console commands](../console-commands) to your application. By default, this will automatically discover console commands in your application and all plugins. +- `eventListeners` Used to register global [event listener](../core-libraries/events) + classes with the application's event manager. +- `events` Used to register global [events](../core-libraries/events) that + require custom registration logic. ## Bootstrapping your Application @@ -49,8 +53,7 @@ sections there are better ways you add custom logic to your application. In addition to the **config/bootstrap.php** file which should be used to configure low-level concerns of your application, you can also use the -`Application::bootstrap()` hook method to load/initialize plugins, and attach -global event listeners: +`Application::bootstrap()` hook method to load/initialize plugins: ```php // in src/Application.php @@ -84,6 +87,6 @@ class Application extends BaseApplication } ``` -Loading plugins and events in `Application::bootstrap()` makes -[Integration Testing](../development/testing#integration-testing) easier as events and routes will be re-processed on +Loading plugins in `Application::bootstrap()` makes +[Integration Testing](../development/testing#integration-testing) easier as routes will be re-processed on each test method. diff --git a/docs/en/development/testing.md b/docs/en/development/testing.md index 4acde5c865..d51bc5e4e1 100644 --- a/docs/en/development/testing.md +++ b/docs/en/development/testing.md @@ -790,7 +790,9 @@ in order to truncate all dirty tables before each test. The following command will help you bake your factories: - bin/cake bake fixture_factory -h +```bash +bin/cake bake fixture_factory -h +``` Once your factories are [tuned](https://github.com/vierge-noire/cakephp-fixture-factories/blob/main/docs/factories.md), @@ -1043,6 +1045,23 @@ In your `tearDown()` method be sure to remove the mock with: $this->getTableLocator()->clear(); ``` +::: info Added in version 5.4 +::: + +If you prefer Mockery mocks you can use `mockModel()` instead of `getMockForModel()`. + +```php +public function testSendingEmails(): void +{ + $model = $this->mockModel('EmailVerification'); + $mock->shouldReceive('send') + ->once() + ->andReturn(true); + + $model->verifyEmail('test@example.com'); +} +``` + ## Controller Integration Testing diff --git a/docs/en/epub-contents.md b/docs/en/epub-contents.md index 20a81f7537..ebfd9e2add 100644 --- a/docs/en/epub-contents.md +++ b/docs/en/epub-contents.md @@ -13,6 +13,7 @@ - [Views](views) - [Database Access & ORM](orm) - [Caching](core-libraries/caching) +- [Locking](core-libraries/locking) - [Bake Console](bake) - [Console Commands](console-commands) - [Debugging](development/debugging) diff --git a/docs/en/index.md b/docs/en/index.md index a80f4f9257..45116e5c4e 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -40,7 +40,7 @@ Get a CakePHP application running in under 5 minutes: ```bash [Composer] # Create new project -composer create-project --prefer-dist cakephp/app:|cakeversion| my_app +composer create-project --prefer-dist cakephp/app:~|cakeversion| my_app # Start development server cd my_app @@ -53,14 +53,14 @@ bin/cake server # Setup with DDEV mkdir my-cakephp-app && cd my-cakephp-app ddev config --project-type=cakephp --docroot=webroot -ddev composer create --prefer-dist cakephp/app:|cakeversion| +ddev composer create --prefer-dist cakephp/app:~|cakeversion| ddev launch ``` ```bash [Docker] # Using official PHP image docker run -it --rm -v $(pwd):/app composer create-project \ - --prefer-dist cakephp/app:|cakeversion| my_app + --prefer-dist cakephp/app:~|cakeversion| my_app cd my_app docker run -it --rm -p 8765:8765 -v $(pwd):/app \ diff --git a/docs/en/installation.md b/docs/en/installation.md index e8da3cc4b3..00e66f8094 100644 --- a/docs/en/installation.md +++ b/docs/en/installation.md @@ -71,7 +71,7 @@ Now you can create a new CakePHP project: ```bash [Create Project] # Create a new CakePHP 5 application -composer create-project --prefer-dist cakephp/app:|cakeversion| my_app_name +composer create-project --prefer-dist cakephp/app:~|cakeversion| my_app_name # Navigate to your app cd my_app_name @@ -100,7 +100,7 @@ Perfect for local development environments: # Create and configure project mkdir my-cakephp-app && cd my-cakephp-app ddev config --project-type=cakephp --docroot=webroot -ddev composer create --prefer-dist cakephp/app:|cakeversion| +ddev composer create --prefer-dist cakephp/app:~|cakeversion| # Launch in browser ddev launch @@ -132,7 +132,7 @@ For containerized development: ```bash # Create project using Composer in Docker docker run --rm -v $(pwd):/app composer create-project \ - --prefer-dist cakephp/app:|cakeversion| my_app + --prefer-dist cakephp/app:~|cakeversion| my_app # Start PHP development server (install required extensions first) cd my_app @@ -394,6 +394,9 @@ myapp.local { # Dockerfile in your project root FROM dunglas/frankenphp +# Install required php extension +RUN install-php-extensions intl + # Copy your CakePHP application COPY . /app diff --git a/docs/en/intro.md b/docs/en/intro.md index 869265edb9..d23893b461 100644 --- a/docs/en/intro.md +++ b/docs/en/intro.md @@ -155,6 +155,8 @@ features in CakePHP are: - A [caching](core-libraries/caching) framework that integrates with Memcached, Redis and other backends. +- A [locking](core-libraries/locking) API for coordinating critical sections + across concurrent requests, workers, and commands. - Powerful [code generation tools](bake/usage) so you can start immediately. - [Integrated testing framework](development/testing) so you can ensure your code works perfectly. diff --git a/docs/en/intro/conventions.md b/docs/en/intro/conventions.md index 72e9dfe121..65f9d6910e 100644 --- a/docs/en/intro/conventions.md +++ b/docs/en/intro/conventions.md @@ -53,8 +53,10 @@ The `src/` folder is where you'll do most development. Here's what goes in each | **Command** | Console commands | `*Command.php` - See [Command Objects](../console-commands/commands) | | **Console** | Installation scripts | Executed by Composer | | **Controller** | HTTP request handlers | [Controllers](../controllers), [Components](../controllers/components) | +| **Form** | Non-ORM form objects | `*Form.php` - See [Form](../core-libraries/form) | +| **Mailer** | Email sending classes | `*Mailer.php` - See [Email](../core-libraries/email) | | **Middleware** | Request/response filters | `*Middleware.php` - See [Middleware](../controllers/middleware) | -| **Model** | Data layer | [Tables](../orm/table-objects), [Entities](../orm/entities), [Behaviors](../orm/behaviors) | +| **Model** | Data layer | [Tables](../orm/table-objects), [Entities](../orm/entities), [Behaviors](../orm/behaviors), [Enums](../orm/database-basics#enum-type) | | **View** | Presentation logic | [Views](../views), [Cells](../views/cells), [Helpers](../views/helpers) | > [!NOTE] @@ -146,6 +148,18 @@ class User extends Entity } ``` +```php [✅ Enum Class] +// File: src/Model/Enum/ArticleStatus.php +namespace App\Model\Enum; + +enum ArticleStatus: string +{ + case Draft = 'draft'; + case Published = 'published'; + case Archived = 'archived'; +} +``` + ::: **Rules:** @@ -154,7 +168,7 @@ class User extends Entity - `UsersTable`, `MenuLinksTable`, `UserFavoritePagesTable` - **Entity class:** Singular, CamelCased, no suffix - `User`, `MenuLink`, `UserFavoritePage` -- **Enum class:** `{Entity}{Column}` - e.g., `UserStatus`, `OrderState` +- **Enum class:** `{Entity}{Column}` in `src/Model/Enum/` - e.g., `ArticleStatus`, `UserRole` - **Behavior class:** Ends in `Behavior` - `TimestampBehavior` ### Views & Templates @@ -227,7 +241,7 @@ CREATE TABLE tags_articles; - **Table names:** Plural, underscored - `users`, `menu_links` - **Multiple words:** Only pluralize the last word - `user_favorite_pages` (not `users_favorites_pages`) -- **Columns:** Underscored - `first_name`, `created_at` +- **Columns:** Underscored - `first_name`, `is_processed` - **Foreign keys:** `{singular_table}_id` - `user_id`, `menu_link_id` - **Junction tables:** Alphabetically sorted plurals - `articles_tags` (not `tags_articles`) @@ -282,10 +296,13 @@ All files follow **PSR-4 autoloading** - filenames must match class names exactl | Component | `MyHandyComponent` | `MyHandyComponent.php` | `src/Controller/Component/` | | Table | `OptionValuesTable` | `OptionValuesTable.php` | `src/Model/Table/` | | Entity | `OptionValue` | `OptionValue.php` | `src/Model/Entity/` | +| Enum | `ArticleStatus` | `ArticleStatus.php` | `src/Model/Enum/` | | Behavior | `EspeciallyFunkableBehavior` | `EspeciallyFunkableBehavior.php` | `src/Model/Behavior/` | | View | `SuperSimpleView` | `SuperSimpleView.php` | `src/View/` | | Helper | `BestEverHelper` | `BestEverHelper.php` | `src/View/Helper/` | | Command | `UpdateCacheCommand` | `UpdateCacheCommand.php` | `src/Command/` | +| Mailer | `UserMailer` | `UserMailer.php` | `src/Mailer/` | +| Form | `ContactForm` | `ContactForm.php` | `src/Form/` | ## Complete Example: Articles Feature @@ -299,6 +316,7 @@ CREATE TABLE articles ( user_id INT, title VARCHAR(255), body TEXT, + status VARCHAR(20), -- backed by App\Model\Enum\ArticleStatus created DATETIME, modified DATETIME ); @@ -319,8 +337,10 @@ src/ ├── Model/ │ ├── Table/ │ │ └── ArticlesTable.php → class ArticlesTable -│ └── Entity/ -│ └── Article.php → class Article +│ ├── Entity/ +│ │ └── Article.php → class Article +│ └── Enum/ +│ └── ArticleStatus.php → enum ArticleStatus templates/ └── Articles/ ├── index.php → ArticlesController::index() @@ -346,6 +366,7 @@ URL: `https://example.com/articles/view/5` | **Database Table** | `articles` | `menu_links` | Plural, underscored | | **Table Class** | `ArticlesTable` | `MenuLinksTable` | Plural, CamelCased, ends in `Table` | | **Entity Class** | `Article` | `MenuLink` | Singular, CamelCased | +| **Enum Class** | `ArticleStatus` | `MenuLinkType` | `{Entity}{Column}` in `src/Model/Enum/` | | **Controller Class** | `ArticlesController` | `MenuLinksController` | Plural, CamelCased, ends in `Controller` | | **Template Path** | `templates/Articles/` | `templates/MenuLinks/` | Matches controller name | | **Template File** | `index.php`, `add.php` | `index.php`, `add.php` | Underscored action name | @@ -359,13 +380,13 @@ URL: `https://example.com/articles/view/5` ::: details Database Convention Summary -| Convention | Description | Example | -|------------|-------------|---------| +| Convention | Description | Example | +|------------|-------------|---------------------------------------------| | **Foreign Keys** | `{singular_table}_id` for hasMany/belongsTo/hasOne | Users hasMany Articles → `articles.user_id` | -| **Multi-word FKs** | Use singular of full table name | `menu_links` table → `menu_link_id` | -| **Junction Tables** | Alphabetically sorted plurals | `articles_tags` (not `tags_articles`) | -| **Primary Keys** | Auto-increment INT or UUID | UUID auto-generated via `Text::uuid()` | -| **Column Names** | Underscored for multiple words | `first_name`, `created_at` | +| **Multi-word FKs** | Use singular of full table name | `menu_links` table → `menu_link_id` | +| **Junction Tables** | Alphabetically sorted plurals | `articles_tags` (not `tags_articles`) | +| **Primary Keys** | Auto-increment INT or UUID | UUID auto-generated via `Text::uuid()` | +| **Column Names** | Underscored for multiple words | `first_name`, `is_processed` | > [!WARNING] > If junction tables have additional data columns, create a dedicated Table and Entity class for them. diff --git a/docs/en/orm/associations.md b/docs/en/orm/associations.md index 8d4a36a97b..3b2bd30e5d 100644 --- a/docs/en/orm/associations.md +++ b/docs/en/orm/associations.md @@ -462,9 +462,12 @@ Possible keys for hasMany association arrays include: - **propertyName**: The property name that should be filled with data from the associated table into the source table results. By default, this is the underscored & plural name of the association so `comments` in our example. -- **strategy**: Defines the query strategy to use. Defaults to 'subquery'. The - other valid value is 'select', which uses the `IN` list of parent keys +- **strategy**: Defines the query strategy to use. Defaults to `subquery`. The + other valid value is `select`, which uses the `IN` list of parent keys directly instead of a subquery. +::: tip New default strategy in version 5.4+ +The default strategy has changed from `select` to `subquery` in order to improve performance when the number of parent keys is large. +::: - **saveStrategy**: Either `append` or `replace`. Defaults to `append`. When `append` the current records are appended to any records in the database. When `replace` associated records not in the current set will be removed. If the foreign key is a nullable @@ -616,9 +619,12 @@ Possible keys for belongsToMany association arrays include: - **propertyName**: The property name that should be filled with data from the associated table into the source table results. By default, this is the underscored & plural name of the association, so `tags` in our example. -- **strategy**: Defines the query strategy to use. Defaults to 'subquery'. The - other valid value is 'select', which uses the `IN` list of parent keys +- **strategy**: Defines the query strategy to use. Defaults to `subquery`. The + other valid value is `select`, which uses the `IN` list of parent keys directly instead of a subquery. +::: tip New default strategy in version 5.4+ +The default strategy has changed from `select` to `subquery` in order to improve performance when the number of parent keys is large. +::: - **saveStrategy**: Either `append` or `replace`. Defaults to `replace`. Indicates the mode to be used for saving associated entities. The former will only create new links between both side of the relation and the latter will diff --git a/docs/en/orm/database-basics.md b/docs/en/orm/database-basics.md index f3599b6999..be81151ece 100644 --- a/docs/en/orm/database-basics.md +++ b/docs/en/orm/database-basics.md @@ -188,99 +188,77 @@ use a non-default connection, see [Configuring Table Connections](../orm/table-o There are a number of keys supported in database configuration. A full list is as follows: -className -: The fully namespaced class name of the class that represents the connection to a database server. +- `className`: The fully namespaced class name of the class that represents the connection to a database server. This class is responsible for loading the database driver, providing SQL transaction mechanisms and preparing SQL statements among other things. -driver -: The class name of the driver used to implement all specificities for +- `driver`: The class name of the driver used to implement all specificities for a database engine. This can either be a short classname using `plugin syntax`, a fully namespaced name, or a constructed driver instance. Examples of short classnames are Mysql, Sqlite, Postgres, and Sqlserver. -persistent -: Whether or not to use a persistent connection to the database. This option +- `persistent`: Whether or not to use a persistent connection to the database. This option is not supported by SqlServer. An exception is thrown if you attempt to set `persistent` to `true` with SqlServer. -host -: The database server's hostname (or IP address). +- `host`: The database server's hostname (or IP address). -username -: The username for the account. +- `username`: The username for the account. -password -: The password for the account. +- `password`: The password for the account. -database -: The name of the database for this connection to use. Avoid using `.` in +- `database`: The name of the database for this connection to use. Avoid using `.` in your database name. Because of how it complicates identifier quoting CakePHP does not support `.` in database names. The path to your SQLite database should be an absolute path (for example, `ROOT . DS . 'my_app.db'`) to avoid incorrect paths caused by relative paths. -port (*optional*) -: The TCP port or Unix socket used to connect to the server. +- `port`: The TCP port or Unix socket used to connect to the server + (*optional*). -encoding -: Indicates the character set to use when sending SQL statements to +- `encoding`: Indicates the character set to use when sending SQL statements to the server. This defaults to the database's default encoding for all databases other than DB2. -timezone -: Server timezone to set. +- `timezone`: Server timezone to set. -schema -: Used in PostgreSQL database setups to specify which schema to use. +- `schema`: Used in PostgreSQL database setups to specify which schema to use. -unix_socket -: Used by drivers that support it to connect via Unix socket files. If you are +- `unix_socket`: Used by drivers that support it to connect via Unix socket files. If you are using PostgreSQL and want to use Unix sockets, leave the host key blank. -ssl_key -: The file path to the SSL key file. (Only supported by MySQL). +- `ssl_key`: The file path to the SSL key file. (Only supported by MySQL). -ssl_cert -: The file path to the SSL certificate file. (Only supported by MySQL). +- `ssl_cert`: The file path to the SSL certificate file. (Only supported by MySQL). -ssl_ca -: The file path to the SSL certificate authority. (Only supported by MySQL). +- `ssl_ca`: The file path to the SSL certificate authority. (Only supported by MySQL). -init -: A list of queries that should be sent to the database server as +- `init`: A list of queries that should be sent to the database server as when the connection is created. -log -: Set to `true` to enable query logging. When enabled queries will be logged +- `log`: Set to `true` to enable query logging. When enabled queries will be logged at a `debug` level with the `queriesLog` scope. -quoteIdentifiers -: Set to `true` if you are using reserved words or special characters in +- `quoteIdentifiers`: Set to `true` if you are using reserved words or special characters in your table or column names. Enabling this setting will result in queries built using the [Query Builder](../orm/query-builder) having identifiers quoted when creating SQL. It should be noted that this decreases performance because each query needs to be traversed and manipulated before being executed. -flags -: An associative array of PDO constants that should be passed to the +- `flags`: An associative array of PDO constants that should be passed to the underlying PDO instance. See the PDO documentation for the flags supported by the driver you are using. -cacheMetadata -: Either boolean `true`, or a string containing the cache configuration to +- `cacheMetadata`: Either boolean `true`, or a string containing the cache configuration to store meta data in. Having metadata caching disabled by setting it to `false` is not advised and can result in very poor performance. See the [Database Metadata Cache](#database-metadata-cache) section for more information. -mask -: Set the permissions on the generated database file. (Only supported by SQLite) +- `mask`: Set the permissions on the generated database file. (Only supported by SQLite) -cache -: The `cache` flag to send to SQLite. +- `cache`: The `cache` flag to send to SQLite. -mode -: The `mode` flag value to send to SQLite. +- `mode`: The `mode` flag value to send to SQLite. ### SqlServer Entra Authentication @@ -618,7 +596,54 @@ enum ArticleStatus: string implements EnumLabelInterface ``` This can be useful if you want to use your enums in `FormHelper` select -inputs. You can use [bake](../bake) to generate an enum class: +inputs. + +#### EnumLabelTrait and the Label Attribute + +::: info Added in version 5.4.0 +`Cake\Database\Type\EnumLabelTrait` and the +`Cake\Database\Type\Attribute\Label` attribute were added in 5.4.0. +::: + +Writing the `label()` `match` block by hand becomes repetitive once an enum +grows past a few cases. `EnumLabelTrait` provides a default `label()` +implementation that derives the label from the case name and resolves it +through the translator. Cases can override the derived label with the +`#[Label]` attribute: + +```php +namespace App\Model\Enum; + +use Cake\Database\Type\Attribute\Label; +use Cake\Database\Type\EnumLabelInterface; +use Cake\Database\Type\EnumLabelTrait; + +enum ArticleStatus: string implements EnumLabelInterface +{ + use EnumLabelTrait; + + case Published = 'Y'; + + #[Label('Not yet published')] + case Unpublished = 'N'; + + #[Label('Archived', domain: 'articles', context: 'status')] + case Archived = 'A'; +} +``` + +For a case **without** a `#[Label]` attribute, the trait humanizes the case +name (`Unpublished` → `Unpublished`, `InReview` → `In review`) and runs it +through the translator. For cases **with** a `#[Label]`, the explicit label +string is used and is translated using the optional `domain` and `context` +constructor arguments. Labels are extracted by `cake i18n extract`, which +detects the `#[Label]` attribute and emits one msgid per case. + +> [!TIP] +> Pair `EnumLabelTrait` with `EnumLabelInterface` so type-aware consumers +> (e.g. `FormHelper`'s automatic enum support) keep working. + +You can use [bake](../bake) to generate an enum class: ```bash # generate an enum class with two cases and stored as an integer @@ -1107,6 +1132,53 @@ do the following: - If the closure returns `false`, a rollback will be issued. - If the closure executes successfully, the transaction will be committed. +### afterCommit + +`method` Cake\\Database\\Connection::**afterCommit**(callable $callback): void + +You can register callbacks to run after the outermost transaction commits using +``afterCommit()``. This is useful for deferring side effects like sending +emails, dispatching jobs, or invalidating caches until you know the data has +been persisted: + +```php +$connection->begin(); +$connection->execute('UPDATE articles SET published = ? WHERE id = ?', [true, 2]); +$connection->afterCommit(function () { + // Send notification email — only runs if the transaction commits. + $this->mailer->send('article-published'); +}); +$connection->commit(); // Callback fires here. +``` + +Callbacks are discarded if the transaction is rolled back. When nested +transactions are in use, callbacks registered at any depth are deferred until +the outermost transaction commits: + +```php +$connection->begin(); +$connection->afterCommit(function () { + // This fires after the outermost commit. +}); + +$connection->begin(); // Nested (savepoint) +$connection->afterCommit(function () { + // Also deferred to outermost commit. +}); +$connection->commit(); // Releases savepoint — callbacks don't fire yet. + +$connection->commit(); // Outermost commit — both callbacks fire now. +``` + +If ``afterCommit()`` is called when no transaction is active, the callback +executes immediately. This matches the semantics of the ORM's +``Model.afterSaveCommit`` event, which also fires immediately for non-atomic +saves. + +::: info Added in version 5.4.0 +`Connection::afterCommit()` was added. +::: + ## Interacting with Statements When using the lower level database API, you will often encounter statement @@ -1185,6 +1257,43 @@ Log::setConfig('queries', [ > never leave query logging on in production as it will negatively impact the > performance of your application. +### Redacting Sensitive Values from Query Logs + +Query log lines render the executed SQL with bound parameters spliced +back in, so any secret bound as a parameter (encryption keys, +passwords, OAuth tokens) ends up in every surface that consumes a +`LoggedQuery` — file logs via `__toString()`, structured loggers via +`getContext()`, and anything that re-serialises the LoggedQuery as JSON +via `jsonSerialize()`. + +`Cake\Database\Log\LoggedQuery::setRedactor()` registers a global +`Closure` invoked before any of those exit points are exposed. The +closure receives the raw query string and bound params and must return +a 2-element array `[string $query, array $params]` with sensitive +values replaced: + +```php +use Cake\Database\Log\LoggedQuery; + +// In Application::bootstrap() or equivalent. +LoggedQuery::setRedactor(function (string $query, array $params): array { + foreach ($params as $key => $value) { + if (in_array($key, ['password', 'token', 'apiKey'], true)) { + $params[$key] = '«REDACTED»'; + } + } + return [$query, $params]; +}); +``` + +The hook fires in `interpolate()`, `getContext()`, and `jsonSerialize()`, +so every public exit path is covered. Pass `null` to clear a previously +registered redactor. + +A redactor that throws or returns a malformed value is silently ignored +for that call — the raw query and params are used as a safe fallback so +a faulty redactor cannot break logging. + ## Identifier Quoting By default, CakePHP does **not** quote identifiers in generated SQL queries. The diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index bb5215dd9f..6b9a93d9af 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -346,31 +346,36 @@ You can access existing wrappers for several SQL functions through `SelectQuery: Generate a random value between 0 and 1 via SQL. `sum()` -Calculate a sum. `Assumes arguments are literal values.` +Calculate a sum. *Assumes arguments are literal values.* `avg()` -Calculate an average. `Assumes arguments are literal values.` +Calculate an average. *Assumes arguments are literal values.* `min()` -Calculate the min of a column. `Assumes arguments are literal values.` +Calculate the min of a column. *Assumes arguments are literal values.* `max()` -Calculate the max of a column. `Assumes arguments are literal values.` +Calculate the max of a column. *Assumes arguments are literal values.* `count()` -Calculate the count. `Assumes arguments are literal values.` +Calculate the count. *Assumes arguments are literal values.* + +`stringAgg()` +Aggregate string values using a separator. Translates to `STRING_AGG()`, +`GROUP_CONCAT()`, or `LISTAGG()` depending on the database driver. +*Assumes the first argument is a literal value.* `cast()` Convert a field or expression from one data type to another. `concat()` -Concatenate two values together. `Assumes arguments are bound parameters.` +Concatenate two values together. *Assumes arguments are bound parameters.* `coalesce()` -Coalesce values. `Assumes arguments are bound parameters.` +Coalesce values. *Assumes arguments are bound parameters.* `dateDiff()` -Get the difference between two dates/times. `Assumes arguments are bound parameters.` +Get the difference between two dates/times. *Assumes arguments are bound parameters.* `now()` Defaults to returning date and time, but accepts 'time' or 'date' to return only @@ -471,6 +476,38 @@ FROM articles; > [!NOTE] > Use `func()` to pass untrusted user data to any SQL function. +#### String Aggregation + +The `stringAgg()` method provides a portable way to aggregate string values +using a separator. It translates to the appropriate native SQL function for +each driver (`STRING_AGG()` on PostgreSQL and SQL Server, `GROUP_CONCAT()` on +MySQL, and `STRING_AGG()` or `GROUP_CONCAT()` on MariaDB/SQLite depending on +version): + +```php +$query = $articles->find(); +$query->select([ + 'category_id', + 'titles' => $query->func()->stringAgg('title', ', '), +]) +->groupBy('category_id'); +``` + +You can optionally specify an ordering for the aggregated values via the +third argument: + +```php +$query->func()->stringAgg('title', ', ', ['title' => 'ASC']); +``` + +`STRING_AGG` with aggregate-local ordering is supported on PostgreSQL, +SQL Server, MariaDB 10.5+ and SQLite 3.44+. MySQL translates the call to +`GROUP_CONCAT` in all cases. + +::: info Added in version 5.4.0 +`FunctionsBuilder::stringAgg()` was added. +::: + ### Ordering Results To apply ordering, you can use the `orderBy()` method: @@ -2200,6 +2237,39 @@ $unpublished->intersectAll($inReview); `intersect()` and `intersectAll()` were added. ::: +### Except + +Except operations allow you to return rows from one query that do not appear +in another query. Except queries are created by composing one or more select +queries together: + +```php +$allArticles = $articles->find(); + +$published = $articles->find() + ->where(['published' => true]); + +$allArticles->except($published); +``` + +You can create `EXCEPT ALL` queries using the `exceptAll()` method: + +```php +$allArticles = $articles->find(); + +$published = $articles->find() + ->where(['published' => true]); + +$allArticles->exceptAll($published); +``` + +`EXCEPT ALL` is supported on PostgreSQL and recent MySQL/MariaDB versions. +It is not supported on SQLite or SQL Server. + +::: info Added in version 5.4.0 +`except()` and `exceptAll()` were added. +::: + ### Subqueries Subqueries enable you to compose queries together and build conditions and diff --git a/docs/en/orm/retrieving-data-and-resultsets.md b/docs/en/orm/retrieving-data-and-resultsets.md index 132b9fa6d4..80660e3890 100644 --- a/docs/en/orm/retrieving-data-and-resultsets.md +++ b/docs/en/orm/retrieving-data-and-resultsets.md @@ -179,16 +179,46 @@ you can pass query objects to your controllers, we recommend that you package your queries up as [Custom Find Methods](#custom-find-methods) instead. Using custom finder methods will let you re-use your queries and make testing easier. -By default, queries and result sets will return [Entities](../orm/entities) objects. You -can retrieve basic arrays by disabling hydration: + + +### Getting Arrays Instead of Entities + +By default, queries and result sets return [Entities](../orm/entities) objects. +When you only need plain arrays, use `unhydratedFind()` instead of `find()`: ```php -$query->disableHydration(); +$query = $articles->unhydratedFind(); -// $data is ResultSet that contains array data. +// $data is a ResultSet that contains array data. $data = $query->all(); + +// Terminal methods are typed as arrays too. +$row = $articles->unhydratedFind()->where(['id' => 1])->first(); // array|null +``` + +`unhydratedFind()` accepts the same finder type and arguments as `find()`, so +your existing [custom finders](#custom-find-methods) are reused unchanged: + +```php +$rows = $articles->unhydratedFind('published')->all(); ``` +It returns a `Cake\ORM\Query\UnhydratedSelectQuery`. This behaves exactly like +`find()->disableHydration()` at runtime, but its static type matches the array +result shape, so static analyzers no longer see `entity|array` on `first()`, +`firstOrFail()`, `all()`, `toArray()` and iteration — including after the query +flows through a custom finder. + +> [!NOTE] +> `unhydratedFind()` only changes the result shape for row-returning finders. +> `findList()` and `findThreaded()` produce a key/value map or nested tree +> regardless of hydration, so there is nothing to type differently for them. + +> [!WARNING] +> `SelectQuery::disableHydration()` is deprecated as of 5.4.0 and will be +> removed in 6.0. The fluent toggle returns a query whose static type still +> claims to produce entities; prefer `unhydratedFind()` instead. + ## Getting the First Result @@ -1071,15 +1101,15 @@ section show how you can add calculated fields, or replace the result set. > ->all(); > ``` > -> Depending on your use case, you may also consider using disabling hydration: +> Depending on your use case, you may also consider skipping hydration: > > ``` bash -> $results = $articles->find() -> ->disableHydration() +> $results = $articles->unhydratedFind() > ->all(); > ``` > -> The above will disable creation of entity objects and return rows as arrays instead. +> The above will skip creation of entity objects and return rows as arrays instead. +> See [Getting Arrays Instead of Entities](#getting-arrays-instead-of-entities). ### Getting the First & Last Record From a ResultSet @@ -1244,10 +1274,9 @@ $reducer = function ($occurrences, $word, $mapReduce) { Finally, we put everything together: ```php -$wordCount = $articles->find() +$wordCount = $articles->unhydratedFind() ->where(['published' => true]) ->andWhere(['published_date >=' => new DateTime('2014-01-01')]) - ->disableHydration() ->mapReduce($mapper, $reducer) ->all() ->toArray(); @@ -1317,8 +1346,7 @@ $reducer = function ($friends, $user, $mr) { And we supply our functions to a query: ```php -$fakeFriends = $friends->find() - ->disableHydration() +$fakeFriends = $friends->unhydratedFind() ->mapReduce($mapper, $reducer) ->all() ->toArray(); diff --git a/docs/en/orm/schema-system.md b/docs/en/orm/schema-system.md index f62f0cb4a7..4b89e16140 100644 --- a/docs/en/orm/schema-system.md +++ b/docs/en/orm/schema-system.md @@ -182,6 +182,28 @@ $indexes = $schema->indexes() $index = $schema->index('author_id_idx') ``` +#### PostgreSQL Index Access Methods + +::: info Added in version 5.4.0 +::: + +When reflecting indexes on PostgreSQL, non-btree indexes include an +`accessMethod` field identifying the underlying index type (`gin`, `gist`, +`spgist`, `brin`, `hash`). Schemas generated from these reflections will emit +the appropriate `USING` clause when recreating the index. + +```php +$schema->addIndex('articles_tags_idx', [ + 'columns' => ['tags'], + 'type' => 'index', + 'accessMethod' => 'gin', +]); +``` + +The `Cake\Database\Schema\Index` class exposes constants for the supported +access methods: `Index::GIN`, `Index::GIST`, `Index::SPGIST`, `Index::BRIN`, +and `Index::HASH`. Btree indexes (the default) omit the `accessMethod` field. + ### Adding Table Options Some drivers (primarily MySQL) support and require additional table metadata. In diff --git a/docs/en/orm/table-objects.md b/docs/en/orm/table-objects.md index 50aa8b7d21..d778e6416d 100644 --- a/docs/en/orm/table-objects.md +++ b/docs/en/orm/table-objects.md @@ -95,6 +95,15 @@ As seen in the examples above Table objects have an `initialize()` method which is called at the end of the constructor. It is recommended that you use this method to do initialization logic instead of overriding the constructor. +::: tip Added in version 5.4.0 +Table methods `save()`, `delete()`, `patchEntity()`, `patchEntities()` and `loadInto()` +will throw an exception if the entity being passed down does not belong to the table instance. +This will prevent accidental data corruption or deleted records. + +If you don't want this new behavior, you can disable it by calling +`$this->disableEntityClassAssertion();` in your `initialize()` method. +::: + ### Getting Instances of a Table Class Before you can query a table, you'll need to get an instance of the table. You @@ -320,8 +329,18 @@ The `Model.afterSave` event is fired after an entity is saved. The `Model.afterSaveCommit` event is fired after the transaction in which the save operation is wrapped has been committed. It's also triggered for non atomic saves where database operations are implicitly committed. The event is triggered -only for the primary table on which `save()` is directly called. The event is -not triggered if a transaction is started before calling save. +only for the primary table on which `save()` is directly called. + +When `save()` is called inside an outer transaction (e.g. one started with +`Connection::begin()`), the event is deferred until the outermost transaction +commits. If the outer transaction is rolled back, the event is discarded. This +ensures the event only fires after data has been persisted to the database. + +::: info Changed in version 5.4.0 +Previously, this event was not triggered if a transaction was started before +calling `save()`. It is now deferred and fires after the outermost +transaction commits. +::: ### beforeDelete @@ -342,10 +361,19 @@ The `Model.afterDelete` event is fired after an entity has been deleted. `method` Cake\\ORM\\Table::**afterDeleteCommit**(EventInterface $event, EntityInterface $entity, ArrayObject $options): void The `Model.afterDeleteCommit` event is fired after the transaction in which the -delete operation is wrapped has been is committed. It's also triggered for non +delete operation is wrapped has been committed. It's also triggered for non atomic deletes where database operations are implicitly committed. The event is triggered only for the primary table on which `delete()` is directly called. -The event is not triggered if a transaction is started before calling delete. + +When `delete()` is called inside an outer transaction, the event is deferred +until the outermost transaction commits. If the outer transaction is rolled +back, the event is discarded. + +::: info Changed in version 5.4.0 +Previously, this event was not triggered if a transaction was started before +calling `delete()`. It is now deferred and fires after the outermost +transaction commits. +::: ### Stopping Table Events diff --git a/docs/en/pdf-contents.md b/docs/en/pdf-contents.md index 20a81f7537..ebfd9e2add 100644 --- a/docs/en/pdf-contents.md +++ b/docs/en/pdf-contents.md @@ -13,6 +13,7 @@ - [Views](views) - [Database Access & ORM](orm) - [Caching](core-libraries/caching) +- [Locking](core-libraries/locking) - [Bake Console](bake) - [Console Commands](console-commands) - [Debugging](development/debugging) diff --git a/docs/en/plugins.md b/docs/en/plugins.md index 6e772ffe1d..5b2f6df62f 100644 --- a/docs/en/plugins.md +++ b/docs/en/plugins.md @@ -115,6 +115,10 @@ appropriate parts of your application. The hooks are: collection. - `services` Used to register application container services. This is a good opportunity to setup additional objects that need access to the container. +- `eventListeners` Used to register global event listener classes with the + application's event manager. +- `events` Used to register global events that require custom registration + logic. By default, all plugins hooks are enabled. You can disable hooks by using the related options of the `plugin load` command: @@ -311,6 +315,7 @@ use Cake\Core\BasePlugin; use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; use Cake\Console\CommandCollection; +use Cake\Event\EventManagerInterface; use Cake\Http\MiddlewareQueue; use Cake\Routing\RouteBuilder; @@ -369,6 +374,26 @@ class ContactManagerPlugin extends BasePlugin { // Add your services here } + + /** + * @return list> + */ + public function eventListeners(): array + { + return [ + // Add your event listeners here. + ]; + } + + /** + * @inheritDoc + */ + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + // Add custom event registration logic here. + + return $eventManager; + } } ``` diff --git a/docs/en/topics.md b/docs/en/topics.md index 8c2457859d..7ef20142e9 100644 --- a/docs/en/topics.md +++ b/docs/en/topics.md @@ -24,6 +24,7 @@ Introduction to all the key parts of CakePHP: - [Entities](orm/entities) - [Error & Exception Handling](development/errors) - [Caching](core-libraries/caching) +- [Locking](core-libraries/locking) - [Logging](core-libraries/logging) - [Modelless Forms](core-libraries/form) - [Sessions](development/sessions) diff --git a/docs/en/tutorials-and-examples/cms/database.md b/docs/en/tutorials-and-examples/cms/database.md index ca163bb88a..32b1d38543 100644 --- a/docs/en/tutorials-and-examples/cms/database.md +++ b/docs/en/tutorials-and-examples/cms/database.md @@ -66,9 +66,6 @@ bin/cake bake migration CreateUsers email:string password:string created modifie bin/cake bake migration CreateArticles user_id:integer title:string slug:string[191]:unique body:text published:boolean created modified bin/cake bake migration CreateTags title:string[191]:unique created modified bin/cake bake migration CreateArticlesTags article_id:integer:primary tag_id:integer:primary created modified - -# Run migrations to create tables -bin/cake migrations migrate ``` ```php [Migration Example] @@ -120,6 +117,12 @@ $table->addColumn('tag_id', 'integer', [ Remove the `autoIncrement` lines before running the migration to prevent foreign key problems. ::: +Once the `articles_tags` migration is corrected, run the migrations to create the tables: + +```bash +bin/cake migrations migrate +``` + #### Adding Seed Data Create seed files to populate initial data: diff --git a/docs/en/tutorials-and-examples/cms/installation.md b/docs/en/tutorials-and-examples/cms/installation.md index 97cc4ade83..29e4269e66 100644 --- a/docs/en/tutorials-and-examples/cms/installation.md +++ b/docs/en/tutorials-and-examples/cms/installation.md @@ -56,11 +56,11 @@ in the **cms** directory of the current working directory: ::: code-group ```bash [Linux/macOS] -php composer.phar create-project --prefer-dist cakephp/app:|cakeversion| cms +php composer.phar create-project --prefer-dist cakephp/app:~|cakeversion| cms ``` ```bash [Windows] -composer create-project --prefer-dist cakephp/app:|cakeversion| cms +composer create-project --prefer-dist cakephp/app:~|cakeversion| cms ``` ::: diff --git a/docs/en/views/helpers/form.md b/docs/en/views/helpers/form.md index 3ece9b408c..bb04a8c5c9 100644 --- a/docs/en/views/helpers/form.md +++ b/docs/en/views/helpers/form.md @@ -118,7 +118,7 @@ happens. This special array can contain a number of different key-value pairs that affect the way the form tag is generated. Valid values: -- `'type'` - Allows you to choose the type of form to create. If no type is +- `type` - Allows you to choose the type of form to create. If no type is provided then it will be autodetected based on the form context. Valid values: - `'get'` - Will set the form method to HTTP GET. @@ -127,21 +127,21 @@ Valid values: - `'post'` - Will set the method to POST. - `'put', 'delete', 'patch'` - Will override the HTTP method with PUT, DELETE or PATCH respectively, when the form is submitted. -- `'method'` - Valid values are the same as above. Allows you to explicitly +- `method` - Valid values are the same as above. Allows you to explicitly override the form's method. -- `'url'` - Specify the URL the form will submit to. Can be a string or a URL +- `url` - Specify the URL the form will submit to. Can be a string or a URL array. -- `'encoding'` - Sets the `accept-charset` encoding for the form. Defaults +- `encoding` - Sets the `accept-charset` encoding for the form. Defaults to `Configure::read('App.encoding')`. -- `'enctype'` - Allows you to set the form encoding explicitly. -- `'templates'` - The templates you want to use for this form. Any templates +- `enctype` - Allows you to set the form encoding explicitly. +- `templates` - The templates you want to use for this form. Any templates provided will be merged on top of the already loaded templates. Can be either a filename (without extension) from `/config` or an array of templates to use. -- `'context'` - Additional options for the form context class. (For example +- `context` - Additional options for the form context class. (For example the `EntityContext` accepts a `'table'` option that allows you to set the specific Table class the form should be based on.) -- `'idPrefix'` - Prefix for generated ID attributes. -- `'templateVars'` - Allows you to provide template variables for the +- `idPrefix` - Prefix for generated ID attributes. +- `templateVars` - Allows you to provide template variables for the `formStart` template. - `autoSetCustomValidity` - Set to `true` to use custom required and notBlank validation messages in the control's HTML5 validity message. Default is `true`. @@ -743,10 +743,10 @@ by the `control()` method. To reduce repetition, the common options shared by all control methods are as follows: -- `'id'` - Set this key to force the value of the DOM id for the control. +- `id` - Set this key to force the value of the DOM id for the control. This will override the `'idPrefix'` that may be set. -- `'default'` - Used to set a default value for the control field. The +- `default` - Used to set a default value for the control field. The value is used if the data passed to the form does not contain a value for the field (or if no data is passed at all). If no default value is provided, the column's default value will be used. @@ -774,7 +774,7 @@ as follows: > used to disable/exclude options of a control field, so `'default' => false` > would not set any value at all. Instead use `'default' => 0`. -- `'value'` - Used to set a specific value for the control field. This +- `value` - Used to set a specific value for the control field. This will override any value that may else be injected from the context, such as Form, Entity or `request->getData()` etc. @@ -906,7 +906,7 @@ Example: In addition to the [General Control Options](#general-control-options), `textarea()` supports a couple of specific options: -- `'escape'` - Determines whether or not the contents of the textarea should +- `escape` - Determines whether or not the contents of the textarea should be escaped. Defaults to `true`. For example: @@ -917,7 +917,7 @@ couple of specific options: echo $this->Form->control('notes', ['type' => 'textarea', 'escape' => false]); ``` -- `'rows', 'cols'` - You can use these two keys to set the HTML attributes +- `rows, cols` - You can use these two keys to set the HTML attributes which specify the number of rows and columns for the `textarea` field. For example: @@ -946,7 +946,7 @@ You can find below the options which are shared by `select()`, `checkbox()` and `radio()` (the options particular only to one of the methods are described in each method's own section.) -- `'value'` - Sets or selects the value of the affected element(s): +- `value` - Sets or selects the value of the affected element(s): - For checkboxes, it sets the HTML `'value'` attribute assigned to the `input` element to whatever you provide as value. @@ -983,7 +983,7 @@ methods are described in each method's own section.) ); ``` -- `'empty'` - Applies to `radio()` and `select()`. Defaults to `false`. +- `empty` - Applies to `radio()` and `select()`. Defaults to `false`. - When passed to `radio()` and set to `true` it will create an extra input element as the first radio button, with a value of `''` and a @@ -1016,7 +1016,7 @@ methods are described in each method's own section.) ``` -- `'hiddenField'` - For checkboxes and radio buttons, by default, +- `hiddenField` - For checkboxes and radio buttons, by default, a hidden `input` element is also created, along with the main element, so that the key in `$this->request->getData()` will exist even without a value specified. For checkboxes its value @@ -1137,9 +1137,9 @@ Creates a `checkbox` form element. The widget template used is: **Options for Checkboxes** -- `'checked'` - Boolean to indicate whether this checkbox will be checked. +- `checked` - Boolean to indicate whether this checkbox will be checked. Defaults to `false`. -- `'disabled'` - Create a disabled checkbox input. +- `disabled` - Create a disabled checkbox input. This method also generates an associated hidden form `input` element to force the submission of data for @@ -1215,14 +1215,14 @@ Creates a set of radio button inputs. The default widget templates used are: **Attributes for Radio Buttons** -- `'label'` - Boolean to indicate whether or not labels for widgets should be +- `label` - Boolean to indicate whether or not labels for widgets should be displayed, or an array of attributes to apply to all labels. In case a `class` attribute is defined, `selected` will be added to the `class` attribute of checked buttons. Defaults to `true`. -- `'hiddenField'` - If set to `true` a hidden input with a value of `''` +- `hiddenField` - If set to `true` a hidden input with a value of `''` will be included. This is useful for creating radio sets that are non-continuous. Defaults to `true`. -- `'disabled'` - Set to `true` or `'disabled'` to disable all the radio +- `disabled` - Set to `true` or `'disabled'` to disable all the radio buttons. Defaults to `false`. You must provide the label captions for the radio buttons via the `$options` @@ -1353,13 +1353,13 @@ May also use: **Attributes for Select Pickers** -- `'multiple'` - If set to `true` allows multiple selections in the select +- `multiple` - If set to `true` allows multiple selections in the select picker. If set to `'checkbox'`, multiple checkboxes will be created instead. Defaults to `null`. -- `'escape'` - Boolean. If `true` the contents of the `option` elements +- `escape` - Boolean. If `true` the contents of the `option` elements inside the select picker will be HTML entity encoded. Defaults to `true`. -- `'val'` - Allows preselecting a value in the select picker. -- `'disabled'` - Controls the `disabled` attribute. If set to `true` +- `val` - Allows preselecting a value in the select picker. +- `disabled` - Controls the `disabled` attribute. If set to `true` disables the whole select picker. If set to an array it will disable only those specific `option` elements whose values are provided in the array. @@ -1462,12 +1462,30 @@ Output: ``` +To build `$options` from a backed enum, you can use `enumOptions()`: + +```php +use App\Model\Enum\ArticleStatus; + +echo $this->Form->select('status', $this->Form->enumOptions(ArticleStatus::class)); +``` + +When `ArticleStatus` implements `EnumLabelInterface` (or uses +`EnumLabelTrait`), the option text is taken from `label()`; otherwise the +case name is used. This is useful when the form was created without an +entity context, where the automatic enum detection on `control()` does not +apply. + +::: info Added in version 5.4.0 +`FormHelper::enumOptions()` was made public in 5.4.0. +::: + **Controlling Select Pickers via Attributes** By using specific options in the `$attributes` parameter you can control certain behaviors of the `select()` method. -- `'empty'` - Set the `'empty'` key in the `$attributes` argument +- `empty` - Set the `'empty'` key in the `$attributes` argument to `true` (the default value is `false`) to add a blank option with an empty value at the top of your dropdown list. @@ -1488,7 +1506,7 @@ certain behaviors of the `select()` method. ``` -- `'escape'` - The `select()` method allows for an attribute +- `escape` - The `select()` method allows for an attribute called `'escape'` which accepts a boolean value and determines whether to HTML entity encode the contents of the `select`'s `option` elements. @@ -1501,7 +1519,7 @@ certain behaviors of the `select()` method. echo $this->Form->select('gender', $options, ['escape' => false]); ``` -- `'multiple'` - If set to `true`, the select picker will allow +- `multiple` - If set to `true`, the select picker will allow multiple selections. For example: @@ -1541,7 +1559,7 @@ certain behaviors of the `select()` method. ``` -- `'disabled'` - This option can be set in order to disable all or some +- `disabled` - This option can be set in order to disable all or some of the `select`'s `option` items. To disable all items set `'disabled'` to `true`. To disable only certain items, assign to `'disabled'` an array containing the keys of the items to be disabled. @@ -2017,9 +2035,9 @@ By default, it will use the following widget templates: **Options for Submit** -- `'type'` - Set this option to `'reset'` in order to generate reset buttons. +- `type` - Set this option to `'reset'` in order to generate reset buttons. It defaults to `'submit'`. -- `'templateVars'` - Set this array to provide additional template variables +- `templateVars` - Set this array to provide additional template variables for the input element and its container. - Any other provided attributes will be assigned to the `input` element. @@ -2065,18 +2083,18 @@ of `'button'`. **Options for Button** -- `'type'` - You can set this to one of the following three +- `type` - You can set this to one of the following three possible values: 1. `'submit'` - Similarly to the `$this->Form->submit()` method it will create a submit button. However this won't generate a wrapping `div` as `submit()` does. This is the default type. 2. `'reset'` - Creates a form reset button. 3. `'button'` - Creates a standard push button. -- `'escapeTitle'` - Boolean. If set to `true` it will HTML encode +- `escapeTitle` - Boolean. If set to `true` it will HTML encode the value provided inside `$title`. Defaults to `true`. -- `'escape'` - Boolean. If set to `true` it will HTML encode +- `escape` - Boolean. If set to `true` it will HTML encode all the HTML attributes generated for the button. Defaults to `true`. -- `'confirm'` - The confirmation message to display on click. Defaults to +- `confirm` - The confirmation message to display on click. Defaults to `null`. For example: @@ -2140,7 +2158,7 @@ echo $this->Form->end(['data-type' => 'hidden']); Will output: ```html -
+