From 37e10d87848903e63015c6c12ec7c40d8c1541a4 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 24 May 2026 17:52:12 +0200 Subject: [PATCH 01/30] Bump rector to ~2.4 (#19465) Skip SafeDeclareStrictTypesRector and RemoveUselessTernaryRector, which newly fire under rector 2.4: the former adds declare(strict_types=1) to test fixtures/config, the latter rewrites `$x ?: []` in ways that can change behavior on undefined/empty values. Keeps the bump behavior-neutral. --- composer.json | 2 +- rector.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 23989ffa749..e64e685c727 100644 --- a/composer.json +++ b/composer.json @@ -134,7 +134,7 @@ "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", "lowest": "validate-prefer-lowest", "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json", - "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.3.1\" && mv composer.backup composer.json", + "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.4.0\" && mv composer.backup composer.json", "rector-check": "vendor/bin/rector process --dry-run", "rector-fix": "vendor/bin/rector process", "test": "phpunit", diff --git a/rector.php b/rector.php index 4f09b3f229f..4d4c50afc6d 100644 --- a/rector.php +++ b/rector.php @@ -127,4 +127,10 @@ \Rector\Php73\Rector\FuncCall\ArrayKeyFirstLastRector::class, \Rector\Php80\Rector\FuncCall\ClassOnObjectRector::class, \Rector\CodeQuality\Rector\Ternary\SwitchNegatedTernaryRector::class, + + // Newly aggressive in rector 2.4 - keep the bump behavior-neutral: + // adds declare(strict_types=1) to test fixtures/config (out of scope here), + \Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector::class, + // and rewrites `$x ?: []` in ways that can change behavior on undefined/empty values. + \Rector\DeadCode\Rector\Ternary\RemoveUselessTernaryRector::class, ]); From 4b17c42d2f84eec7ffb00577a030ad88acb0834e Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 27 May 2026 16:53:55 +0200 Subject: [PATCH 02/30] 5.x Release: Shorten name (#19471) Shorten name --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6227133dc26..222bece920b 100644 --- a/Makefile +++ b/Makefile @@ -146,7 +146,7 @@ package: clean dist/cakephp-$(DASH_VERSION).zip # Publish app skeleton with dependencies zipballs to Github. publish: guard-VERSION dist/cakephp-$(DASH_VERSION).zip @echo "Creating draft release for $(VERSION). prerelease=$(PRERELEASE)" - curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/cakephp/releases -d '{"tag_name": "$(VERSION)", "name": "CakePHP $(VERSION) released", "draft": true, "prerelease": $(PRERELEASE)}' > release.json + curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/cakephp/releases -d '{"tag_name": "$(VERSION)", "name": "CakePHP $(VERSION)", "draft": true, "prerelease": $(PRERELEASE)}' > release.json # Extract id out of response json. php -r '$$f = file_get_contents("./release.json"); $$d = json_decode($$f, true); file_put_contents("./id.txt", $$d["id"]);' @echo "Uploading zip file to github." From fcf190e1cb32148221e430055e467998e1aaaae3 Mon Sep 17 00:00:00 2001 From: ADmad Date: Thu, 28 May 2026 22:27:21 +0530 Subject: [PATCH 03/30] Bump phpstan to 2.2.0 (#19475) --- .phive/phars.xml | 2 +- phpstan-baseline.neon | 6 ------ src/ORM/Association/BelongsTo.php | 1 - src/ORM/Association/HasMany.php | 1 - src/ORM/Association/HasOne.php | 1 - src/ORM/Behavior/TreeBehavior.php | 1 - src/ORM/Marshaller.php | 2 -- src/ORM/Table.php | 1 - src/TestSuite/MiddlewareDispatcher.php | 1 + 9 files changed, 2 insertions(+), 14 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index f648a9f744f..a1053000514 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6fe203586e1..ab3fafb2b3e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,12 +54,6 @@ parameters: count: 8 path: src/Database/Expression/QueryExpression.php - - - message: '#^Strict comparison using \!\=\= between Cake\\Database\\TypeMap and null will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/Database/Expression/WhenThenExpression.php - - message: '#^Dead catch \- InvalidArgumentException is never thrown in the try block\.$#' identifier: catch.neverThrown diff --git a/src/ORM/Association/BelongsTo.php b/src/ORM/Association/BelongsTo.php index 908b2914d9e..a504900c476 100644 --- a/src/ORM/Association/BelongsTo.php +++ b/src/ORM/Association/BelongsTo.php @@ -148,7 +148,6 @@ public function saveAssociated(EntityInterface $entity, array $options = []): En $targetEntity->extract((array)$this->getBindingKey()), ); - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity = $entity->patch($properties, ['guard' => false]); } else { diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index 3b352b1dbf3..5325d670fb7 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -228,7 +228,6 @@ protected function _saveTarget( } if ($foreignKeyReference !== $entity->extract($foreignKey)) { - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity->patch($foreignKeyReference, ['guard' => false]); } else { diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php index 71d7d4da7b0..51012c7733f 100644 --- a/src/ORM/Association/HasOne.php +++ b/src/ORM/Association/HasOne.php @@ -127,7 +127,6 @@ public function saveAssociated(EntityInterface $entity, array $options = []): En $foreignKeys, $entity->extract((array)$this->getBindingKey()), ); - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($targetEntity, 'patch')) { $targetEntity = $targetEntity->patch($properties, ['guard' => false]); } else { diff --git a/src/ORM/Behavior/TreeBehavior.php b/src/ORM/Behavior/TreeBehavior.php index 2a9ffde9bed..f86cfee3366 100644 --- a/src/ORM/Behavior/TreeBehavior.php +++ b/src/ORM/Behavior/TreeBehavior.php @@ -944,7 +944,6 @@ protected function _ensureFields(EntityInterface $entity): void } $fresh = $this->_table->get($entity->get($this->_getPrimaryKey())); - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity->patch($fresh->extract($fields), ['guard' => false]); } else { diff --git a/src/ORM/Marshaller.php b/src/ORM/Marshaller.php index b21f9149aac..7573fc2cfac 100644 --- a/src/ORM/Marshaller.php +++ b/src/ORM/Marshaller.php @@ -250,7 +250,6 @@ public function one(array $data, array $options = []): EntityInterface $entity->set($field, $properties[$field], ['asOriginal' => true]); } } - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) } elseif (method_exists($entity, 'patch')) { $entity->patch($properties, ['asOriginal' => true]); } else { @@ -624,7 +623,6 @@ public function merge(EntityInterface $entity, array $data, array $options = []) $entity->setErrors($errors); if (!isset($options['fields'])) { - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity->patch($properties); } else { diff --git a/src/ORM/Table.php b/src/ORM/Table.php index 688f7d4a84a..a6810a8e26c 100644 --- a/src/ORM/Table.php +++ b/src/ORM/Table.php @@ -2195,7 +2195,6 @@ protected function _insert(EntityInterface $entity, array $data): EntityInterfac if ($statement->rowCount() !== 0) { $success = $entity; - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity = $entity->patch($filteredKeys, ['guard' => false]); } else { diff --git a/src/TestSuite/MiddlewareDispatcher.php b/src/TestSuite/MiddlewareDispatcher.php index 4e6f18ab892..51e053921cc 100644 --- a/src/TestSuite/MiddlewareDispatcher.php +++ b/src/TestSuite/MiddlewareDispatcher.php @@ -124,6 +124,7 @@ protected function _createRequest(array $spec): ServerRequest array_merge($_SERVER, ['REQUEST_URI' => $spec['url']]), $spec['environment'], ); + /** @phpstan-ignore offsetAccess.notFound */ if (str_contains($environment['PHP_SELF'], 'phpunit')) { $environment['PHP_SELF'] = '/'; } From db156093a091bfd7767013dcfc17f47830e4117f Mon Sep 17 00:00:00 2001 From: ADmad Date: Fri, 29 May 2026 00:33:42 +0530 Subject: [PATCH 04/30] Exclude unneeded files from git archive (#19476) --- .gitattributes | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 30832ef77b1..237fac3f68d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -34,16 +34,34 @@ tests/TestCase export-ignore .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore +.git-blame-ignore-revs export-ignore .mailmap export-ignore -.stickler.yml export-ignore Makefile export-ignore phpcs.xml export-ignore phpstan.neon.dist export-ignore phpstan-baseline.neon export-ignore phpunit.xml.dist export-ignore +psalm.xml export-ignore +psalm-baseline.xml export-ignore rector.php export-ignore +tests/composer.lock export-ignore +tests/phpstan.neon export-ignore +tests/phpstan-baseline.neon export-ignore + # Split package files +src/Database/.gitattributes export-ignore +src/Database/phpstan.neon.dist export-ignore +src/Database/tests/ export-ignore +src/Datasource/.gitattributes export-ignore +src/Datasource/phpstan.neon.dist export-ignore +src/Datasource/tests/ export-ignore +src/Http/.gitattributes export-ignore +src/Http/phpstan.neon.dist export-ignore +src/Http/tests/ export-ignore +src/ORM/.gitattributes export-ignore +src/ORM/phpstan.neon.dist export-ignore +src/ORM/tests/ export-ignore src/Validation/.gitattributes export-ignore src/Validation/phpstan.neon.dist export-ignore src/Validation/tests/ export-ignore From 631b24a2221af53bcd86365faeb1435f162555a6 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 30 May 2026 23:16:28 +0200 Subject: [PATCH 05/30] improve psalm taint handling (#19477) --- psalm-baseline.xml | 10 ---------- psalm.xml | 5 ----- src/Core/Exception/CakeException.php | 3 +++ src/Utility/Hash.php | 17 +++++++++++++++++ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 13c9f2e5f1f..ad810f56aca 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,10 +1,5 @@ - - - - - _config]]> - - - - - _fields[$field]]]> diff --git a/psalm.xml b/psalm.xml index 53050d23b63..7b40fa17225 100644 --- a/psalm.xml +++ b/psalm.xml @@ -27,10 +27,5 @@ - - - - - diff --git a/src/Core/Exception/CakeException.php b/src/Core/Exception/CakeException.php index aae98c0e5b6..1028cd666e3 100644 --- a/src/Core/Exception/CakeException.php +++ b/src/Core/Exception/CakeException.php @@ -68,6 +68,9 @@ public function __construct(array|string $message = '', ?int $code = null, ?Thro * Get the passed in attributes * * @return array + * @psalm-taint-escape html Exception attributes are developer-defined metadata (e.g. controller + * name, validation rule names) used only in debug output — log files and CLI console — never + * rendered unescaped into HTML responses. */ public function getAttributes(): array { diff --git a/src/Utility/Hash.php b/src/Utility/Hash.php index f8abbda50db..35467611be5 100644 --- a/src/Utility/Hash.php +++ b/src/Utility/Hash.php @@ -53,6 +53,9 @@ class Hash * @return mixed The value fetched from the array, or $default if path doesn't exist, is null, * or $data is empty. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-get + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. request body) bleeds into unrelated + * callers of this generic utility (e.g. Configure::read, getParam). */ public static function get(ArrayAccess|array $data, array|string|int|null $path, mixed $default = null): mixed { @@ -297,6 +300,9 @@ protected static function _matches(ArrayAccess|array $data, string $selector): b * @return \ArrayAccess|array The data with $values inserted. * @phpstan-return (T is array ? array : \ArrayAccess) * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-insert + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequest::withData) bleeds into + * unrelated callers of this generic utility (e.g. Configure::write). */ public static function insert(ArrayAccess|array $data, string $path, mixed $values = null): ArrayAccess|array { @@ -349,6 +355,10 @@ public static function insert(ArrayAccess|array $data, string $path, mixed $valu * @param array $path The path to work on. * @param mixed $values The values to insert when doing inserts. * @return \ArrayAccess|array + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally. Without this, + * the ArrayAccess|array $data parameter causes Psalm to dispatch taint through every + * ArrayAccess::offsetSet implementation in the codebase (e.g. Validator) when this method + * is called with tainted request data. */ protected static function _simpleOp( string $op, @@ -401,6 +411,9 @@ protected static function _simpleOp( * @return \ArrayAccess|array The modified array. * @phpstan-return (T is array ? array : \ArrayAccess) * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-remove + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequest::withoutData) bleeds into + * unrelated callers of this generic utility (e.g. Configure::delete). */ public static function remove(ArrayAccess|array $data, string $path): ArrayAccess|array { @@ -776,6 +789,10 @@ public static function expand(array $data, string $separator = '.'): array * @param mixed $merge Array to merge with. The argument and all trailing arguments will be array cast when merged * @return array Merged array * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-merge + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequestFactory merging POST body + * with uploaded files) bleeds into unrelated callers (e.g. InstanceConfigTrait merging + * developer configuration, ServerRequest::addDetector merging detector definitions). */ public static function merge(array $data, mixed $merge): array { From 43e846de5ca95a5022a95272c2c5e51956523cf4 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 1 Jun 2026 13:35:20 +0200 Subject: [PATCH 06/30] Clarify ServerRequest::referer() $local docblock The $local param was documented as an attempt to return a local address, implying a soft fallback. It is actually a hard contract: when true, a non-same-origin referer returns null rather than the raw value. Reword the docblock to describe both modes accurately. --- src/Http/ServerRequest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Http/ServerRequest.php b/src/Http/ServerRequest.php index 1e29dd3b6d5..8079954f00e 100644 --- a/src/Http/ServerRequest.php +++ b/src/Http/ServerRequest.php @@ -430,8 +430,10 @@ public function getTrustedProxies(): array /** * Returns the referer that referred this request. * - * @param bool $local Attempt to return a local address. - * Local addresses do not contain hostnames. + * @param bool $local When true, return the referer as a host-stripped local path, + * or null if the referer is not on the same origin as this application. + * When false, return the raw referer URL as-is. In both cases null is returned + * when no referer is available. * @return string|null The referring address for this request or null. */ public function referer(bool $local = true): ?string From 46a2d260535abfb583651d8220273a49675b6928 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 1 Jun 2026 14:31:46 +0200 Subject: [PATCH 07/30] Remove stale PHPStan ignores from Database split package config (#19488) * Remove stale PHPStan ignores from Database split package config notIdentical.alwaysTrue and catch.neverThrown no longer match any reported errors when PHPStan runs against the isolated cakephp/database package, so reportUnmatchedIgnoredErrors fails the "Static Analysis for Split Packages" CI job. The git subtree split itself is unaffected. * Make Database dead-catch ignore dependency-tolerant The catch.neverThrown finding on Type/DateTimeType.php depends on the resolved Chronos/DateTimeImmutable version: PHPStan reports it under some dependency sets and not others. Re-add the ignore with reportUnmatched: false (and without count) so the split-package analysis passes whether or not the error is reported, instead of flapping red in either direction. --- src/Database/phpstan.neon.dist | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Database/phpstan.neon.dist b/src/Database/phpstan.neon.dist index ca17bf0b54d..573d19e7da3 100644 --- a/src/Database/phpstan.neon.dist +++ b/src/Database/phpstan.neon.dist @@ -9,13 +9,10 @@ parameters: - vendor/ ignoreErrors: - '#Unsafe usage of new static\(\).#' - - - identifier: notIdentical.alwaysTrue - identifier: missingType.iterableValue - - message: '#^Dead catch \- InvalidArgumentException is never thrown in the try block\.$#' identifier: catch.neverThrown - count: 1 path: Type/DateTimeType.php + reportUnmatched: false From 9f4fe7dd605a33ff624457df834a9e28622402b1 Mon Sep 17 00:00:00 2001 From: othercorey Date: Mon, 1 Jun 2026 08:00:34 -0500 Subject: [PATCH 08/30] Fix parameter list in beforeRedirect method documentation (#19489) --- src/Controller/Component.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Component.php b/src/Controller/Component.php index 9cfdf716a95..595aa96b387 100644 --- a/src/Controller/Component.php +++ b/src/Controller/Component.php @@ -46,7 +46,7 @@ * - `afterFilter(EventInterface $event)` * Called after the action is complete and the view has been rendered but * before Controller::afterFilter(). - * - `beforeRedirect(EventInterface $event $url, Response $response)` + * - `beforeRedirect(EventInterface $event, $url, Response $response)` * Called before a redirect is done. Allows you to change the URL that will * be redirected to by returning a Response instance with new URL set using * Response::location(). Redirection can be prevented by stopping the event From a345a41abb31935626b1bea14f5ef37fdb7b2b5a Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 1 Jun 2026 14:21:15 -0400 Subject: [PATCH 09/30] Update supported version list (#19480) * Update supported version list When I was doing the last round of security releases, I noticed that our policy document is missing several versions and needs to be updated as time has passed. I've also moved 4.3 to dropped support. Looking at the recent usage data, there is a low minority of usage for this release, with the bulk of the 4.3 usage coming from 4.4 and 4.6. * Update policy doc to link to github --- .github/SECURITY.md | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6b168892c37..a61b2a1656d 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,19 +2,7 @@ ## Supported Versions -We support fixing security issues on the following releases: - -| Version | Supported | Security fixes until -| ------- | ------------------ | -------------------- -| 5.0 | :white_check_mark: | The release of 5.2 -| 4.5 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) -| 4.4 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) -| 4.3 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) -| 4.2 | :x: | No longer supported -| 4.1 | :x: | No longer supported -| 4.0 | :x: | No longer supported -| 3.10.x | :x: | No longer supported -| 2.10.x | :x: | No longer supported +The supported version list can be found in [GitHub wiki](https://github.com/cakephp/cakephp/wiki#supported-versions). ## Reporting a Vulnerability @@ -32,3 +20,4 @@ the CakePHP team will take the following actions: * Prepare a post describing the vulnerability, and the possible exploits. * Release new versions of all affected versions. * Prominently feature the problem in the release announcement + From 3076e4c01ecec3a51baa16136054102007ac01e0 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 6 Jun 2026 20:19:31 +0200 Subject: [PATCH 10/30] Include passed parameters in MissingRouteException for failed named routes (#19494) When a named route is found but matching fails (e.g. a passed parameter does not satisfy the route's regex patterns), the exception now lists the parameters that were passed. Previously the message only stated that matching failed, giving no hint about which value was wrong. The passed parameters are json-encoded; any percent signs are escaped so the message survives CakeException's vsprintf() formatting unchanged. --- src/Routing/RouteCollection.php | 9 ++++++++- tests/TestCase/Routing/RouteCollectionTest.php | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php index ecec092a060..4c1866b9640 100644 --- a/src/Routing/RouteCollection.php +++ b/src/Routing/RouteCollection.php @@ -294,10 +294,17 @@ public function match(array $url, array $context): string if ($out) { return $out; } + $message = sprintf( + 'A named route was found for `%s`, but matching failed. Passed parameters: `%s`.', + $name, + (string)json_encode($url), + ); + throw new MissingRouteException([ 'url' => $name, 'context' => $context, - 'message' => "A named route was found for `{$name}`, but matching failed.", + // Escape `%` so the message survives CakeException's vsprintf() pass unchanged. + 'message' => str_replace('%', '%%', $message), ]); } throw new MissingRouteException(['url' => $name, 'context' => $context]); diff --git a/tests/TestCase/Routing/RouteCollectionTest.php b/tests/TestCase/Routing/RouteCollectionTest.php index 253af476a70..cb888be70e9 100644 --- a/tests/TestCase/Routing/RouteCollectionTest.php +++ b/tests/TestCase/Routing/RouteCollectionTest.php @@ -666,7 +666,9 @@ public function testMatchNamed(): void public function testMatchNamedError(): void { $this->expectException(MissingRouteException::class); - $this->expectExceptionMessage('A named route was found for `fail`, but matching failed'); + $this->expectExceptionMessage( + 'A named route was found for `fail`, but matching failed. Passed parameters: `{"controller":"Articles"}`.', + ); $context = [ '_base' => '/', '_scheme' => 'http', @@ -675,7 +677,7 @@ public function testMatchNamedError(): void $routes = new RouteBuilder($this->collection, '/b'); $routes->connect('/{lang}/articles', ['controller' => 'Articles'], ['_name' => 'fail']); - $this->collection->match(['_name' => 'fail'], $context); + $this->collection->match(['_name' => 'fail', 'controller' => 'Articles'], $context); } /** From c1bd2b967767cb8960183a725be70df72f2f56de Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 7 Jun 2026 17:16:54 +0530 Subject: [PATCH 11/30] Improve type declaration/inference (#19496) --- src/Console/HelpFormatter.php | 12 ++++++++---- src/Core/functions.php | 2 +- src/Core/functions_global.php | 2 +- src/Database/Schema/PostgresSchemaDialect.php | 5 +++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Console/HelpFormatter.php b/src/Console/HelpFormatter.php index 1be9a12cc9f..65dc3b96607 100644 --- a/src/Console/HelpFormatter.php +++ b/src/Console/HelpFormatter.php @@ -196,12 +196,16 @@ public function xml(bool $string = true): SimpleXMLElement|string $xml->addChild('description', $parser->getDescription()); $options = $xml->addChild('options'); - foreach ($parser->options() as $option) { - $option->xml($options); + if ($options !== null) { + foreach ($parser->options() as $option) { + $option->xml($options); + } } $arguments = $xml->addChild('arguments'); - foreach ($parser->arguments() as $argument) { - $argument->xml($arguments); + if ($arguments !== null) { + foreach ($parser->arguments() as $argument) { + $argument->xml($arguments); + } } $xml->addChild('epilog', $parser->getEpilog()); diff --git a/src/Core/functions.php b/src/Core/functions.php index 9285ac0bfc9..fa2009ac2dd 100644 --- a/src/Core/functions.php +++ b/src/Core/functions.php @@ -248,7 +248,7 @@ function pj(mixed $var): mixed * environment information. * * @param string $key Environment variable name. - * @param string|bool|null $default Specify a default value in case the environment variable is not defined. + * @param string|float|int|bool|null $default Specify a default value in case the environment variable is not defined. * @return string|float|int|bool|null Environment variable setting. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#env */ diff --git a/src/Core/functions_global.php b/src/Core/functions_global.php index 6b466565bb5..5cd5380f7e0 100644 --- a/src/Core/functions_global.php +++ b/src/Core/functions_global.php @@ -155,7 +155,7 @@ function pj(mixed $var): mixed * environment information. * * @param string $key Environment variable name. - * @param string|bool|null $default Specify a default value in case the environment variable is not defined. + * @param string|float|int|bool|null $default Specify a default value in case the environment variable is not defined. * @return string|float|int|bool|null Environment variable setting. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#env */ diff --git a/src/Database/Schema/PostgresSchemaDialect.php b/src/Database/Schema/PostgresSchemaDialect.php index 0357240baa4..89831afc3f5 100644 --- a/src/Database/Schema/PostgresSchemaDialect.php +++ b/src/Database/Schema/PostgresSchemaDialect.php @@ -918,10 +918,11 @@ public function indexSql(TableSchema $schema, string $name): string (array)$index->getColumns(), ); $include = ''; - if ($index->getInclude()) { + $includes = $index->getInclude(); + if ($includes) { $included = array_map( $this->_driver->quoteIdentifier(...), - $index->getInclude(), + $includes, ); $include = sprintf(' INCLUDE (%s)', implode(', ', $included)); } From 191076a5be5e542d2f4853a9d455e7885b1db54b Mon Sep 17 00:00:00 2001 From: ADmad Date: Mon, 8 Jun 2026 03:14:48 +0530 Subject: [PATCH 12/30] Remove phpstan prefixed annotations. (#19499) --- src/Cache/Cache.php | 3 +-- src/Collection/CollectionInterface.php | 3 +-- src/Collection/Iterator/TreeIterator.php | 9 +++------ src/Collection/Iterator/TreePrinter.php | 3 +-- src/Console/Command/HelpCommand.php | 3 +-- src/Console/CommandCollection.php | 3 +-- .../Component/FormProtectionComponent.php | 2 +- src/Controller/Controller.php | 6 ++---- src/Core/PluginCollection.php | 3 +-- src/Core/StaticConfigTrait.php | 3 +-- src/Core/functions.php | 5 ++--- src/Core/functions_global.php | 3 +-- src/Database/Connection.php | 3 +-- src/Database/Driver.php | 8 +++----- src/Database/Expression/WindowInterface.php | 9 +++------ src/Database/Statement/Statement.php | 3 +-- src/Database/TypeFactory.php | 12 ++++-------- src/Datasource/ConnectionManager.php | 3 +-- src/Event/Event.php | 1 - src/Event/EventDispatcherTrait.php | 2 -- src/Form/Form.php | 3 +-- src/Form/FormProtector.php | 6 ++---- src/Http/Client/Request.php | 8 +++----- src/Http/ControllerFactoryInterface.php | 6 ++---- src/Http/Response.php | 6 ++---- src/Http/UriFactory.php | 6 ++---- src/I18n/Date.php | 2 +- src/I18n/DateTime.php | 2 +- src/I18n/RelativeTimeFormatter.php | 3 +-- src/I18n/Time.php | 2 +- src/Log/Log.php | 3 +-- src/Mailer/AbstractTransport.php | 3 +-- src/Mailer/Mailer.php | 15 +++++---------- src/Mailer/Renderer.php | 6 ++---- src/Mailer/Transport/SmtpTransport.php | 3 +-- src/Mailer/TransportFactory.php | 3 +-- src/ORM/AssociationCollection.php | 16 ++++++---------- src/ORM/Behavior/TranslateBehavior.php | 9 +++------ src/ORM/BehaviorRegistry.php | 3 +-- src/ORM/Table.php | 9 +++------ src/Routing/Asset.php | 3 +-- src/TestSuite/TestCase.php | 12 ++++-------- src/TestSuite/TestEmailTransport.php | 3 +-- src/Utility/Hash.php | 18 ++++++------------ src/Utility/Text.php | 3 +-- src/Utility/Xml.php | 3 +-- src/Validation/RulesProvider.php | 3 +-- src/Validation/Validator.php | 12 ++++-------- src/View/Helper/HtmlHelper.php | 3 +-- src/View/Helper/PaginatorHelper.php | 3 +-- src/View/View.php | 14 +++++--------- src/View/ViewBuilder.php | 3 +-- 52 files changed, 96 insertions(+), 185 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 13f5b256606..f2349c823f9 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -72,8 +72,7 @@ class Cache * An array mapping URL schemes to fully qualified caching engine * class names. * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'array' => Engine\ArrayEngine::class, diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index e28527760e0..4f876f0bad3 100644 --- a/src/Collection/CollectionInterface.php +++ b/src/Collection/CollectionInterface.php @@ -811,8 +811,7 @@ public function insert(string $path, mixed $values): CollectionInterface; * collection as the array keys. Keep in mind that it is valid for iterators * to return the same key for different elements, setting this value to false * can help getting all items if keys are not important in the result. - * @phpstan-return ($keepKeys is true ? array : array) - * @return array|array + * @return ($keepKeys is true ? array : array) */ public function toArray(bool $keepKeys = true): array; diff --git a/src/Collection/Iterator/TreeIterator.php b/src/Collection/Iterator/TreeIterator.php index c4286fbf646..beb961004b3 100644 --- a/src/Collection/Iterator/TreeIterator.php +++ b/src/Collection/Iterator/TreeIterator.php @@ -38,8 +38,7 @@ class TreeIterator extends RecursiveIteratorIterator implements CollectionInterf /** * The iteration mode * - * @var int - * @phpstan-var \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST + * @var \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST */ protected int $_mode; @@ -47,10 +46,8 @@ class TreeIterator extends RecursiveIteratorIterator implements CollectionInterf * Constructor * * @param \RecursiveIterator $items The iterator to flatten. - * @param int $mode Iterator mode. - * @param int $flags Iterator flags. - * @phpstan-param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode - * @phpstan-param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::CATCH_GET_CHILD $flags + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode Iterator mode. + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::CATCH_GET_CHILD $flags Iterator flags. */ public function __construct( RecursiveIterator $items, diff --git a/src/Collection/Iterator/TreePrinter.php b/src/Collection/Iterator/TreePrinter.php index 9bc6e709765..95e73dbcc5a 100644 --- a/src/Collection/Iterator/TreePrinter.php +++ b/src/Collection/Iterator/TreePrinter.php @@ -73,8 +73,7 @@ class TreePrinter extends RecursiveIteratorIterator implements CollectionInterfa * callable returning the key value. * @param string $spacer The string to use for prefixing the values according to * their depth in the tree. - * @param int $mode Iterator mode. - * @phpstan-param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode Iterator mode. */ public function __construct( RecursiveIterator $items, diff --git a/src/Console/Command/HelpCommand.php b/src/Console/Command/HelpCommand.php index 366d09b69a5..639f29fb0c5 100644 --- a/src/Console/Command/HelpCommand.php +++ b/src/Console/Command/HelpCommand.php @@ -434,9 +434,8 @@ protected function outputPaths(ConsoleIo $io): void } /** - * @param array $names Names + * @param non-empty-array $names Names * @return string - * @phpstan-param non-empty-array $names */ protected function getShortestName(array $names): string { diff --git a/src/Console/CommandCollection.php b/src/Console/CommandCollection.php index cc816fba505..0e00b615009 100644 --- a/src/Console/CommandCollection.php +++ b/src/Console/CommandCollection.php @@ -162,8 +162,7 @@ public function get(string $name): CommandInterface|string /** * Implementation of IteratorAggregate. * - * @return \Traversable - * @phpstan-return \Traversable> + * @return \Traversable> */ public function getIterator(): Traversable { diff --git a/src/Controller/Component/FormProtectionComponent.php b/src/Controller/Component/FormProtectionComponent.php index 924fbe87acf..100f71a0988 100644 --- a/src/Controller/Component/FormProtectionComponent.php +++ b/src/Controller/Component/FormProtectionComponent.php @@ -33,7 +33,7 @@ * - Existing fields have not been removed from the form. * - Values of hidden inputs have not been changed. * - * @phpstan-property array{validate:bool, unlockedFields:array, unlockedActions:array, validationFailureCallback:?\Closure} $_config + * @property array{validate:bool, unlockedFields:array, unlockedActions:array, validationFailureCallback:?\Closure} $_config */ class FormProtectionComponent extends Component { diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 1518729161c..0a8e43aaf3d 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -175,8 +175,7 @@ class Controller implements EventListenerInterface, EventDispatcherInterface /** * Middlewares list. * - * @var array - * @phpstan-var array + * @var array */ protected array $middlewares = []; @@ -522,12 +521,11 @@ public function invokeAction(Closure $action, array $args): void * Register middleware for the controller. * * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware Middleware. - * @param array $options Valid options: + * @param array{only?: array|string, except?: array|string} $options Valid options: * - `only`: (array|string) Only run the middleware for specified actions. * - `except`: (array|string) Run the middleware for all actions except the specified ones. * @return void * @since 4.3.0 - * @phpstan-param array{only?: array|string, except?: array|string} $options */ public function middleware(MiddlewareInterface|Closure|string $middleware, array $options = []): void { diff --git a/src/Core/PluginCollection.php b/src/Core/PluginCollection.php index 51dd526f96e..83d5a298b95 100644 --- a/src/Core/PluginCollection.php +++ b/src/Core/PluginCollection.php @@ -248,12 +248,11 @@ public function get(string $name): PluginInterface /** * Create a plugin instance from a name/classname and configuration. * - * @param string $name The plugin name or classname + * @param class-string<\Cake\Core\PluginInterface>|string $name The plugin name or classname * @param array $config Configuration options for the plugin. * @return \Cake\Core\PluginInterface * @throws \Cake\Core\Exception\MissingPluginException When plugin instance could not be created. * @throws \InvalidArgumentException When class name cannot be found or an empty name is provided. - * @phpstan-param class-string<\Cake\Core\PluginInterface>|string $name */ public function create(string $name, array $config = []): PluginInterface { diff --git a/src/Core/StaticConfigTrait.php b/src/Core/StaticConfigTrait.php index 717a6cbfb8f..c9c4408123d 100644 --- a/src/Core/StaticConfigTrait.php +++ b/src/Core/StaticConfigTrait.php @@ -312,9 +312,8 @@ public static function parseDsn(string $dsn): array /** * Updates the DSN class map for this class. * - * @param array $map Additions/edits to the class map to apply. + * @param array $map Additions/edits to the class map to apply. * @return void - * @phpstan-param array $map */ public static function setDsnClassMap(array $map): void { diff --git a/src/Core/functions.php b/src/Core/functions.php index fa2009ac2dd..c2e3bfdda3a 100644 --- a/src/Core/functions.php +++ b/src/Core/functions.php @@ -145,9 +145,8 @@ function h(mixed $text, bool $double = true, ?string $charset = null): mixed * @param string $name The name you want to plugin split. * @param bool $dotAppend Set to true if you want the plugin to have a '.' appended to it. * @param string|null $plugin Optional default plugin to use if no plugin is found. Defaults to null. - * @return array Array with 2 indexes. 0 => plugin name, 1 => class name. + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => class name. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pluginsplit - * @phpstan-return array{string|null, string} */ function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = null): array { @@ -157,7 +156,7 @@ function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = nu $parts[0] .= '.'; } - /** @phpstan-var array{string, string} */ + /** @var array{string, string} */ return $parts; } diff --git a/src/Core/functions_global.php b/src/Core/functions_global.php index 5cd5380f7e0..0644ecc15d8 100644 --- a/src/Core/functions_global.php +++ b/src/Core/functions_global.php @@ -82,9 +82,8 @@ function h(mixed $text, bool $double = true, ?string $charset = null): mixed * @param string $name The name you want to plugin split. * @param bool $dotAppend Set to true if you want the plugin to have a '.' appended to it. * @param string|null $plugin Optional default plugin to use if no plugin is found. Defaults to null. - * @return array Array with 2 indexes. 0 => plugin name, 1 => class name. + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => class name. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pluginsplit - * @phpstan-return array{string|null, string} */ function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = null): array { diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 119dfb9b66f..34e3ba62fa2 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -133,8 +133,7 @@ public function __construct(array $config) * Creates read and write drivers. * * @param array $config Connection config - * @return array - * @phpstan-return array{read: \Cake\Database\Driver, write: \Cake\Database\Driver} + * @return array{read: \Cake\Database\Driver, write: \Cake\Database\Driver} */ protected function createDrivers(array $config): array { diff --git a/src/Database/Driver.php b/src/Database/Driver.php index 552136c21b7..eabd43683a7 100644 --- a/src/Database/Driver.php +++ b/src/Database/Driver.php @@ -670,13 +670,11 @@ protected function _updateQueryTranslator(UpdateQuery $query): UpdateQuery /** * Removes aliases from the `WHERE` clause of a query. * - * @param \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery $query The query to process. - * @return \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery The modified query. + * @template T of \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery + * @param T $query The query to process. + * @return T The modified query. * @throws \Cake\Database\Exception\DatabaseException In case the processed query contains any joins, as removing * aliases from the conditions can break references to the joined tables. - * @template T of \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery - * @phpstan-param T $query - * @phpstan-return T */ protected function _removeAliasesFromConditions(UpdateQuery|DeleteQuery $query): UpdateQuery|DeleteQuery { diff --git a/src/Database/Expression/WindowInterface.php b/src/Database/Expression/WindowInterface.php index f96441e71b4..31016cc0c31 100644 --- a/src/Database/Expression/WindowInterface.php +++ b/src/Database/Expression/WindowInterface.php @@ -133,16 +133,13 @@ public function groups(?int $start, ?int $end = 0); * - `0` - 'CURRENT ROW' * - `null` - 'UNBOUNDED' * - * @param string $type Frame type + * @param self::RANGE|self::ROWS|self::GROUPS $type Frame type * @param \Cake\Database\ExpressionInterface|string|int|null $startOffset Frame start offset - * @param string $startDirection Frame start direction + * @param self::PRECEDING|self::FOLLOWING $startDirection Frame start direction * @param \Cake\Database\ExpressionInterface|string|int|null $endOffset Frame end offset - * @param string $endDirection Frame end direction + * @param self::PRECEDING|self::FOLLOWING $endDirection Frame end direction * @return $this * @throws \InvalidArgumentException WHen offsets are negative. - * @phpstan-param self::RANGE|self::ROWS|self::GROUPS $type - * @phpstan-param self::PRECEDING|self::FOLLOWING $startDirection - * @phpstan-param self::PRECEDING|self::FOLLOWING $endDirection */ public function frame( string $type, diff --git a/src/Database/Statement/Statement.php b/src/Database/Statement/Statement.php index 9c751784f65..73024e98464 100644 --- a/src/Database/Statement/Statement.php +++ b/src/Database/Statement/Statement.php @@ -101,8 +101,7 @@ public function bindValue(string|int $column, mixed $value, string|int|null $typ * * @param mixed $value The value to cast. * @param \Cake\Database\TypeInterface|string|int $type The type name or type instance to use. - * @return array List containing converted value and internal type. - * @phpstan-return array{0:mixed, 1:int} + * @return array{0:mixed, 1:int} List containing converted value and internal type. */ protected function cast(mixed $value, TypeInterface|string|int $type = 'string'): array { diff --git a/src/Database/TypeFactory.php b/src/Database/TypeFactory.php index 7c97a789f59..6d1efb44499 100644 --- a/src/Database/TypeFactory.php +++ b/src/Database/TypeFactory.php @@ -26,8 +26,7 @@ class TypeFactory * identifier is used as key and a complete namespaced class name as value * representing the class that will do actual type conversions. * - * @var array - * @phpstan-var array> + * @var array> */ protected static array $_types = [ 'biginteger' => Type\IntegerType::class, @@ -118,9 +117,8 @@ public static function set(string $name, TypeInterface $instance): void * Registers a new type identifier and maps it to a fully namespaced classname. * * @param string $type Name of type to map. - * @param string $className The classname to register. + * @param class-string<\Cake\Database\TypeInterface> $className The classname to register. * @return void - * @phpstan-param class-string<\Cake\Database\TypeInterface> $className */ public static function map(string $type, string $className): void { @@ -131,9 +129,8 @@ public static function map(string $type, string $className): void /** * Set type to classname mapping. * - * @param array $map List of types to be mapped. + * @param array> $map List of types to be mapped. * @return void - * @phpstan-param array> $map */ public static function setMap(array $map): void { @@ -168,8 +165,7 @@ public static function getMap(?string $type = null): array|string|null * Get mapped class name for a specific type. * * @param string $type Type name to get mapped class for. - * @return string|null Configured class name for given $type or null if not found. - * @phpstan-return class-string<\Cake\Database\TypeInterface>|null + * @return class-string<\Cake\Database\TypeInterface>|null Configured class name for given $type or null if not found. */ public static function getMapped(string $type): ?string { diff --git a/src/Datasource/ConnectionManager.php b/src/Datasource/ConnectionManager.php index 20e990a270e..114fbb89f1e 100644 --- a/src/Datasource/ConnectionManager.php +++ b/src/Datasource/ConnectionManager.php @@ -51,8 +51,7 @@ class ConnectionManager /** * An array mapping url schemes to fully qualified driver class names * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'mysql' => Mysql::class, diff --git a/src/Event/Event.php b/src/Event/Event.php index cc1163d458f..b7ed901e419 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -78,7 +78,6 @@ class Event implements EventInterface * (usually the object that is generating the event). * @param array $data any value you wish to be transported * with this event to it can be read by listeners. - * @phpstan-param TSubject|null $subject */ public function __construct(string $name, ?object $subject = null, array $data = []) { diff --git a/src/Event/EventDispatcherTrait.php b/src/Event/EventDispatcherTrait.php index 0856b75eddb..c5fbda4c005 100644 --- a/src/Event/EventDispatcherTrait.php +++ b/src/Event/EventDispatcherTrait.php @@ -78,7 +78,6 @@ public function setEventManager(EventManagerInterface $eventManager) * @param TSubject|null $subject The object that this event applies to * ($this by default). * @return \Cake\Event\EventInterface - * @phpstan-ignore missingType.generics */ public function dispatchEvent(string $name, array $data = [], ?object $subject = null): EventInterface // @phpstan-ignore missingType.generics { @@ -86,7 +85,6 @@ public function dispatchEvent(string $name, array $data = [], ?object $subject = /** * @var \Cake\Event\EventInterface $event Coerce for psalm/phpstan - * @phpstan-ignore missingType.generics (TSubject may itself be generic) */ $event = new $this->_eventClass($name, $subject, $data); $this->getEventManager()->dispatch($event); diff --git a/src/Form/Form.php b/src/Form/Form.php index e5270cc495e..519187f5e05 100644 --- a/src/Form/Form.php +++ b/src/Form/Form.php @@ -74,8 +74,7 @@ class Form implements EventListenerInterface, EventDispatcherInterface, Validato /** * Schema class. * - * @var string - * @phpstan-var class-string<\Cake\Form\Schema> + * @var class-string<\Cake\Form\Schema> */ protected string $_schemaClass = Schema::class; diff --git a/src/Form/FormProtector.php b/src/Form/FormProtector.php index b303a3629b9..61b8a122db0 100644 --- a/src/Form/FormProtector.php +++ b/src/Form/FormProtector.php @@ -270,8 +270,7 @@ protected function extractToken(mixed $formData): ?string * Return hash parts for the token generation * * @param array $formData Form data. - * @return array Contains 'fields' and 'unlockedFields' keys. Additional keys allowed. - * @phpstan-return array{fields: array, unlockedFields: array, ...} + * @return array{fields: array, unlockedFields: array, ...} Contains 'fields' and 'unlockedFields' keys. Additional keys allowed. */ protected function extractHashParts(array $formData): array { @@ -380,8 +379,7 @@ protected function sortedUnlockedFields(array $formData): array * * @param string $url Form URL. * @param string $sessionId Session ID. - * @return array The token data. Contains 'fields', 'unlocked', and 'debug' keys. Additional keys allowed. - * @phpstan-return array{fields: string, unlocked: string, debug: string, ...} + * @return array{fields: string, unlocked: string, debug: string, ...} The token data. Contains 'fields', 'unlocked', and 'debug' keys. Additional keys allowed. */ public function buildTokenData(string $url = '', string $sessionId = ''): array { diff --git a/src/Http/Client/Request.php b/src/Http/Client/Request.php index 16f2b70474e..52abaa3dbb3 100644 --- a/src/Http/Client/Request.php +++ b/src/Http/Client/Request.php @@ -36,10 +36,9 @@ class Request extends Message implements RequestInterface * * Provides backwards compatible defaults for some properties. * - * @phpstan-param array $headers * @param \Psr\Http\Message\UriInterface|string $url The request URL * @param string $method The HTTP method to use. - * @param array $headers The HTTP headers to set. + * @param array $headers The HTTP headers to set. * @param array|string|null $data The request body to use. */ public function __construct( @@ -65,8 +64,7 @@ public function __construct( /** * Add an array of headers to the request. * - * @phpstan-param array $headers - * @param array $headers The headers to add. + * @param array $headers The headers to add. * @return void */ protected function addHeaders(array $headers): void @@ -101,7 +99,7 @@ protected function setContent(array|string $content) $formData = new FormData(); $formData->addMany($content); - /** @phpstan-var array $headers */ + /** @var array $headers */ $headers = ['Content-Type' => $formData->contentType()]; $this->addHeaders($headers); $content = (string)$formData; diff --git a/src/Http/ControllerFactoryInterface.php b/src/Http/ControllerFactoryInterface.php index d7e34fb0bd7..8406b440891 100644 --- a/src/Http/ControllerFactoryInterface.php +++ b/src/Http/ControllerFactoryInterface.php @@ -30,18 +30,16 @@ interface ControllerFactoryInterface * Create a controller for a given request * * @param \Psr\Http\Message\ServerRequestInterface $request The request to build a controller for. - * @return mixed + * @return TController * @throws \Cake\Http\Exception\MissingControllerException - * @phpstan-return TController */ public function create(ServerRequestInterface $request): mixed; /** * Invoke a controller's action and wrapping methods. * - * @param mixed $controller The controller to invoke. + * @param TController $controller The controller to invoke. * @return \Psr\Http\Message\ResponseInterface The response - * @phpstan-param TController $controller */ public function invoke(mixed $controller): ResponseInterface; } diff --git a/src/Http/Response.php b/src/Http/Response.php index 29f84f62443..7fd55100ce0 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -304,8 +304,7 @@ public function withLocation(string $url): static /** * Sets a header. * - * @phpstan-param non-empty-string $header - * @param string $header Header key. + * @param non-empty-string $header Header key. * @param string $value Header value. * @return void */ @@ -319,8 +318,7 @@ protected function _setHeader(string $header, string $value): void /** * Clear header * - * @phpstan-param non-empty-string $header - * @param string $header Header key. + * @param non-empty-string $header Header key. * @return void */ protected function _clearHeader(string $header): void diff --git a/src/Http/UriFactory.php b/src/Http/UriFactory.php index 0ba277139c9..05037457549 100644 --- a/src/Http/UriFactory.php +++ b/src/Http/UriFactory.php @@ -44,8 +44,7 @@ public function createUri(string $uri = ''): UriInterface * * @param array|null $server Array of server data to build the Uri from. * $_SERVER will be used if $server parameter is null. - * @return array - * @phpstan-return array{uri: \Psr\Http\Message\UriInterface, base: string, webroot: string} + * @return array{uri: \Psr\Http\Message\UriInterface, base: string, webroot: string} */ public static function marshalUriAndBaseFromSapi(?array $server = null): array { @@ -117,8 +116,7 @@ protected static function updatePath(string $base, UriInterface $uri): UriInterf * * @param \Psr\Http\Message\UriInterface $uri The Uri instance. * @param array $server The SERVER data to use. - * @return array An array containing the base and webroot paths. - * @phpstan-return array{base: string, webroot: string} + * @return array{base: string, webroot: string} An array containing the base and webroot paths. */ protected static function getBase(UriInterface $uri, array $server): array { diff --git a/src/I18n/Date.php b/src/I18n/Date.php index a1a699059f4..671408e3d56 100644 --- a/src/I18n/Date.php +++ b/src/I18n/Date.php @@ -29,7 +29,7 @@ * * Adds handy methods and locale-aware formatting helpers. * - * @phpstan-immutable + * @immutable */ class Date extends ChronosDate implements JsonSerializable, Stringable { diff --git a/src/I18n/DateTime.php b/src/I18n/DateTime.php index 462bddffba4..aee19dfbd51 100644 --- a/src/I18n/DateTime.php +++ b/src/I18n/DateTime.php @@ -29,7 +29,7 @@ * Extends the built-in DateTime class to provide handy methods and locale-aware * formatting helpers. * - * @phpstan-immutable + * @immutable */ class DateTime extends Chronos implements JsonSerializable, Stringable { diff --git a/src/I18n/RelativeTimeFormatter.php b/src/I18n/RelativeTimeFormatter.php index b202daad0c4..56fd9b96a2b 100644 --- a/src/I18n/RelativeTimeFormatter.php +++ b/src/I18n/RelativeTimeFormatter.php @@ -413,9 +413,8 @@ public function dateAgoInWords(DateTime|Date $date, array $options = []): string * Build the options for relative date formatting. * * @param array $options The options provided by the user. - * @param string $class The class name to use for defaults. + * @param class-string<\Cake\I18n\Date>|class-string<\Cake\I18n\DateTime> $class The class name to use for defaults. * @return array Options with defaults applied. - * @phpstan-param class-string<\Cake\I18n\Date>|class-string<\Cake\I18n\DateTime> $class */ protected function _options(array $options, string $class): array { diff --git a/src/I18n/Time.php b/src/I18n/Time.php index 79def737ae2..8913e2c64b3 100644 --- a/src/I18n/Time.php +++ b/src/I18n/Time.php @@ -28,7 +28,7 @@ * * Adds handy methods and locale-aware formatting helpers. * - * @phpstan-immutable + * @immutable */ class Time extends ChronosTime implements JsonSerializable, Stringable { diff --git a/src/Log/Log.php b/src/Log/Log.php index 0440836c1ac..ffecaf2b54f 100644 --- a/src/Log/Log.php +++ b/src/Log/Log.php @@ -115,8 +115,7 @@ class Log /** * An array mapping url schemes to fully qualified Log engine class names * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'console' => Engine\ConsoleLog::class, diff --git a/src/Mailer/AbstractTransport.php b/src/Mailer/AbstractTransport.php index 4964d1c10c6..e72218490cd 100644 --- a/src/Mailer/AbstractTransport.php +++ b/src/Mailer/AbstractTransport.php @@ -37,8 +37,7 @@ abstract class AbstractTransport * Send mail * * @param \Cake\Mailer\Message $message Email message. - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ abstract public function send(Message $message): array; diff --git a/src/Mailer/Mailer.php b/src/Mailer/Mailer.php index 3f4b32c6250..efc69281927 100644 --- a/src/Mailer/Mailer.php +++ b/src/Mailer/Mailer.php @@ -153,8 +153,7 @@ class Mailer implements EventListenerInterface /** * Message class name. * - * @var string - * @phpstan-var class-string<\Cake\Mailer\Message> + * @var class-string<\Cake\Mailer\Message> */ protected string $messageClass = Message::class; @@ -187,8 +186,7 @@ class Mailer implements EventListenerInterface /** * Mailer driver class map. * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = []; @@ -315,8 +313,7 @@ public function setViewVars(array|string $key, mixed $value = null) * If no action is specified then all other method arguments will be ignored. * @param array $args Arguments to pass to the triggered mailer action. * @param array $headers Headers to set. - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. * @throws \Cake\Mailer\Exception\MissingActionException * @throws \BadMethodCallException */ @@ -373,8 +370,7 @@ public function render(string $content = '') * Render content and send email using configured transport. * * @param string $content Content. - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ public function deliver(string $content = ''): array { @@ -552,8 +548,7 @@ public function reset() /** * Log the email message delivery. * - * @param array $contents The content with 'headers' and 'message' keys. - * @phpstan-param array{headers: string, message: string, ...} $contents + * @param array{headers: string, message: string, ...} $contents The content with 'headers' and 'message' keys. * @return void */ protected function logDelivery(array $contents): void diff --git a/src/Mailer/Renderer.php b/src/Mailer/Renderer.php index d1d9f411fdf..1459a2afebd 100644 --- a/src/Mailer/Renderer.php +++ b/src/Mailer/Renderer.php @@ -49,10 +49,8 @@ public function __construct() * of the specified content types for the email. * * @param string $content The content. - * @param array $types Content types to render. Valid array values are {@link Message::MESSAGE_HTML}, {@link Message::MESSAGE_TEXT}. - * @return array The rendered content with "html" and/or "text" keys. - * @phpstan-param array<\Cake\Mailer\Message::MESSAGE_HTML|\Cake\Mailer\Message::MESSAGE_TEXT> $types - * @phpstan-return array{html?: string, text?: string} + * @param array<\Cake\Mailer\Message::MESSAGE_HTML|\Cake\Mailer\Message::MESSAGE_TEXT> $types Content types to render. Valid array values are {@link Message::MESSAGE_HTML}, {@link Message::MESSAGE_TEXT}. + * @return array{html?: string, text?: string} The rendered content with "html" and/or "text" keys. */ public function render(string $content, array $types = []): array { diff --git a/src/Mailer/Transport/SmtpTransport.php b/src/Mailer/Transport/SmtpTransport.php index 90f77292c91..47378f4e3ec 100644 --- a/src/Mailer/Transport/SmtpTransport.php +++ b/src/Mailer/Transport/SmtpTransport.php @@ -202,8 +202,7 @@ public function getLastResponse(): array * Send mail * * @param \Cake\Mailer\Message $message Message instance - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. * @throws \Cake\Network\Exception\SocketException */ public function send(Message $message): array diff --git a/src/Mailer/TransportFactory.php b/src/Mailer/TransportFactory.php index 4794e899ed8..a35e149f8d7 100644 --- a/src/Mailer/TransportFactory.php +++ b/src/Mailer/TransportFactory.php @@ -36,8 +36,7 @@ class TransportFactory /** * An array mapping url schemes to fully qualified Transport class names * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'debug' => Transport\DebugTransport::class, diff --git a/src/ORM/AssociationCollection.php b/src/ORM/AssociationCollection.php index a286d783f6a..05667b364d6 100644 --- a/src/ORM/AssociationCollection.php +++ b/src/ORM/AssociationCollection.php @@ -68,13 +68,11 @@ public function __construct(?LocatorInterface $tableLocator = null) * If the alias added contains a `.` the part preceding the `.` will be dropped. * This makes using plugins simpler as the Plugin.Class syntax is frequently used. * + * @template T of \Cake\ORM\Association * @param string $alias The association alias - * @param \Cake\ORM\Association $association The association to add. - * @return \Cake\ORM\Association The association object being added. + * @param T $association The association to add. + * @return T The association object being added. * @throws \Cake\Core\Exception\CakeException If the alias is already added. - * @template T of \Cake\ORM\Association - * @phpstan-param T $association - * @phpstan-return T */ public function add(string $alias, Association $association): Association { @@ -90,14 +88,12 @@ public function add(string $alias, Association $association): Association /** * Creates and adds the Association object to this collection. * - * @param string $className The name of association class. + * @template T of \Cake\ORM\Association + * @param class-string $className The name of association class. * @param string $associated The alias for the target table. * @param array $options List of options to configure the association definition. - * @return \Cake\ORM\Association + * @return T * @throws \InvalidArgumentException - * @template T of \Cake\ORM\Association - * @phpstan-param class-string $className - * @phpstan-return T */ public function load(string $className, string $associated, array $options = []): Association { diff --git a/src/ORM/Behavior/TranslateBehavior.php b/src/ORM/Behavior/TranslateBehavior.php index c1950963ca5..4d9c2f3d8b0 100644 --- a/src/ORM/Behavior/TranslateBehavior.php +++ b/src/ORM/Behavior/TranslateBehavior.php @@ -73,8 +73,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface /** * Default strategy class name. * - * @var string - * @phpstan-var class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> + * @var class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> */ protected static string $defaultStrategyClass = ShadowTableStrategy::class; @@ -136,10 +135,9 @@ public function initialize(array $config): void /** * Set default strategy class name. * - * @param string $class Class name. + * @param class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $class Class name. * @return void * @since 4.0.0 - * @phpstan-param class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $class */ public static function setDefaultStrategyClass(string $class): void { @@ -149,9 +147,8 @@ public static function setDefaultStrategyClass(string $class): void /** * Get default strategy class name. * - * @return string + * @return class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> * @since 4.0.0 - * @phpstan-return class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> */ public static function getDefaultStrategyClass(): string { diff --git a/src/ORM/BehaviorRegistry.php b/src/ORM/BehaviorRegistry.php index b32c93fc2ba..4e6e3c95e3b 100644 --- a/src/ORM/BehaviorRegistry.php +++ b/src/ORM/BehaviorRegistry.php @@ -91,8 +91,7 @@ public function setTable(Table $table): void * Resolve a behavior classname. * * @param string $class Partial classname to resolve. - * @return string|null Either the correct classname or null. - * @phpstan-return class-string|null + * @return class-string|null Either the correct classname or null. */ public static function className(string $class): ?string { diff --git a/src/ORM/Table.php b/src/ORM/Table.php index a6810a8e26c..1ed4860d007 100644 --- a/src/ORM/Table.php +++ b/src/ORM/Table.php @@ -263,8 +263,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc /** * The name of the class that represent a single row for this table * - * @var string|null - * @phpstan-var class-string|null + * @var class-string|null */ protected ?string $_entityClass = null; @@ -843,11 +842,9 @@ public function behaviors(): BehaviorRegistry /** * Get a behavior from the registry. * - * @param string $name The behavior alias to get from the registry. - * @return \Cake\ORM\Behavior + * @param TName $name The behavior alias to get from the registry. + * @return (TName is key-of ? TBehaviors[TName] : \Cake\ORM\Behavior) * @template TName of string - * @phpstan-param TName $name The behavior alias to get from the registry. - * @phpstan-return (TName is key-of ? TBehaviors[TName] : \Cake\ORM\Behavior) * @throws \InvalidArgumentException If the behavior does not exist. */ public function getBehavior(string $name): Behavior diff --git a/src/Routing/Asset.php b/src/Routing/Asset.php index a76dcbe5dee..16708c63adf 100644 --- a/src/Routing/Asset.php +++ b/src/Routing/Asset.php @@ -355,8 +355,7 @@ protected static function requestWebroot(): string * It checks if the plugin is loaded, else filename will stay unchanged for filenames containing dot. * * @param string $name The name you want to plugin split. - * @return array Array with 2 indexes. 0 => plugin name, 1 => filename. - * @phpstan-return array{string|null, string} + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => filename. */ protected static function pluginSplit(string $name): array { diff --git a/src/TestSuite/TestCase.php b/src/TestSuite/TestCase.php index 368db63f82b..c68b7852cad 100644 --- a/src/TestSuite/TestCase.php +++ b/src/TestSuite/TestCase.php @@ -562,11 +562,10 @@ public function assertTextEquals(string $expected, string $result, string $messa * Asserts that a string starts with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $prefix The prefix to check for. + * @param non-empty-string $prefix The prefix to check for. * @param string $string The string to search in. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $prefix */ public function assertTextStartsWith(string $prefix, string $string, string $message = ''): void { @@ -580,11 +579,10 @@ public function assertTextStartsWith(string $prefix, string $string, string $mes * Asserts that a string starts not with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $prefix The prefix to not find. + * @param non-empty-string $prefix The prefix to not find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $prefix */ public function assertTextStartsNotWith(string $prefix, string $string, string $message = ''): void { @@ -598,11 +596,10 @@ public function assertTextStartsNotWith(string $prefix, string $string, string $ * Asserts that a string ends with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $suffix The suffix to find. + * @param non-empty-string $suffix The suffix to find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $suffix */ public function assertTextEndsWith(string $suffix, string $string, string $message = ''): void { @@ -616,11 +613,10 @@ public function assertTextEndsWith(string $suffix, string $string, string $messa * Asserts that a string ends not with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $suffix The suffix to not find. + * @param non-empty-string $suffix The suffix to not find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $suffix */ public function assertTextEndsNotWith(string $suffix, string $string, string $message = ''): void { diff --git a/src/TestSuite/TestEmailTransport.php b/src/TestSuite/TestEmailTransport.php index 0b34fd257ce..5ad9a2a6a0a 100644 --- a/src/TestSuite/TestEmailTransport.php +++ b/src/TestSuite/TestEmailTransport.php @@ -38,8 +38,7 @@ class TestEmailTransport extends DebugTransport * Stores email for later assertions * * @param \Cake\Mailer\Message $message Message - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ public function send(Message $message): array { diff --git a/src/Utility/Hash.php b/src/Utility/Hash.php index 35467611be5..5f96e2c3099 100644 --- a/src/Utility/Hash.php +++ b/src/Utility/Hash.php @@ -116,10 +116,9 @@ public static function get(ArrayAccess|array $data, array|string|int|null $path, * * @param \ArrayAccess|array $data The data to extract from. * @param string $path The path to extract. - * @return \ArrayAccess|array An array of the extracted values. Returns an empty array + * @return ($path is non-empty-string ? array : \ArrayAccess|array) An array of the extracted values. Returns an empty array * if there are no matches. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-extract - * @phpstan-return ($path is non-empty-string ? array : \ArrayAccess|array) */ public static function extract(ArrayAccess|array $data, string $path): ArrayAccess|array { @@ -297,8 +296,7 @@ protected static function _matches(ArrayAccess|array $data, string $selector): b * @param T $data The data to insert into. * @param string $path The path to insert at. * @param mixed $values The values to insert. - * @return \ArrayAccess|array The data with $values inserted. - * @phpstan-return (T is array ? array : \ArrayAccess) + * @return (T is array ? array : \ArrayAccess) The data with $values inserted. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-insert * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing * false positives where taint from one caller (e.g. ServerRequest::withData) bleeds into @@ -408,8 +406,7 @@ protected static function _simpleOp( * @template T of \ArrayAccess|array * @param T $data The data to operate on * @param string $path A path expression to use to remove. - * @return \ArrayAccess|array The modified array. - * @phpstan-return (T is array ? array : \ArrayAccess) + * @return (T is array ? array : \ArrayAccess) The modified array. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-remove * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing * false positives where taint from one caller (e.g. ServerRequest::withoutData) bleeds into @@ -566,12 +563,11 @@ public static function combine( * @param array $data Source array from which to extract the data * @param array $paths An array containing one or more Hash::extract()-style key paths * @param string $format Format string into which values will be inserted, see sprintf() - * @return array|null An array of strings extracted from `$path` and formatted with `$format`, + * @return ($paths is non-empty-array ? array : null) An array of strings extracted from `$path` and formatted with `$format`, * or null if $paths is empty. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-format * @see sprintf() * @see \Cake\Utility\Hash::extract() - * @phpstan-return ($paths is non-empty-array ? array : null) */ public static function format(array $data, array $paths, string $format): ?array { @@ -744,9 +740,8 @@ public static function flatten(array $data, string $separator = '.'): array * into a multi-dimensional array. So, `['0.Foo.Bar' => 'Far']` becomes * `[['Foo' => ['Bar' => 'Far']]]`. * - * @phpstan-param non-empty-string $separator * @param array $data Flattened array - * @param string $separator The delimiter used + * @param non-empty-string $separator The delimiter used * @return array * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-expand */ @@ -1229,12 +1224,11 @@ public static function normalize(array $data, bool $assoc = true, mixed $default * - `root` The id of the desired top-most result. * * @param array $data The data to nest. - * @param array $options Options. + * @param array{idPath?: string, parentPath?: string, children?: string, root?: string|null} $options Options. * @return array of results, nested * @see \Cake\Utility\Hash::extract() * @throws \InvalidArgumentException When providing invalid data. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-nest - * @phpstan-param array{idPath?: string, parentPath?: string, children?: string, root?: string|null} $options */ public static function nest(array $data, array $options = []): array { diff --git a/src/Utility/Text.php b/src/Utility/Text.php index ef2349b9eec..57a5099058c 100644 --- a/src/Utility/Text.php +++ b/src/Utility/Text.php @@ -422,10 +422,9 @@ public static function wrapBlock(string $text, array|int $options = []): string /** * Unicode and newline aware version of wordwrap. * - * @phpstan-param non-empty-string $break * @param string $text The text to format. * @param int $width The width to wrap to. Defaults to 72. - * @param string $break The line is broken using the optional break parameter. Defaults to '\n'. + * @param non-empty-string $break The line is broken using the optional break parameter. Defaults to '\n'. * @param bool $cut If the cut is set to true, the string is always wrapped at the specified width. * @return string Formatted text. */ diff --git a/src/Utility/Xml.php b/src/Utility/Xml.php index c49c4d4771f..442a4e01035 100644 --- a/src/Utility/Xml.php +++ b/src/Utility/Xml.php @@ -412,9 +412,8 @@ protected static function _fromArray( /** * Helper to _fromArray(). It will create children of arrays * - * @param array $data Array with information to create children + * @param array{dom: \DOMDocument, node: \DOMNode, key: string, format: string, value?: mixed} $data Array with information to create children * @return void - * @phpstan-param array{dom: \DOMDocument, node: \DOMNode, key: string, format: string, value?: mixed} $data */ protected static function _createChild(array $data): void { diff --git a/src/Validation/RulesProvider.php b/src/Validation/RulesProvider.php index 077b7cc77f0..ef98036185f 100644 --- a/src/Validation/RulesProvider.php +++ b/src/Validation/RulesProvider.php @@ -46,9 +46,8 @@ class RulesProvider /** * Constructor, sets the default class to use for calling methods * - * @param object|string $class the default class to proxy + * @param object|class-string $class the default class to proxy * @throws \ReflectionException - * @phpstan-param object|class-string $class */ public function __construct(object|string $class = Validation::class) { diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index 81934e83c26..e407236b2ba 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -139,16 +139,14 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable * An associative array of objects or classes containing methods * used for validation * - * @var array - * @phpstan-var array + * @var array */ protected array $_providers = []; /** * An associative array of objects or classes used as a default provider list * - * @var array - * @phpstan-var array + * @var array */ protected static array $_defaultProviders = []; @@ -314,8 +312,7 @@ public function hasField(string $name): bool * when called will receive the full list of providers stored in this validator. * * @param string $name The name under which the provider should be set. - * @param object|string $object Provider object or class name. - * @phpstan-param object|class-string $object + * @param object|class-string $object Provider object or class name. * @return $this */ public function setProvider(string $name, object|string $object) @@ -351,8 +348,7 @@ public static function getDefaultProvider(string $name): object|string|null * Associates an object to a name so it can be used as a default provider. * * @param string $name The name under which the provider should be set. - * @param object|string $object Provider object or class name. - * @phpstan-param object|class-string $object + * @param object|class-string $object Provider object or class name. * @return void */ public static function addDefaultProvider(string $name, object|string $object): void diff --git a/src/View/Helper/HtmlHelper.php b/src/View/Helper/HtmlHelper.php index aeed440de05..98d450c4996 100644 --- a/src/View/Helper/HtmlHelper.php +++ b/src/View/Helper/HtmlHelper.php @@ -523,11 +523,10 @@ public function script(array|string $url, array $options = []): ?string /** * Generate the "importmap" script tag. * - * @param array $map Map array. + * @param array{imports?: array, scopes?: array>>, integrity?: array} $map Map array. * @param array $options Same options as `UrlHelper::script()`. * @return string * @since 5.2.0 - * @phpstan-param array{imports?: array, scopes?: array>>, integrity?: array} $map */ public function importmap(array $map, array $options = []): string { diff --git a/src/View/Helper/PaginatorHelper.php b/src/View/Helper/PaginatorHelper.php index ecfa89f4359..16f80a22e45 100644 --- a/src/View/Helper/PaginatorHelper.php +++ b/src/View/Helper/PaginatorHelper.php @@ -755,8 +755,7 @@ public function numbers(array $options = []): string * * @param array $params Params from the numbers() method. * @param array $options Options from the numbers() method. - * @return array An array with the start and end numbers. - * @phpstan-return array{0: int, 1: int} + * @return array{0: int, 1: int} An array with the start and end numbers. */ protected function _getNumbersStartAndEnd(array $params, array $options): array { diff --git a/src/View/View.php b/src/View/View.php index 7010edef7a7..54fd512df4d 100644 --- a/src/View/View.php +++ b/src/View/View.php @@ -285,8 +285,7 @@ class View implements EventDispatcherInterface /** * ViewBlock class. * - * @var string - * @phpstan-var class-string<\Cake\View\ViewBlock> + * @var class-string<\Cake\View\ViewBlock> */ protected string $_viewBlockClass = ViewBlock::class; @@ -650,7 +649,7 @@ public function setLayout(string $name) * or `MyPlugin.template` to use the template element from MyPlugin. If the element * is not found in the plugin, the normal view path cascade will be searched. * @param array $data Array of data to be made available to the rendered view (i.e. the Element) - * @param array $options Array of options. Possible keys are: + * @param array{cache?:array|true, callbacks?:bool, plugin?:string|false, ignoreMissing?:bool} $options Array of options. Possible keys are: * * - `cache` - Can either be `true`, to enable caching using the config in View::$elementCache. Or an array * If an array, the following keys can be used: @@ -666,7 +665,6 @@ public function setLayout(string $name) * @return string Rendered Element * @throws \Cake\View\Exception\MissingElementException When an element is missing and `ignoreMissing` * is false. - * @phpstan-param array{cache?:array|true, callbacks?:bool, plugin?:string|false, ignoreMissing?:bool} $options */ public function element(string $name, array $data = [], array $options = []): string { @@ -1449,8 +1447,7 @@ protected function _checkFilePath(string $file, ?string $plugin): string * * @param string $name The name you want to plugin split. * @param bool $fallback If true uses the plugin set in the current Request when parsed plugin is not loaded - * @return array Array with 2 indexes. 0 => plugin name, 1 => filename. - * @phpstan-return array{string|null, string} + * @return array{0:string|null, 1:string} Array with 2 indexes. 0 => plugin name, 1 => filename. */ public function pluginSplit(string $name, bool $fallback = true): array { @@ -1655,13 +1652,12 @@ protected function _paths(?string $plugin = null, bool $cached = true): array * @param string $name Element name * @param array $data Data * @param array $options Element options - * @return array Element Cache configuration. - * @phpstan-return array{key:string, config:string} + * @return array{key:string, config:string} Element Cache configuration. */ protected function _elementCache(string $name, array $data, array $options): array { if (isset($options['cache']['key'], $options['cache']['config'])) { - /** @phpstan-var array{key:string, config:string} $cache */ + /** @var array{key:string, config:string} $cache */ $cache = $options['cache']; $cache['key'] = 'element_' . $cache['key']; diff --git a/src/View/ViewBuilder.php b/src/View/ViewBuilder.php index adaba7271bc..b6200e84a7f 100644 --- a/src/View/ViewBuilder.php +++ b/src/View/ViewBuilder.php @@ -111,8 +111,7 @@ class ViewBuilder implements JsonSerializable * Can either use plugin notation, a short name * or a fully namespaced classname. * - * @var string|null - * @phpstan-var class-string<\Cake\View\View>|string|null + * @var class-string<\Cake\View\View>|string|null */ protected ?string $_className = null; From 61331b2aff7bc562a82bda4b221d3c00bf721d91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:42:23 +0000 Subject: [PATCH 13/30] Bump codecov/codecov-action from 6 to 7 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v6...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd265c7261f..46c02351c7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,7 +164,7 @@ jobs: - name: Submit code coverage if: matrix.php-version == '8.2' - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: coverage.xml,coverage-database.xml,coverage-functions.xml token: ${{ secrets.CODECOV_TOKEN }} From 786fe293cc7d28ac822cefa169356de92312fdc1 Mon Sep 17 00:00:00 2001 From: XananasX Date: Tue, 9 Jun 2026 04:31:44 +0100 Subject: [PATCH 14/30] [Security] Add allowedClasses config to RedisEngine to prevent PHP Object Injection (#19485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: add allowedClasses config to prevent PHP Object Injection in cache unserialize() RedisEngine and FileEngine both call unserialize() on data fetched from their respective backends without restricting the allowed_classes option. An attacker who can write to the cache backend (e.g., via a compromised Redis instance, APC race, or a cache-poisoning vulnerability in the application) can trigger PHP Object Injection by injecting a crafted serialized payload containing a gadget chain. Add an 'allowedClasses' configuration option (default: true = allow all, for backwards compatibility) to both engines. Applications that only cache scalar or array values can set 'allowedClasses' => false to eliminate the attack surface; applications caching known object types can enumerate only those classes. * revert FileEngine.php to master — only harden Redis/Memcache backends * Add test coverage for allowedClasses and fix failing config tests - Add the new allowedClasses key to the expected default config arrays in RedisEngineTest and RedisClusterEngineTest (the new option broke the testConfig / testConfigDsn / testConfigDsnSSLContext assertions). - Cover the allowedClasses behavior: default (true) unserializes objects, false yields incomplete class instances, an array whitelist allows only listed classes, and scalars/arrays round-trip in every mode. - Move the allowedClasses documentation into the class docblock option list to match the convention used for every other config key. --------- Co-authored-by: XananasX7 Co-authored-by: Mark Scherer --- src/Cache/Engine/RedisEngine.php | 12 +++ .../Cache/Engine/RedisClusterEngineTest.php | 1 + .../TestCase/Cache/Engine/RedisEngineTest.php | 79 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/src/Cache/Engine/RedisEngine.php b/src/Cache/Engine/RedisEngine.php index f7d6f06ffdb..d35fccd9bf9 100644 --- a/src/Cache/Engine/RedisEngine.php +++ b/src/Cache/Engine/RedisEngine.php @@ -86,6 +86,12 @@ class RedisEngine extends CacheEngine * - `clearUsesFlushDb` Enable clear() and clearBlocking() to use FLUSHDB. This will be * faster than standard clear()/clearBlocking() but will ignore prefixes and will * cause dataloss if other applications are sharing a redis database. + * - `allowedClasses` Controls the `allowed_classes` option passed to `unserialize()` + * when reading values back. Set to `false` to disallow all object unserialization + * (safest when the cache only stores scalar/array values), or provide an array of + * fully qualified class names to allow only those classes. Useful for hardening + * against PHP object injection when a cache backend is shared across applications. + * Defaults to `true` (allow all) for backwards compatibility. * * @var array */ @@ -108,6 +114,7 @@ class RedisEngine extends CacheEngine 'nodes' => [], 'failover' => null, 'clearUsesFlushDb' => false, + 'allowedClasses' => true, ]; /** @@ -669,6 +676,11 @@ protected function unserialize(string $value): mixed return (int)$value; } + $allowedClasses = $this->getConfig('allowedClasses'); + if ($allowedClasses !== true) { + return unserialize($value, ['allowed_classes' => $allowedClasses]); + } + return unserialize($value); } diff --git a/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php index 2b557dc7eea..bf79ad7f5a8 100644 --- a/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php +++ b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php @@ -136,6 +136,7 @@ public function testConfig(): void 'unix_socket' => false, 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } diff --git a/tests/TestCase/Cache/Engine/RedisEngineTest.php b/tests/TestCase/Cache/Engine/RedisEngineTest.php index 86a62f06cd0..1d0b9c5d66e 100644 --- a/tests/TestCase/Cache/Engine/RedisEngineTest.php +++ b/tests/TestCase/Cache/Engine/RedisEngineTest.php @@ -16,12 +16,14 @@ */ namespace Cake\Test\TestCase\Cache\Engine; +use __PHP_Incomplete_Class; use Cake\Cache\Cache; use Cake\Cache\Engine\RedisEngine; use Cake\TestSuite\TestCase; use DateInterval; use Mockery; use Redis; +use stdClass; use function Cake\Core\env; /** @@ -123,6 +125,7 @@ public function testConfig(): void 'nodes' => [], 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } @@ -157,6 +160,7 @@ public function testConfigDsn(): void 'nodes' => [], 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } @@ -198,6 +202,7 @@ public function testConfigDsnSSLContext(): void 'nodes' => [], 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } @@ -539,6 +544,80 @@ public function testReadAndWriteCache(): void Cache::delete('test', 'redis'); } + /** + * Objects are unserialized normally with the default `allowedClasses` of `true`. + */ + public function testAllowedClassesDefaultAllowsObjects(): void + { + $this->_configCache(); + + $data = new stdClass(); + $data->foo = 'bar'; + $this->assertTrue(Cache::write('object', $data, 'redis')); + + $result = Cache::read('object', 'redis'); + $this->assertInstanceOf(stdClass::class, $result); + $this->assertSame('bar', $result->foo); + + Cache::delete('object', 'redis'); + } + + /** + * Setting `allowedClasses` to `false` blocks object unserialization, + * yielding an incomplete class instance instead of the original object. + */ + public function testAllowedClassesFalseBlocksObjects(): void + { + $this->_configCache(['allowedClasses' => false]); + + $data = new stdClass(); + $data->foo = 'bar'; + $this->assertTrue(Cache::write('object', $data, 'redis')); + + $result = Cache::read('object', 'redis'); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result); + + Cache::delete('object', 'redis'); + } + + /** + * An array of `allowedClasses` permits only the listed classes; others + * are returned as incomplete class instances. + */ + public function testAllowedClassesWhitelist(): void + { + $this->_configCache(['allowedClasses' => [stdClass::class]]); + + $allowed = new stdClass(); + $allowed->foo = 'bar'; + $this->assertTrue(Cache::write('allowed', $allowed, 'redis')); + $this->assertInstanceOf(stdClass::class, Cache::read('allowed', 'redis')); + + $blocked = new DateInterval('PT1S'); + $this->assertTrue(Cache::write('blocked', $blocked, 'redis')); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, Cache::read('blocked', 'redis')); + + Cache::delete('allowed', 'redis'); + Cache::delete('blocked', 'redis'); + } + + /** + * Integers keep round-tripping correctly regardless of `allowedClasses`. + */ + public function testAllowedClassesPreservesScalars(): void + { + $this->_configCache(['allowedClasses' => false]); + + $this->assertTrue(Cache::write('int', 42, 'redis')); + $this->assertSame(42, Cache::read('int', 'redis')); + + $this->assertTrue(Cache::write('array', ['a' => 1, 'b' => 2], 'redis')); + $this->assertSame(['a' => 1, 'b' => 2], Cache::read('array', 'redis')); + + Cache::delete('int', 'redis'); + Cache::delete('array', 'redis'); + } + /** * Test get with default value */ From b4c307e61e1f85dece09c6dd0b964930652d10f5 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 13 Jun 2026 08:29:29 +0530 Subject: [PATCH 15/30] Add phpcs sniff to report namespace to filename mismatch as per PSR-4 (#19467) --- phpcs.xml | 11 +++++++++++ .../Error/Renderer/WebExceptionRendererTest.php | 2 +- tests/TestCase/Http/MimeTypeTest.php | 2 +- .../TestCase/TestSuite/Fixture/FixtureHelperTest.php | 2 +- .../TestSuite/Fixture/TransactionStrategyTest.php | 2 +- .../TestSuite/Fixture/TruncateStrategyTest.php | 2 +- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/phpcs.xml b/phpcs.xml index 71c1b80d43f..2f409ba72bb 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -16,6 +16,17 @@ + + + + + + + + + + tests/test_app/* + diff --git a/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php b/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php index b41a1c17163..d05228b0301 100644 --- a/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php +++ b/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php @@ -14,7 +14,7 @@ * @since 2.0.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\Error; +namespace Cake\Test\TestCase\Error\Renderer; use Cake\Controller\Controller; use Cake\Controller\ErrorController; diff --git a/tests/TestCase/Http/MimeTypeTest.php b/tests/TestCase/Http/MimeTypeTest.php index 75c18bcf42d..cd8fefdceb9 100644 --- a/tests/TestCase/Http/MimeTypeTest.php +++ b/tests/TestCase/Http/MimeTypeTest.php @@ -14,7 +14,7 @@ * @since 5.2.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Http\Test\TestCase; +namespace Cake\Test\TestCase\Http; use Cake\Http\MimeType; use Cake\TestSuite\TestCase; diff --git a/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php b/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php index 86f53df35a3..ecb6fa84bc1 100644 --- a/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php +++ b/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php @@ -14,7 +14,7 @@ * @since 4.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\TestSuite; +namespace Cake\Test\TestCase\TestSuite\Fixture; use Cake\Core\Exception\CakeException; use Cake\Database\Connection; diff --git a/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php b/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php index 6bf0bad00e2..1cc600dee37 100644 --- a/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php +++ b/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php @@ -14,7 +14,7 @@ * @since 4.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\TestSuite; +namespace Cake\Test\TestCase\TestSuite\Fixture; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\Fixture\TransactionStrategy; diff --git a/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php b/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php index d56ddc122cc..057cbaebe13 100644 --- a/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php +++ b/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php @@ -14,7 +14,7 @@ * @since 4.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\TestSuite; +namespace Cake\Test\TestCase\TestSuite\Fixture; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\Fixture\TruncateStrategy; From f802f3ec3ad691a4aa1e26550228feed75df07cb Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 19 Jun 2026 05:11:16 +0200 Subject: [PATCH 16/30] Respect curl.cainfo php.ini setting in Curl adapter (#19507) * Respect curl.cainfo php.ini setting in Curl adapter When no `ssl_cafile` option is given, the Curl client adapter hardcoded the CA bundle shipped with composer/ca-bundle, ignoring an explicitly configured `curl.cainfo` in php.ini. This broke enterprise/containerized environments that inject a custom CA globally, even though native curl honored it. Prefer `curl.cainfo` (when set) before falling back to the bundled CA file. An explicitly passed `ssl_cafile` option still takes highest priority. * Minimize inline comment * Apply suggestion from @ADmad --------- Co-authored-by: ADmad --- src/Http/Client/Adapter/Curl.php | 2 +- .../TestCase/Http/Client/Adapter/CurlTest.php | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Http/Client/Adapter/Curl.php b/src/Http/Client/Adapter/Curl.php index 40aca2afe0a..118b2539292 100644 --- a/src/Http/Client/Adapter/Curl.php +++ b/src/Http/Client/Adapter/Curl.php @@ -128,7 +128,7 @@ public function buildOptions(RequestInterface $request, array $options): array } if (empty($options['ssl_cafile'])) { - $options['ssl_cafile'] = CaBundle::getBundledCaBundlePath(); + $options['ssl_cafile'] = ini_get('curl.cainfo') ?: CaBundle::getBundledCaBundlePath(); } if (!empty($options['ssl_verify_host'])) { // Value of 1 or true is deprecated. Only 2 or 0 should be used now. diff --git a/tests/TestCase/Http/Client/Adapter/CurlTest.php b/tests/TestCase/Http/Client/Adapter/CurlTest.php index 8341877bb38..48daf5e3f51 100644 --- a/tests/TestCase/Http/Client/Adapter/CurlTest.php +++ b/tests/TestCase/Http/Client/Adapter/CurlTest.php @@ -43,7 +43,9 @@ protected function setUp(): void $this->skipIf(!function_exists('curl_init'), 'Skipping as ext/curl is not installed.'); $this->curl = new Curl(); - $this->caFile = CaBundle::getBundledCaBundlePath(); + // Mirror the adapter default: an explicit `curl.cainfo` (php.ini) wins, + // otherwise the bundled CA file is used. + $this->caFile = ini_get('curl.cainfo') ?: CaBundle::getBundledCaBundlePath(); } /** @@ -122,6 +124,21 @@ public function testBuildOptionsGet(): void $this->assertSame($expected, $result); } + /** + * An explicitly passed `ssl_cafile` must take precedence over both the + * `curl.cainfo` php.ini setting and the bundled CA file. + */ + public function testBuildOptionsExplicitCafileWins(): void + { + $options = [ + 'ssl_cafile' => '/custom/ca-bundle.pem', + ]; + $request = new Request('http://localhost/things', 'GET'); + $result = $this->curl->buildOptions($request, $options); + + $this->assertSame('/custom/ca-bundle.pem', $result[CURLOPT_CAINFO]); + } + /** * Test converting client options into curl ones. */ From 012bf70df93233b41a8de34271dac4e5d15764e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:42:30 +0000 Subject: [PATCH 17/30] Bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/split-packages.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46c02351c7b..0527d0572f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: sleep 2 done - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -178,7 +178,7 @@ jobs: PHP_VERSION: '8.2' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -242,7 +242,7 @@ jobs: PHPSTAN_TESTS: 1 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -335,7 +335,7 @@ jobs: PHIVE_KEYS: 'CF1A108D0E7AE720,51C67305FFC2E5C0,12CE0F1D262429A5,99BF4D9A33D65E1E' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false diff --git a/.github/workflows/split-packages.yml b/.github/workflows/split-packages.yml index 3d1dea6588f..8bdb4db0c45 100644 --- a/.github/workflows/split-packages.yml +++ b/.github/workflows/split-packages.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false From 8a78e88ee56c111ef44bc6016754fae477f573ca Mon Sep 17 00:00:00 2001 From: ADmad Date: Thu, 25 Jun 2026 00:52:07 +0530 Subject: [PATCH 18/30] Fix missing trailing backtick --- src/Database/FunctionsBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/FunctionsBuilder.php b/src/Database/FunctionsBuilder.php index 2afbc46b53b..05ba42a2226 100644 --- a/src/Database/FunctionsBuilder.php +++ b/src/Database/FunctionsBuilder.php @@ -345,7 +345,7 @@ public function jsonValue( * @param string $name The SQL aggregate function name * @param array $params Array of arguments to be passed to the function. * Can be an associative array with the literal value or identifier: - * `['value' => 'literal']` or `['value' => 'identifier'] + * `['value' => 'literal']` or `['value' => 'identifier']` * @param array $types Array of types that match the names used in `$params`: * `['name' => 'type']` * @param string $return Return type of the entire expression. Defaults to float. From 3e09dae6cbdc983754fa3a8e6aae74da102a3ea1 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 26 Jun 2026 00:03:31 -0400 Subject: [PATCH 19/30] Strip CRLF from message headers in SmtpTransport Strip CRLF bytes from message headers when delivering mail by SmtpTransport. This prevents header injection should userland code supply user data into messages headers. Thank you to Himanshu Anand for reporting this issue. --- src/Mailer/Transport/SmtpTransport.php | 26 +++++++---- .../Mailer/Transport/MailTransportTest.php | 41 +++++++++++++++++ .../Mailer/Transport/SmtpTransportTest.php | 46 +++++++++++++++++++ 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/Mailer/Transport/SmtpTransport.php b/src/Mailer/Transport/SmtpTransport.php index 47378f4e3ec..175ad78f4cd 100644 --- a/src/Mailer/Transport/SmtpTransport.php +++ b/src/Mailer/Transport/SmtpTransport.php @@ -557,16 +557,22 @@ protected function _sendData(Message $message): void { $this->_smtpSend('DATA', '354'); - $headers = $message->getHeadersString([ - 'from', - 'sender', - 'replyTo', - 'readReceipt', - 'to', - 'cc', - 'subject', - 'returnPath', - ]); + $headers = $message->getHeadersString( + [ + 'from', + 'sender', + 'replyTo', + 'readReceipt', + 'to', + 'cc', + 'subject', + 'returnPath', + ], + "\r\n", + function (string $val): string { + return str_replace("\r\n", '', $val); + }, + ); $message = $this->_prepareMessage($message); $this->_smtpSend($headers . "\r\n\r\n" . $message . "\r\n\r\n\r\n."); diff --git a/tests/TestCase/Mailer/Transport/MailTransportTest.php b/tests/TestCase/Mailer/Transport/MailTransportTest.php index 0d1944e3f74..332748517a0 100644 --- a/tests/TestCase/Mailer/Transport/MailTransportTest.php +++ b/tests/TestCase/Mailer/Transport/MailTransportTest.php @@ -114,4 +114,45 @@ public function testSendData(): void $this->assertStringContainsString('Subject: ', $result['headers']); $this->assertStringContainsString('To: ', $result['headers']); } + + /** + * test send strips crlf from headers + */ + public function testSendHeadersStripCrlf(): void + { + $eol = "\r\n"; + $date = date(DATE_RFC2822); + + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setSubject('injected headers'); + $message->setMessageId(''); + + $message->setHeaders([ + 'X-inject' => "line one\r\nline two", + ]); + $message->setBody(['text' => "First Line\nSecond Line"]); + + $data = "From: CakePHP Test {$eol}"; + $data .= 'X-inject: line oneline two' . $eol; + $data .= 'Date: ' . $date . $eol; + $data .= 'Message-ID: ' . $eol; + $data .= "MIME-Version: 1.0{$eol}"; + $data .= "Content-Type: text/plain; charset=UTF-8{$eol}"; + $data .= 'Content-Transfer-Encoding: 8bit'; + + $this->MailTransport->expects($this->once())->method('_mail') + ->with( + 'CakePHP ', + 'injected headers', + implode($eol, ['First Line', 'Second Line', '', '']), + $data, + '-f', + ); + + $result = $this->MailTransport->send($message); + $this->assertStringContainsString('Subject: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } } diff --git a/tests/TestCase/Mailer/Transport/SmtpTransportTest.php b/tests/TestCase/Mailer/Transport/SmtpTransportTest.php index 2cf22ec7b1d..ad0636917e2 100644 --- a/tests/TestCase/Mailer/Transport/SmtpTransportTest.php +++ b/tests/TestCase/Mailer/Transport/SmtpTransportTest.php @@ -600,6 +600,52 @@ public function testSendData(): void $this->SmtpTransport->sendData($message); } + /** + * test send strips crlf from headers + */ + public function testSendHeadersStripCrlf(): void + { + $eol = "\r\n"; + $date = date(DATE_RFC2822); + + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setSubject('injected headers'); + $message->setMessageId(''); + + $message->setHeaders([ + 'X-inject' => "line one\r\nline two", + ]); + $message->setBody(['text' => 'oh no']); + + $data = "From: CakePHP Test {$eol}"; + $data .= 'To: CakePHP ' . $eol; + $data .= 'X-inject: line oneline two' . $eol; + $data .= 'Date: ' . $date . $eol; + $data .= 'Message-ID: ' . $eol; + $data .= 'Subject: injected headers' . $eol; + $data .= "MIME-Version: 1.0{$eol}"; + $data .= "Content-Type: text/plain; charset=UTF-8{$eol}"; + $data .= 'Content-Transfer-Encoding: 8bit' . $eol; + $data .= "\r\n"; + $data .= "oh no\r\n"; + $data .= "\r\n\r\n"; + $data .= "\r\n\r\n.\r\n"; + + $this->socket->shouldReceive('read') + ->andReturn( + "354 OK\r\n", + "250 OK\r\n", + ) + ->twice(); + + $this->socket->shouldReceive('write')->with("DATA\r\n")->once(); + $this->socket->shouldReceive('write')->with($data)->once(); + + $this->SmtpTransport->sendData($message); + } + /** * testQuit method */ From 9fbd156ea2430a2dcb5b3a40b280c914c6cdcb1f Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 27 Jun 2026 15:46:17 +0530 Subject: [PATCH 20/30] Include enum class name in the deprecation message (#19514) --- src/View/Helper/FormHelper.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index c9bc5c57fa6..0b08a12c9f2 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -1401,7 +1401,13 @@ protected function enumOptions(string $enumClass): array $hasLabel = true; } elseif (method_exists($enumClass, 'label')) { $hasLabel = true; - deprecationWarning('5.2.0', 'Enums with the `label()` method must implement the `EnumLabelInterface`.'); + deprecationWarning( + '5.2.0', + sprintf( + 'Enum class `%s` with a `label()` method must implement the `EnumLabelInterface`.', + $enumClass, + ), + ); } else { $hasLabel = false; } From 744ebd7141f582ba72c9f19189b41659a30da64c Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 28 Jun 2026 20:37:17 +0530 Subject: [PATCH 21/30] Fix docblock type (#19515) * Fix docblock type * Simplify code --- src/Database/Schema/Column.php | 4 ++-- src/Database/Schema/TableSchema.php | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php index 3706ee67548..aa6d2ba0e52 100644 --- a/src/Database/Schema/Column.php +++ b/src/Database/Schema/Column.php @@ -409,7 +409,7 @@ public function setComment(?string $comment) /** * Gets the column comment. * - * @return string + * @return string|null */ public function getComment(): ?string { @@ -597,7 +597,7 @@ public function setAttributes(array $attributes) /** * Convert an index into an array that is compatible with the Column constructor. * - * @return array + * @return array{name: ?string, baseType: ?string, type: string, length: ?int, null: ?bool, default: mixed, generated: ?string, unsigned: ?bool, onUpdate: ?string, collate: ?string, precision: ?int, srid: ?int, comment: ?string, autoIncrement: bool, identity: bool, fixed: ?bool, geometryType?: ?string} */ public function toArray(): array { diff --git a/src/Database/Schema/TableSchema.php b/src/Database/Schema/TableSchema.php index 49c7b4479c7..7a4b633ae82 100644 --- a/src/Database/Schema/TableSchema.php +++ b/src/Database/Schema/TableSchema.php @@ -435,18 +435,14 @@ public function getColumn(string $name): ?array if (isset(static::$_columnExtras[$attrs['type']])) { $expected += static::$_columnExtras[$attrs['type']]; } - // Remove any attributes that weren't in the allow list. - // This is to provide backwards compatible keys - $remove = array_diff(array_keys($attrs), array_keys($expected)); - foreach ($remove as $key) { - unset($attrs[$key]); - } if (isset($attrs['baseType']) && $attrs['baseType'] === $attrs['type']) { unset($attrs['baseType']); } - return $attrs; + // Remove any attributes that weren't in the allow list. + // This is to provide backwards compatible keys + return array_intersect_key($attrs, $expected); } /** From a9a8942f1703e245d84cf493854830a2443fd310 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:37:27 -0400 Subject: [PATCH 22/30] Bump actions/cache from 5 to 6 (#19519) Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0527d0572f6..a1a7e0f76f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,7 +195,7 @@ jobs: key: ${{ steps.key-date.outputs.date }} - name: Cache PHP extensions - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.php-ext-cache.outputs.dir }} key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} @@ -260,7 +260,7 @@ jobs: uses: ramsey/composer-install@v4 - name: Cache phive tools - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.phive @@ -283,7 +283,7 @@ jobs: - name: Setup phpcs cache if: always() - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ runner.temp }}/phpcs.cache key: ${{ runner.os }}-phpcs-${{ hashFiles('phpcs.xml', 'composer.lock') }} @@ -311,7 +311,7 @@ jobs: - name: Setup rector cache if: always() - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ runner.temp }}/rector key: ${{ runner.os }}-rector-${{ hashFiles('rector.php', 'composer.lock') }} @@ -353,7 +353,7 @@ jobs: uses: ramsey/composer-install@v4 - name: Cache phive tools - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.phive From 92443254f7643921959fdf72173023372334614d Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Tue, 30 Jun 2026 09:24:21 +0200 Subject: [PATCH 23/30] zizmor CI cleanup (#19517) zizmore CI cleanup --- .github/dependabot.yml | 4 ++++ .github/workflows/api-docs.yml | 4 +++- .github/workflows/cancel.yml | 18 ------------------ .github/workflows/ci.yml | 4 ++++ .github/workflows/split-packages.yml | 3 ++- 5 files changed, 13 insertions(+), 20 deletions(-) delete mode 100644 .github/workflows/cancel.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6647c42863e..78cb4271e62 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,8 +5,12 @@ updates: schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 - package-ecosystem: github-actions directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index 6a4e37236dd..495d772f691 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -22,9 +22,11 @@ jobs: private_key: ${{ secrets.CAKEBOT_APP_PRIVATE_KEY }} - name: Trigger API build + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} run: > curl -XPOST - -H 'Authorization: Bearer ${{ steps.app-token.outputs.token }}' + -H "Authorization: Bearer ${APP_TOKEN}" -H 'Accept: application/vnd.github.v3+json' -H 'Content-Type: application/json' https://api.github.com/repos/cakephp/cakephp-api-docs/actions/workflows/deploy_2x.yml/dispatches diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml deleted file mode 100644 index 263c669071d..00000000000 --- a/.github/workflows/cancel.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Cancel -on: - workflow_run: - workflows: ["CI"] - types: - - requested -permissions: - contents: read - -jobs: - cancel: - permissions: - actions: write # for styfle/cancel-workflow-action to cancel/stop running workflows - runs-on: ubuntu-latest - steps: - - uses: styfle/cancel-workflow-action@0.13.1 - with: - workflow_id: ${{ github.event.workflow.id }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1a7e0f76f3..502170e20e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: - '*' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read # to fetch code (actions/checkout) diff --git a/.github/workflows/split-packages.yml b/.github/workflows/split-packages.yml index 8bdb4db0c45..927da3474d3 100644 --- a/.github/workflows/split-packages.yml +++ b/.github/workflows/split-packages.yml @@ -28,7 +28,8 @@ jobs: - name: Push split packages env: + CURRENT_BRANCH: ${{ github.ref_name }} GITHUB_TOKEN: ${{ secrets.GH_SPLIT_PACKAGES_WRITE_TOKEN }} run: | - make CURRENT_BRANCH=${{ github.ref_name }} components + make CURRENT_BRANCH="${CURRENT_BRANCH}" components make clean-components-branches From 3ffe279203cab7a3c6c9cf0d5ce4aba0313699eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:37:28 +0200 Subject: [PATCH 24/30] Bump actions/cache from 6 to 6.1.0 (#19522) Bumps [actions/cache](https://github.com/actions/cache) from 6 to 6.1.0. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v6...v6.1.0) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 502170e20e4..f68d22c38e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,7 +199,7 @@ jobs: key: ${{ steps.key-date.outputs.date }} - name: Cache PHP extensions - uses: actions/cache@v6 + uses: actions/cache@v6.1.0 with: path: ${{ steps.php-ext-cache.outputs.dir }} key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} @@ -264,7 +264,7 @@ jobs: uses: ramsey/composer-install@v4 - name: Cache phive tools - uses: actions/cache@v6 + uses: actions/cache@v6.1.0 with: path: | ~/.phive @@ -287,7 +287,7 @@ jobs: - name: Setup phpcs cache if: always() - uses: actions/cache@v6 + uses: actions/cache@v6.1.0 with: path: ${{ runner.temp }}/phpcs.cache key: ${{ runner.os }}-phpcs-${{ hashFiles('phpcs.xml', 'composer.lock') }} @@ -315,7 +315,7 @@ jobs: - name: Setup rector cache if: always() - uses: actions/cache@v6 + uses: actions/cache@v6.1.0 with: path: ${{ runner.temp }}/rector key: ${{ runner.os }}-rector-${{ hashFiles('rector.php', 'composer.lock') }} @@ -357,7 +357,7 @@ jobs: uses: ramsey/composer-install@v4 - name: Cache phive tools - uses: actions/cache@v6 + uses: actions/cache@v6.1.0 with: path: | ~/.phive From ab608711674ac662af7315c5cdf1e0fbe2000e45 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Wed, 1 Jul 2026 12:37:52 -0400 Subject: [PATCH 25/30] Fix parameter handling in FunctionsBuilder (#19520) Fix weakness in FunctionsBuilder Several methods did not explicity label their parameters as unsafe, so it is possible for an application developer to mistakenly supply user controlled data into these parameters creating a SQL injection vector. Thank you to Himanshu Anand for reporting this issue. --- src/Database/FunctionsBuilder.php | 40 ++++++++++++---- .../Database/FunctionsBuilderTest.php | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/Database/FunctionsBuilder.php b/src/Database/FunctionsBuilder.php index 05ba42a2226..515e9a52ad8 100644 --- a/src/Database/FunctionsBuilder.php +++ b/src/Database/FunctionsBuilder.php @@ -23,7 +23,11 @@ /** * Contains methods related to generating FunctionExpression objects * with most commonly used SQL functions. + * * This acts as a factory for FunctionExpression objects. + * + * Supplying user-controlled data to parameters with the `ExpressionInterface|string` + * type is **unsafe**. These parameters are included in the final query without escaping. */ class FunctionsBuilder { @@ -40,7 +44,7 @@ public function rand(): FunctionExpression /** * Returns a AggregateExpression representing a call to SQL SUM function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the sum() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -57,7 +61,7 @@ public function sum(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL AVG function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the avg() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -69,7 +73,7 @@ public function avg(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL MAX function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the max() function * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -81,7 +85,7 @@ public function max(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL MIN function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the min() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -93,7 +97,7 @@ public function min(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL COUNT function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the count() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -133,11 +137,12 @@ public function coalesce(array $args, array $types = []): FunctionExpression * is the default type name. Use `setReturnType()` to update it. * * @param \Cake\Database\ExpressionInterface|string $field Field or expression to cast. - * @param string $dataType The SQL data type + * @param string $dataType The SQL data type. Must be a simple alphanumeric string. * @return \Cake\Database\Expression\FunctionExpression */ public function cast(ExpressionInterface|string $field, string $dataType): FunctionExpression { + $this->ensureSimpleString('dataType', $dataType); $expression = new FunctionExpression('CAST', $this->toLiteralParam($field)); return $expression->setConjunction(' AS')->add([$dataType => 'literal']); @@ -159,7 +164,7 @@ public function dateDiff(array $args, array $types = []): FunctionExpression /** * Returns the specified date part from the SQL expression. * - * @param string $part Part of the date to return. + * @param string $part Part of the date to return. Must be a simple alphanumeric string. * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\FunctionExpression @@ -175,13 +180,14 @@ public function datePart( /** * Returns the specified date part from the SQL expression. * - * @param string $part Part of the date to return. + * @param string $part Part of the date to return. Must be a simple alphanumeric string. * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\FunctionExpression */ public function extract(string $part, ExpressionInterface|string $expression, array $types = []): FunctionExpression { + $this->ensureSimpleString('part', $part); $expression = new FunctionExpression('EXTRACT', $this->toLiteralParam($expression), $types, 'integer'); return $expression->setConjunction(' FROM')->add([$part => 'literal'], [], true); @@ -192,7 +198,7 @@ public function extract(string $part, ExpressionInterface|string $expression, ar * * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from. * @param string|int $value Value to be added. Use negative to subtract. - * @param string $unit Unit of the value e.g. hour or day. + * @param string $unit Unit of the value e.g. hour or day. Must be a simple alphanumeric string. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\FunctionExpression */ @@ -205,6 +211,7 @@ public function dateAdd( if (!is_numeric($value)) { $value = 0; } + $this->ensureSimpleString('unit', $unit); $interval = $value . ' ' . $unit; $expression = new FunctionExpression('DATE_ADD', $this->toLiteralParam($expression), $types, 'datetime'); @@ -388,4 +395,19 @@ protected function toLiteralParam(ExpressionInterface|string $expression): array return [$expression]; } + + /** + * Ensures that string values are simple ascii values with no whitespace + * + * @param string $parameterName The name of the parameter being checked. + * @param string $value The value to check + * @return void + */ + protected function ensureSimpleString(string $parameterName, string $value): void + { + if (preg_match('/^[a-zA-Z0-9]+$/', $value)) { + return; + } + throw new InvalidArgumentException("Argument `{$parameterName}` must be an alphanumeric string"); + } } diff --git a/tests/TestCase/Database/FunctionsBuilderTest.php b/tests/TestCase/Database/FunctionsBuilderTest.php index 082c74f10ee..3177e172bd8 100644 --- a/tests/TestCase/Database/FunctionsBuilderTest.php +++ b/tests/TestCase/Database/FunctionsBuilderTest.php @@ -21,6 +21,8 @@ use Cake\Database\FunctionsBuilder; use Cake\Database\ValueBinder; use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; /** * Tests FunctionsBuilder class @@ -172,6 +174,26 @@ public function testCast(): void $this->assertSame('string', $function->getReturnType()); } + public static function invalidValues(): array + { + return [ + ['words with spaces'], + ["' drop table users --"], + [' word '], + ]; + } + + /** + * Ensure that only alphanumeric values are accepted for types. + */ + #[DataProvider('invalidValues')] + public function testCastInvalidValue(string $type): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`dataType` must be an alphanumeric string'); + $this->functions->cast('field', $type); + } + /** * Tests generating a NOW(), CURRENT_TIME() and CURRENT_DATE() function */ @@ -209,6 +231,22 @@ public function testExtract(): void $this->assertSame('integer', $function->getReturnType()); } + #[DataProvider('invalidValues')] + public function testExtractInvalidValue(string $part): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`part` must be an alphanumeric string'); + $this->functions->extract($part, 'created'); + } + + #[DataProvider('invalidValues')] + public function testDatePartInvalidValue(string $part): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`part` must be an alphanumeric string'); + $this->functions->datePart($part, 'created'); + } + /** * Tests generating a DATE_ADD() function */ @@ -224,6 +262,14 @@ public function testDateAdd(): void $this->assertSame('DATE_ADD(created, INTERVAL -3 day)', $function->sql(new ValueBinder())); } + #[DataProvider('invalidValues')] + public function testDateAddInvalidValue(string $unit): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`unit` must be an alphanumeric string'); + $this->functions->dateAdd('created', -3, $unit); + } + /** * Tests generating a DAYOFWEEK() function */ From 138f2f61486532c29ee4d106da2a9848c1ff1ab3 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Wed, 8 Jul 2026 23:06:25 -0400 Subject: [PATCH 26/30] Fix weakness in FunctionsBuilder::jsonValue() with postgres (#19530) The postgres driver needs to transforms jsonValue function calls to rename the function and apply postgres specific cast operations. If the current implementation is provided a user-controlled path expression, the generated query can be manipulated. Thanks to Himanshu Anand for reporting this --- src/Database/Driver/Postgres.php | 4 +- .../TestCase/Database/Driver/PostgresTest.php | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Database/Driver/Postgres.php b/src/Database/Driver/Postgres.php index 78574daea08..a65f164cfa2 100644 --- a/src/Database/Driver/Postgres.php +++ b/src/Database/Driver/Postgres.php @@ -328,9 +328,7 @@ protected function _transformFunctionExpression(FunctionExpression $expression): $expression->setName('JSONB_PATH_QUERY') ->iterateParts(function ($p, $key) { if ($key === 0) { - $p = sprintf('%s::jsonb', $p); - } elseif ($key === 1) { - $p = sprintf("'%s'::jsonpath", $this->quoteIdentifier($p['value'])); + return sprintf('%s::jsonb', $p); } return $p; diff --git a/tests/TestCase/Database/Driver/PostgresTest.php b/tests/TestCase/Database/Driver/PostgresTest.php index 47a2465caed..ad8324a94fb 100644 --- a/tests/TestCase/Database/Driver/PostgresTest.php +++ b/tests/TestCase/Database/Driver/PostgresTest.php @@ -19,6 +19,7 @@ use Cake\Database\Connection; use Cake\Database\Driver\Postgres; use Cake\Database\DriverFeatureEnum; +use Cake\Database\Exception\QueryException; use Cake\Database\Query\SelectQuery; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; @@ -270,4 +271,43 @@ public function testQuote(): void $expected = "'O\slash'"; $this->assertEquals($expected, $result); } + + /** + * test jsonValue expression tranformation and parameter binding + * + * @return void + */ + public function testJsonValueParameterBinding(): void + { + $connection = ConnectionManager::get('test'); + $this->skipIf(!$connection->getDriver() instanceof Postgres); + + $stmt = $connection->insert( + 'comments', + ['article_id' => 1, 'user_id' => 1, 'comment' => ['scores' => [25, 36]]], + ['comment' => 'json'], + ); + $this->assertEquals(1, $stmt->rowCount()); + + $query = new SelectQuery($connection); + $query + ->select(['score' => $query->func()->jsonValue('comment', '$.scores[1]')]) + ->from('comments') + ->where(['id' => 1]); + + $result = $query->execute(); + $comment = $result->fetchAll('assoc'); + $result->closeCursor(); + $this->assertSame('36', $comment[0]['score']); + + $query = new SelectQuery($connection); + $query + ->select(['score' => $query->func()->jsonValue('comment', "x')) > '0' OR (SELECT 1) --")]) + ->from('comments') + ->where(['id' => 1]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('syntax error at end of jsonpath input'); + $query->execute(); + } } From 6548976d15066e395b4d8e7f003b947a051b5379 Mon Sep 17 00:00:00 2001 From: ADmad Date: Thu, 9 Jul 2026 08:51:02 +0530 Subject: [PATCH 27/30] Set default value for `exportMask` config of Debugger. (#19532) This makes the code now match the docblock of Debugger::exportArray() which states that values for keys like login, password etc. are replaced by ***s --- src/Error/Debugger.php | 12 ++++- tests/TestCase/Error/DebuggerTest.php | 77 ++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/Error/Debugger.php b/src/Error/Debugger.php index b834e1bd0e7..ba73e1df3de 100644 --- a/src/Error/Debugger.php +++ b/src/Error/Debugger.php @@ -62,7 +62,15 @@ class Debugger * @var array */ protected array $_defaultConfig = [ - 'outputMask' => [], + 'outputMask' => [ + 'password' => '********', + 'login' => '********', + 'host' => '********', + 'database' => '********', + 'port' => '********', + 'prefix' => '********', + 'schema' => '********', + ], 'exportFormatter' => null, 'editor' => 'phpstorm', 'editorBasePath' => null, @@ -661,7 +669,7 @@ protected static function export(mixed $var, DebugContext $context): NodeInterfa /** * Export an array type object. Filters out keys used in datasource configuration. * - * The following keys are replaced with ***'s + * By default the following keys are replaced with ***'s * * - password * - login diff --git a/tests/TestCase/Error/DebuggerTest.php b/tests/TestCase/Error/DebuggerTest.php index 1887ef5d0de..217db518bd3 100644 --- a/tests/TestCase/Error/DebuggerTest.php +++ b/tests/TestCase/Error/DebuggerTest.php @@ -65,6 +65,9 @@ protected function setUp(): void Log::drop('stderr'); Log::drop('stdout'); Debugger::configInstance('exportFormatter', TextFormatter::class); + // The instances need to be initialized for the tests to pass + Debugger::getInstance(TestDebugger::class); + Debugger::getInstance(Debugger::class); } /** @@ -659,9 +662,14 @@ public function testDebugInfo(): void public function testSetOutputMask(): void { Debugger::setOutputMask(['password' => '[**********]']); - $this->assertEquals(['password' => '[**********]'], Debugger::outputMask()); + $result = Debugger::outputMask(); + $this->assertEquals('[**********]', $result['password']); + $this->assertEquals('********', $result['login']); + $this->assertEquals('********', $result['host']); Debugger::setOutputMask(['serial' => 'XXXXXX']); - $this->assertEquals(['password' => '[**********]', 'serial' => 'XXXXXX'], Debugger::outputMask()); + $result = Debugger::outputMask(); + $this->assertEquals('[**********]', $result['password']); + $this->assertEquals('XXXXXX', $result['serial']); Debugger::setOutputMask([], false); $this->assertSame([], Debugger::outputMask()); } @@ -703,6 +711,71 @@ public function testMaskObject(): void $this->assertSame($expected, preg_replace('/\s+/', '', $result)); } + /** + * Tests that default outputMask keys are masked by default. + */ + public function testDefaultOutputMaskMasksPassword(): void + { + $result = Debugger::exportVar(['password' => 'supersecret']); + $this->assertStringContainsString('********', $result); + $this->assertStringNotContainsString('supersecret', $result); + } + + /** + * Tests that all default outputMask keys are present. + */ + public function testDefaultOutputMaskContainsAllKeys(): void + { + $mask = Debugger::outputMask(); + $this->assertArrayHasKey('password', $mask); + $this->assertArrayHasKey('login', $mask); + $this->assertArrayHasKey('host', $mask); + $this->assertArrayHasKey('database', $mask); + $this->assertArrayHasKey('port', $mask); + $this->assertArrayHasKey('prefix', $mask); + $this->assertArrayHasKey('schema', $mask); + $this->assertSame('********', $mask['password']); + $this->assertSame('********', $mask['login']); + $this->assertSame('********', $mask['host']); + $this->assertSame('********', $mask['database']); + $this->assertSame('********', $mask['port']); + $this->assertSame('********', $mask['prefix']); + $this->assertSame('********', $mask['schema']); + } + + /** + * Tests that custom masks merge with defaults. + */ + public function testCustomMaskMergesWithDefaults(): void + { + Debugger::setOutputMask(['api_key' => 'SECRET']); + $mask = Debugger::outputMask(); + $this->assertSame('SECRET', $mask['api_key']); + $this->assertSame('********', $mask['password']); + $this->assertSame('********', $mask['host']); + } + + /** + * Tests that non-default array keys are not masked. + */ + public function testNonDefaultKeysAreNotMasked(): void + { + $result = Debugger::exportVar(['username' => 'admin', 'email' => 'test@example.com']); + $this->assertStringContainsString('admin', $result); + $this->assertStringContainsString('test@example.com', $result); + } + + /** + * Tests that default mask works on object properties. + */ + public function testDefaultMaskOnObjectProperties(): void + { + $object = new SecurityThing(); + $result = Debugger::exportVar($object); + $this->assertStringContainsString('********', $result); + $this->assertStringNotContainsString('pass1234', $result); + } + /** * test testPrintVar() */ From 542ca682ed7c97a1cb7a885a1ca13e83e939acfe Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 13 Jul 2026 20:12:55 +0200 Subject: [PATCH 28/30] Keep the ORDER BY intact when building a subquery strategy filter (#19538) _subqueryFields() collects the ORDER BY fields through iterateParts(), which rebuilds the expression from whatever the callback returns. The callback returned nothing, so every part was dropped - and since it runs against the caller's query rather than a clone, the query itself was left without an ORDER BY. The loader of the first contained association therefore stripped the order, and every following association built its filtering subquery unordered. With a limit in place those subqueries paged onto the wrong rows, so any hasMany after the first one silently returned no results at all. * Add closure param type for rector * Fix CS --------- Co-authored-by: Mark Scherer --- src/ORM/Association/Loader/SelectLoader.php | 7 ++- .../ORM/Query/QueryRegressionTest.php | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/ORM/Association/Loader/SelectLoader.php b/src/ORM/Association/Loader/SelectLoader.php index 866e6ac7fbd..1ca8d0b5b2b 100644 --- a/src/ORM/Association/Loader/SelectLoader.php +++ b/src/ORM/Association/Loader/SelectLoader.php @@ -463,10 +463,15 @@ protected function _subqueryFields(SelectQuery $query): array $order = $query->clause('order'); if ($order) { $columns = $query->clause('select'); - $order->iterateParts(function ($direction, $field) use (&$fields, $columns): void { + // iterateParts() rebuilds the expression from the callback's return value, so each part + // has to be handed back. Returning nothing would strip the ORDER BY from $query itself, + // which is the caller's query, not a clone of it. + $order->iterateParts(function ($direction, $field) use (&$fields, $columns) { if (isset($columns[$field])) { $fields[$field] = $columns[$field]; } + + return $direction; }); } diff --git a/tests/TestCase/ORM/Query/QueryRegressionTest.php b/tests/TestCase/ORM/Query/QueryRegressionTest.php index 32381ceab8a..e5fe48d018a 100644 --- a/tests/TestCase/ORM/Query/QueryRegressionTest.php +++ b/tests/TestCase/ORM/Query/QueryRegressionTest.php @@ -23,6 +23,7 @@ use Cake\Datasource\EntityInterface; use Cake\Event\EventInterface; use Cake\I18n\DateTime; +use Cake\ORM\Association; use Cake\ORM\Entity; use Cake\ORM\Query\SelectQuery; use Cake\TestSuite\TestCase; @@ -1849,4 +1850,62 @@ public function testExecutedSubqueryCanBeReused(): void $this->assertEquals('First Article', $result[0]->title); $this->assertNotEmpty($result[0]->user); } + + /** + * The subquery strategy must not strip the ORDER BY from the query it is given. + * + * The loader for the first association would otherwise leave the caller's query without an + * ORDER BY, so every subsequent loader builds its filtering subquery unordered, pages on the + * wrong rows and returns no results at all. + */ + public function testSubqueryStrategyDoesNotMutateOrder(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->hasMany('Articles', ['strategy' => Association::STRATEGY_SUBQUERY]); + + $query = $authors->find() + ->contain(['Articles']) + ->orderBy(['Authors.id' => 'DESC']) + ->limit(2); + + $this->assertCount(1, $query->clause('order')); + $query->all()->toArray(); + $this->assertCount(1, $query->clause('order'), 'The ORDER BY was removed from the query.'); + } + + /** + * Every hasMany in the contain list must be loaded, not just the first one. + * + * Ordering by id DESC with a limit of 2 pages onto authors 4 and 3. An unordered subquery + * would instead filter on authors 1 and 2, so author 4's comment goes missing. + */ + public function testSubqueryStrategyWithMultipleHasMany(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->hasMany('Articles', ['strategy' => Association::STRATEGY_SUBQUERY]); + $authors->hasMany('Comments', [ + 'foreignKey' => 'user_id', + 'strategy' => Association::STRATEGY_SUBQUERY, + ]); + + $results = $this->getTableLocator()->get('Authors')->find() + ->contain(['Articles', 'Comments']) + ->orderBy(['Authors.id' => 'DESC']) + ->limit(2) + ->all() + ->toArray(); + + $this->assertCount(2, $results); + $this->assertSame([4, 3], array_map(fn(EntityInterface $author) => $author->id, $results)); + + // Second hasMany in the contain list: empty before the fix. + $this->assertCount(1, $results[0]->comments); + $this->assertSame(4, $results[0]->comments[0]->user_id); + $this->assertEmpty($results[0]->articles); + + // First hasMany in the contain list: loaded correctly either way. + $this->assertCount(1, $results[1]->articles); + $this->assertSame('Second Article', $results[1]->articles[0]->title); + $this->assertEmpty($results[1]->comments); + } } From f95aebdbc508882f658ad450e2a8fa5edf0814a1 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 13 Jul 2026 23:03:33 -0400 Subject: [PATCH 29/30] Handle %2f in routing more consistently (#19537) Currently path prefix matching does not treat %2f as /, and `Route` has an option for it. In the past we've made changes in this area (#18050, #16110) to make urldecoding optional, and to intentionally decode urlencoding in path segments to support non-ascii applications. We got a report on the security list for a potential issue where *if* an application enforced authorization within path prefixed scopes, and had fallback routes enabled, then one could potentially bypass the scoped middleware and hit the fallback routes which inconsistently handle %2f. These changes align the behavior of urldecoding between RouteCollection and Route with a new shared internal function. I thought a function was better than exposing a static method on a public class. Thanks to Rotem Reiss for reporting this issue. --- src/Routing/Route/Route.php | 5 +-- src/Routing/RouteCollection.php | 9 ++--- src/Routing/functions.php | 17 +++++++++ tests/TestCase/Routing/Route/RouteTest.php | 22 ++++++++++++ tests/TestCase/Routing/RouteBuilderTest.php | 38 ++++++++++++++++++++- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/Routing/Route/Route.php b/src/Routing/Route/Route.php index e513a788e5a..25ace3da42c 100644 --- a/src/Routing/Route/Route.php +++ b/src/Routing/Route/Route.php @@ -22,6 +22,7 @@ use InvalidArgumentException; use Psr\Http\Message\ServerRequestInterface; use UnitEnum; +use function Cake\Routing\urldecodeSegments; /** * A single Route used by the Router to connect requests to @@ -468,8 +469,8 @@ public function parse(string $url, string $method): ?array [$url, $ext] = $this->_parseExtension($url); $urldecode = $this->options['_urldecode'] ?? true; - if ($urldecode) { - $url = urldecode($url); + if ($urldecode && str_contains($url, '%')) { + $url = urldecodeSegments($url); } if (!preg_match($compiledRoute, $url, $route)) { diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php index 4c1866b9640..a7b30f51809 100644 --- a/src/Routing/RouteCollection.php +++ b/src/Routing/RouteCollection.php @@ -136,15 +136,10 @@ public function add(Route $route, array $options = []): void public function parseRequest(ServerRequestInterface $request): array { $uri = $request->getUri(); + $urlPath = $uri->getPath(); if (str_contains($urlPath, '%')) { - // decode urlencoded segments, but don't decode %2f aka / - $parts = explode('/', $urlPath); - $parts = array_map( - fn(string $part) => str_replace('/', '%2f', urldecode($part)), - $parts, - ); - $urlPath = implode('/', $parts); + $urlPath = urldecodeSegments($urlPath); } if ($urlPath !== '/') { $urlPath = rtrim($urlPath, '/'); diff --git a/src/Routing/functions.php b/src/Routing/functions.php index 1c340ccf659..23a7f814b90 100644 --- a/src/Routing/functions.php +++ b/src/Routing/functions.php @@ -57,3 +57,20 @@ function url(UriInterface|array|string|null $url = null, bool $full = false): st { return Router::url($url, $full); } + +/** + * urldecode all of the segments in a path, but skip over %2f + * because applications can use %2f in a segment. + * + * @internal + */ +function urldecodeSegments(string $url): string +{ + $parts = explode('/', $url); + $parts = array_map( + fn(string $part) => str_replace('/', '%2f', urldecode($part)), + $parts, + ); + + return implode('/', $parts); +} diff --git a/tests/TestCase/Routing/Route/RouteTest.php b/tests/TestCase/Routing/Route/RouteTest.php index f1fa7f91c64..e12cbd56846 100644 --- a/tests/TestCase/Routing/Route/RouteTest.php +++ b/tests/TestCase/Routing/Route/RouteTest.php @@ -1181,6 +1181,28 @@ public function testParseUrlDecodeElements(): void $this->assertSame('∂∂', $result['slug']); } + /** + * Test that segments are urldecoded into parsed route, but not %2f + * as applications use %2f for encoded data that path prefix matching + * doesn't treat as / + */ + public function testParseUrlDecodeButNot2f(): void + { + $route = new Route( + '/{controller}/{slug}', + ['action' => 'view'], + ); + $route->compile(); + $result = $route->parse('/posts/foo', 'GET'); + $this->assertSame('foo', $result['slug']); + + $result = $route->parse('/posts/foo%20bar', 'GET'); + $this->assertSame('foo bar', $result['slug']); + + $result = $route->parse('/posts%2ffoo', 'GET'); + $this->assertNull($result, 'encoded / should not count as real / '); + } + /** * Test numerically indexed defaults, get appended to pass */ diff --git a/tests/TestCase/Routing/RouteBuilderTest.php b/tests/TestCase/Routing/RouteBuilderTest.php index ba0595854c4..1cf7d360249 100644 --- a/tests/TestCase/Routing/RouteBuilderTest.php +++ b/tests/TestCase/Routing/RouteBuilderTest.php @@ -19,6 +19,7 @@ use BadMethodCallException; use Cake\Core\Exception\MissingPluginException; use Cake\Core\Plugin; +use Cake\Http\Response; use Cake\Http\ServerRequest; use Cake\Routing\Route\InflectedRoute; use Cake\Routing\Route\RedirectRoute; @@ -957,7 +958,7 @@ public function testScopeInheritMiddleware(): void /** * Test using name prefixes. */ - public function testNamePrefixes(): void + public function testScopeNamePrefixes(): void { $routes = new RouteBuilder($this->collection, '/api', [], ['namePrefix' => 'api:']); $routes->scope('/v1', ['version' => 1, '_namePrefix' => 'v1:'], function (RouteBuilder $routes): void { @@ -973,6 +974,41 @@ public function testNamePrefixes(): void $this->assertArrayHasKey('web:pong', $all); } + /** + * Test that if an application has scoped middleware on a path prefix, + * and fallback routes, that URL decoding in Route::parse() would + * decode the %2f that was not accepted in the scope path prefix check. + */ + public function testScopeMiddlewarePathMatch(): void + { + $gate = function () { + return new Response(['body' => 'no', 'status' => 403]); + }; + + $routes = new RouteBuilder($this->collection, '/'); + $routes->registerMiddleware('gate', $gate); + $routes->scope('/admin', function (RouteBuilder $routes): void { + $routes->applyMiddleware('gate'); + $routes->connect('/secret', ['controller' => 'Admin', 'action' => 'secret']); + }); + $routes->fallbacks(DashedRoute::class); + + // No encoded / is a standard request. + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/admin/secret', + ])); + $this->assertEquals(['gate'], $result['_route']->getMiddleware()); + $this->assertEquals('/admin/secret', $result['_route']->template); + + // Encoded / will not match path prefix. It will match /controller though + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/admin%2Fsecret', + ])); + $this->assertEquals('/{controller}', $result['_route']->template); + $this->assertEquals('Admin%2fsecret', $result['controller']); + $this->assertEquals('index', $result['action']); + } + /** * Test adding middleware to the collection. */ From c0d21e0da4216d51268691af3ad803f0eecb3a11 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 13 Jul 2026 23:04:24 -0400 Subject: [PATCH 30/30] Update version number to 5.3.7 --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 53a18289616..f91b5944c90 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -16,4 +16,4 @@ // @license https://opensource.org/licenses/mit-license.php MIT License // +--------------------------------------------------------------------------------------------+ // //////////////////////////////////////////////////////////////////////////////////////////////////// -5.3.6 +5.3.7