From 564ba01dcb8910308fae1f382d64cbc190be496c Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 9 Jun 2026 08:37:33 -0700 Subject: [PATCH 01/17] 5.11 changelog [ci skip] --- CHANGELOG-5.11.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CHANGELOG-5.11.md diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md new file mode 100644 index 00000000000..519e1a53092 --- /dev/null +++ b/CHANGELOG-5.11.md @@ -0,0 +1 @@ +# Release Notes for Craft CMS 5.11 (WIP) From f3d743e3cdf85e30a6d1c7385a8e091de2cf50d5 Mon Sep 17 00:00:00 2001 From: i-just Date: Wed, 10 Jun 2026 12:24:12 +0200 Subject: [PATCH 02/17] respect alt text translation method from the start --- src/config/app.php | 2 +- src/elements/Asset.php | 53 ++++--------- src/elements/db/AssetQuery.php | 26 ++----- ...0_065719_translatable_alt_text_content.php | 77 +++++++++++++++++++ 4 files changed, 99 insertions(+), 59 deletions(-) create mode 100644 src/migrations/m260610_065719_translatable_alt_text_content.php diff --git a/src/config/app.php b/src/config/app.php index 0751efe2de9..64068528103 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -4,7 +4,7 @@ 'id' => 'CraftCMS', 'name' => 'Craft CMS', 'version' => '5.10.5', - 'schemaVersion' => '5.10.0.0', + 'schemaVersion' => '5.11.0.0', 'minVersionRequired' => '4.5.0', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/elements/Asset.php b/src/elements/Asset.php index 4f6113d0c26..41d1645e344 100644 --- a/src/elements/Asset.php +++ b/src/elements/Asset.php @@ -1205,20 +1205,6 @@ private static function isFolderIndex(): bool */ private ?int $_oldVolumeId = null; - /** - * @inheritdoc - */ - public function __construct($config = []) - { - // alt='' actually means something, so we should preserve it. - $alt = ArrayHelper::remove($config, 'alt'); - if ($alt !== null) { - $this->alt = $alt; - } - - parent::__construct($config); - } - /** * @inheritdoc */ @@ -1298,20 +1284,6 @@ public function init(): void $this->_oldVolumeId = $this->_volumeId; } - /** - * @inheritdoc - */ - public function setAttributesFromRequest(array $values): void - { - // alt='' actually means something, so we should preserve it. - $alt = ArrayHelper::remove($values, 'alt'); - if ($alt !== null) { - $this->alt = $alt; - } - - parent::setAttributesFromRequest($values); - } - /** * Returns the volume’s ID. * @@ -3385,10 +3357,6 @@ public function afterSave(bool $isNew): void $record->mimeType = $this->_mimeType; } - if ($record->alt === null) { - $record->alt = $this->alt; - } - if ($this->getHasFocalPoint()) { $focal = $this->getFocalPoint(); $record->focalPoint = number_format($focal['x'], 4) . ';' . number_format($focal['y'], 4); @@ -3397,8 +3365,16 @@ public function afterSave(bool $isNew): void } $record->save(false); + + // we're not propagating at this point, so save the alt ONLY against the site we're saving to + Db::upsert(Table::ASSETS_SITES, [ + 'assetId' => $this->id, + 'siteId' => $this->siteId, + 'alt' => $this->alt, + ]); } + $upsert = false; if ( $this->propagating && $this->propagatingFrom && @@ -3411,15 +3387,18 @@ public function afterSave(bool $isNew): void $this->alt !== $from->alt && $this->getAltTranslationKey() === $from->getAltTranslationKey() ) { + $upsert = true; $this->alt = $from->alt; } } - Db::upsert(Table::ASSETS_SITES, [ - 'assetId' => $this->id, - 'siteId' => $this->siteId, - 'alt' => $this->alt, - ]); + if ($upsert or $this->propagateAll) { + Db::upsert(Table::ASSETS_SITES, [ + 'assetId' => $this->id, + 'siteId' => $this->siteId, + 'alt' => $this->alt, + ]); + } parent::afterSave($isNew); } diff --git a/src/elements/db/AssetQuery.php b/src/elements/db/AssetQuery.php index afafe3a9b68..51b2741015d 100644 --- a/src/elements/db/AssetQuery.php +++ b/src/elements/db/AssetQuery.php @@ -1015,29 +1015,15 @@ protected function afterPrepare(): bool { if ($this->hasAlt !== null) { $hasAltCondition = [ - 'or', + 'and', ['not', ['assets_sites.alt' => '']], - [ - 'and', - ['assets_sites.alt' => null], - ['not', ['assets.alt' => '']], - ['not', ['assets.alt' => null]], - ], + ['not', ['assets_sites.alt' => null]], ]; $withoutAltCondition = [ 'or', ['assets_sites.alt' => ''], - [ - 'and', - ['assets_sites.alt' => null], - [ - 'or', - ['assets.alt' => ''], - ['assets.alt' => null], - ], - - ], + ['assets_sites.alt' => null], ]; $this->subQuery @@ -1153,11 +1139,9 @@ private function _normalizeVolumeId(): void */ public function createElement(array $row): ElementInterface { - // Use the site-specific alt text, if set + // Use the site-specific alt text $siteAlt = ArrayHelper::remove($row, 'siteAlt'); - if ($siteAlt !== null) { - $row['alt'] = $siteAlt; - } + $row['alt'] = $siteAlt; return parent::createElement($row); } diff --git a/src/migrations/m260610_065719_translatable_alt_text_content.php b/src/migrations/m260610_065719_translatable_alt_text_content.php new file mode 100644 index 00000000000..913f78b2723 --- /dev/null +++ b/src/migrations/m260610_065719_translatable_alt_text_content.php @@ -0,0 +1,77 @@ +leftJoin('{{%elements_sites}}', '[[elements_sites.elementId]] = [[assets.id]]') + ->select(['assets.id', 'elements_sites.siteId', 'assets.alt']); + + $assetsRecords = $query->createCommand()->queryAll(); + + // for each of those records, + foreach ($assetsRecords as $assetRecord) { + $exists = (new Query()) + ->from(Table::ASSETS_SITES) + ->where([ + 'assetId' => $assetRecord['id'], + 'siteId' => $assetRecord['siteId'], ] + ) + ->exists(); + + if ($exists) { + // if the assets_sites row exists and the alt column is null, copy over the value from the assets.alt + Db::update( + Table::ASSETS_SITES, + [ + 'alt' => $assetRecord['alt'], + ], + [ + 'assetId' => $assetRecord['id'], + 'siteId' => $assetRecord['siteId'], + 'alt' => null, + ] + ); + } else { + // if the assets_sites row doesn't exist, insert one with the assets.alt value + Db::insert( + Table::ASSETS_SITES, + [ + 'assetId' => $assetRecord['id'], + 'siteId' => $assetRecord['siteId'], + 'alt' => $assetRecord['alt'], + ] + ); + } + // the above ensures we're not changing the alt text developers might expect during the update, + // but at the same time we're all set to no longer use the assets.alt column + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260610_065719_translatable_alt_text_content cannot be reverted.\n"; + return false; + } +} From 827891785565bc60b4d4078628b781484fc872b6 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 16 Jun 2026 10:06:35 -0700 Subject: [PATCH 03/17] UrlHelper::removeParams(), removeAllParams() --- src/helpers/UrlHelper.php | 65 +++++++++-- tests/unit/helpers/UrlHelperTest.php | 165 ++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 9 deletions(-) diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php index fd4aefa9d08..e27013eb31e 100644 --- a/src/helpers/UrlHelper.php +++ b/src/helpers/UrlHelper.php @@ -155,12 +155,61 @@ public static function urlWithParams(string $url, array|string $params): string * @since 3.2.2 */ public static function removeParam(string $url, string $param): string + { + return static::removeParams($url, [$param]); + } + + /** + * Removes query string params from a URL. + * + * @param string $url + * @param string[] $params + * @return string + * @since 5.11.0 + */ + public static function removeParams(string $url, array $params): string + { + // Extract any params/fragment from the base URL + [$url, $urlParams, $fragment] = self::_extractParams($url); + + // Remove the params + foreach ($params as $param) { + unset($urlParams[$param]); + } + + // Rebuild + if (($query = static::buildQuery($urlParams)) !== '') { + $url .= '?' . $query; + } + if ($fragment !== null) { + $url .= '#' . $fragment; + } + return $url; + } + + /** + * Removes all query string params from a URL. + * + * @param string $url + * @param string[] $except Any params that should be left alone + * @return string + * @since 5.11.0 + */ + public static function removeAllParams(string $url, array $except = []): string { // Extract any params/fragment from the base URL [$url, $params, $fragment] = self::_extractParams($url); - // Remove the param - unset($params[$param]); + // Remove the params + if (!empty($except)) { + foreach (array_keys($params) as $param) { + if (!in_array($param, $except)) { + unset($params[$param]); + } + } + } else { + $params = []; + } // Rebuild if (($query = static::buildQuery($params)) !== '') { @@ -275,18 +324,20 @@ public static function rootRelativeUrl(string $url): string * Returns either a control panel or a site URL, depending on the request type. * * @param string $path - * @param array|string|null $params + * @param array|string|false|null $params The query params to add to the URL. If `false`, any existing params will be removed. * @param string|null $scheme * @param bool|null $showScriptName Whether the script name (index.php) should be included in the URL. * By default (null) it will defer to the `omitScriptNameInUrls` config setting. * @return string */ - public static function url(string $path = '', array|string|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): string + public static function url(string $path = '', array|string|false|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): string { // Return $path if it appears to be an absolute URL. if (static::isFullUrl($path)) { if ($params) { $path = static::urlWithParams($path, $params); + } elseif ($params === false) { + $path = static::removeAllParams($path); } if ($scheme !== null) { @@ -312,7 +363,7 @@ public static function url(string $path = '', array|string|null $params = null, $scheme = 'https'; } - return self::_createUrl($path, $params, $scheme, $cpUrl, showScriptName: $showScriptName); + return self::_createUrl($path, $params ?: null, $scheme, $cpUrl, showScriptName: $showScriptName); } /** @@ -596,12 +647,12 @@ public static function cpReferralUrl(): ?string } // Make sure the CP referred it - if (!str_starts_with($referrer, self::baseCpUrl())) { + if (!str_starts_with($referrer, static::baseCpUrl())) { return null; } // to ensure we're comparing uris strip base cp url and query string from the referrer first - $referrerFullUri = ltrim(StringHelper::removeLeft($referrer, self::baseCpUrl()), '/'); + $referrerFullUri = ltrim(StringHelper::removeLeft($referrer, static::baseCpUrl()), '/'); $referrerFullUri = substr($referrerFullUri, 0, strpos($referrerFullUri, '?') ?: null); // Make sure it didn't refer itself diff --git a/tests/unit/helpers/UrlHelperTest.php b/tests/unit/helpers/UrlHelperTest.php index 6eb9c23ce71..c30e86627a9 100644 --- a/tests/unit/helpers/UrlHelperTest.php +++ b/tests/unit/helpers/UrlHelperTest.php @@ -158,6 +158,39 @@ public function testStripQueryString(string $expected, string $url): void self::assertSame($expected, UrlHelper::stripQueryString($url)); } + /** + * @dataProvider removeParamDataProvider + * @param string $expected + * @param string $url + * @param string $param + */ + public function testRemoveParam(string $expected, string $url, string $param): void + { + self::assertSame($expected, UrlHelper::removeParam($url, $param)); + } + + /** + * @dataProvider removeParamsDataProvider + * @param string $expected + * @param string $url + * @param string[] $params + */ + public function testRemoveParams(string $expected, string $url, array $params): void + { + self::assertSame($expected, UrlHelper::removeParams($url, $params)); + } + + /** + * @dataProvider removeAllParamsDataProvider + * @param string $expected + * @param string $url + * @param string[] $except + */ + public function testRemoveAllParams(string $expected, string $url, array $except = []): void + { + self::assertSame($expected, UrlHelper::removeAllParams($url, $except)); + } + /** * @dataProvider encodeParamsDataProvider */ @@ -192,11 +225,11 @@ public function testRootRelativeUrl(string $expected, string $url): void * @dataProvider urlFunctionDataProvider * @param string $expected * @param string $path - * @param array|null $params + * @param array|string|false|null $params * @param string|null $scheme * @param bool|null $showScriptName */ - public function testUrlFunction(string $expected, string $path = '', ?array $params = null, ?string $scheme = null, ?bool $showScriptName = null): void + public function testUrlFunction(string $expected, string $path = '', array|string|false|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): void { $scheme ??= 'https'; $expected = $this->_prepExpectedUrl($expected, $scheme); @@ -435,6 +468,129 @@ public static function stripQueryStringDataProvider(): array ]; } + /** + * Tests for UrlHelper::removeParam() method + * + * @return array + */ + public static function removeParamDataProvider(): array + { + return [ + 'basic' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + 'param1', + ], + 'last-param' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + 'param1', + ], + 'missing-param' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + 'param2', + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + 'param1', + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + 'param1', + ], + ]; + } + + /** + * Tests for UrlHelper::removeParams() method + * + * @return array + */ + public static function removeParamsDataProvider(): array + { + return [ + 'multiple' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param1', 'param3'], + ], + 'all-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + ['param1', 'param2'], + ], + 'missing-params' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + ['param2', 'param3'], + ], + 'empty-params' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + [], + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + ['param1'], + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3#anchor', + ['param1', 'param3'], + ], + ]; + } + + /** + * Tests for UrlHelper::removeAllParams() method + * + * @return array + */ + public static function removeAllParamsDataProvider(): array + { + return [ + 'basic' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + [], + ], + 'except' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param2'], + ], + 'except-multiple' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m3=value3', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param1', 'param3'], + ], + 'except-missing' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + ['param3'], + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + [], + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + [], + ], + 'except-keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + ['param2'], + ], + ]; + } + /** * Tests for UrlHelper::urlWithParams() method * @@ -716,6 +872,11 @@ public static function urlFunctionDataProvider(): array ['returnUrl' => 'https://example.test/admin/entries?site={handle}'], 'https', ], + 'remove-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?x-craft-preview=foo&test=bar', + false, + ], ]; } From 95ef394198960be192514ce7c89d70a03eac7646 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 16 Jun 2026 10:24:31 -0700 Subject: [PATCH 04/17] Release notes [ci skip] --- CHANGELOG-5.11.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 519e1a53092..96bb423f326 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -1 +1,9 @@ # Release Notes for Craft CMS 5.11 (WIP) + +### Development +- The `params` argument of the `url()` Twig function now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) + +### Extensibility +- Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) From 45ac9aa47a73a0c883f861c793b2c781854a13e9 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 25 Jun 2026 08:05:59 -0600 Subject: [PATCH 05/17] craft\web\DbSession Resolves #19139 --- CHANGELOG-5.11.md | 1 + src/web/DbSession.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/web/DbSession.php diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 96bb423f326..44d94590972 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -2,6 +2,7 @@ ### Development - The `params` argument of the `url()` Twig function now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\web\DbSession`, which should be used instead of `yii\web\DbSession` to prevent “headers already sent” warnings from getting logged. ([#19139](https://github.com/craftcms/cms/issues/19139)) ### Extensibility - Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) diff --git a/src/web/DbSession.php b/src/web/DbSession.php new file mode 100644 index 00000000000..6963edb30f1 --- /dev/null +++ b/src/web/DbSession.php @@ -0,0 +1,33 @@ + + * @since 5.11.0 + * @mixin SessionBehavior + */ +class DbSession extends \yii\web\DbSession +{ + /** + * @inheritdoc + */ + public function has($key): bool + { + // don't open the session if the headers were already sent + if (!$this->getIsActive() && headers_sent()) { + return isset($_SESSION[$key]); + } + + return parent::has($key); + } +} From 0ccf9a747c0ccae25796a363b9e3df00ca9c2e49 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 6 Jul 2026 10:49:11 -0700 Subject: [PATCH 06/17] Twig 3.28 --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index c2def279d9c..13984bf073b 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,7 @@ "symfony/var-dumper": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.2.3|^6.0|^7.0", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.27.0", + "twig/twig": "~3.28.0", "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.2.4", "yiisoft/yii2": "~2.0.55.0", diff --git a/composer.lock b/composer.lock index ca23fd63835..91f5e137d57 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c5e60c5404c43b6fe24ba3f940b4d5e", + "content-hash": "1f92d9154a7a6c259b9b915d236d014a", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6776,16 +6776,16 @@ }, { "name": "twig/twig", - "version": "v3.27.1", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -6840,7 +6840,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -6852,7 +6852,7 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "voku/portable-ascii", From dd9b6f18513e1d9e1f0419d1325eb3186e7f305a Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 6 Jul 2026 10:50:00 -0700 Subject: [PATCH 07/17] Release note [ci skip] --- CHANGELOG-5.11.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 44d94590972..27230bd1f98 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -8,3 +8,6 @@ - Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) + +### System +- Updated Twig to 3.28. From ad1ee56e12ebdeb66631f3e10ebef4176c55b56d Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 28 Jul 2026 14:07:38 -0400 Subject: [PATCH 08/17] Sort lines [ci skip] --- CHANGELOG-5.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 27230bd1f98..b2285eaf507 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -5,8 +5,8 @@ - Added `craft\web\DbSession`, which should be used instead of `yii\web\DbSession` to prevent “headers already sent” warnings from getting logged. ([#19139](https://github.com/craftcms/cms/issues/19139)) ### Extensibility -- Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) ### System From 2ba19b35238aa6075571a80ea73924964a10c019 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 28 Jul 2026 14:22:19 -0400 Subject: [PATCH 09/17] reorderNestedElements() --- CHANGELOG-5.11.md | 1 + src/controllers/NestedElementsController.php | 34 +---- src/services/Elements.php | 52 +++++++ tests/unit/services/ElementsTest.php | 147 +++++++++++++++++++ 4 files changed, 201 insertions(+), 33 deletions(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index b2285eaf507..a51212998a5 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -7,6 +7,7 @@ ### Extensibility - Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\services\Elements::reorderNestedElements()`. ([#19321](https://github.com/craftcms/cms/issues/19321)) - The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) ### System diff --git a/src/controllers/NestedElementsController.php b/src/controllers/NestedElementsController.php index 2562b84047d..86e3cad6d05 100644 --- a/src/controllers/NestedElementsController.php +++ b/src/controllers/NestedElementsController.php @@ -9,7 +9,6 @@ use Craft; use craft\base\ElementInterface; -use craft\base\NestedElementInterface; use craft\db\Table; use craft\elements\db\ElementQueryInterface; use craft\elements\ElementCollection; @@ -81,38 +80,7 @@ public function actionReorder(): Response $ids = array_map(fn($id) => (int)$id, $this->request->getRequiredBodyParam('elementIds')); $offset = $this->request->getRequiredBodyParam('offset'); - if ($this->nestedElements instanceof ElementQueryInterface) { - $oldSortOrders = (clone $this->nestedElements) - ->status(null) - ->asArray() - ->select(['id', 'sortOrder']) - ->pairs(); - } else { - $oldSortOrders = $this->nestedElements - ->keyBy(fn(ElementInterface $element) => $element->id) - /** @phpstan-ignore-next-line */ - ->map(fn(NestedElementInterface $element) => $element->getSortOrder()) - ->all(); - } - - // Build the full list of IDs in the new sort order - $allIds = array_diff(array_keys($oldSortOrders), $ids); - array_splice($allIds, $offset, 0, $ids); - - // Update all the incorrect sort orders - foreach ($allIds as $i => $id) { - $sortOrder = $i + 1; - if (!isset($oldSortOrders[$id]) || $sortOrder !== $oldSortOrders[$id]) { - Db::update(Table::ELEMENTS_OWNERS, [ - 'sortOrder' => $sortOrder, - ], [ - 'ownerId' => $this->owner->id, - 'elementId' => $id, - ]); - } - } - - Craft::$app->getElements()->invalidateCachesForElement($this->owner); + Craft::$app->getElements()->reorderNestedElements($this->owner, $this->nestedElements, $ids, $offset); return $this->asSuccess(Craft::t('app', 'New {total, plural, =1{position} other{positions}} saved.', [ 'total' => count($ids), diff --git a/src/services/Elements.php b/src/services/Elements.php index 37db9b6d105..0d585a60943 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -918,6 +918,58 @@ public function invalidateCachesForElement(ElementInterface $element): void } } + /** + * Reorders nested elements for a given owner element. + * + * @param ElementInterface $owner The owner element + * @param ElementQueryInterface|ElementCollection $nestedElements The owner’s nested elements + * @param array $elementIds The nested element IDs that are being moved, in their new relative order + * @param int $offset The zero-based offset that `$elementIds` should be inserted at, relative to the owner’s + * other nested elements + * @since 5.11.0 + */ + public function reorderNestedElements( + ElementInterface $owner, + ElementQueryInterface|ElementCollection $nestedElements, + array $elementIds, + int $offset, + ): void { + $elementIds = array_map(fn($id) => (int)$id, $elementIds); + + if ($nestedElements instanceof ElementQueryInterface) { + $oldSortOrders = (clone $nestedElements) + ->status(null) + ->asArray() + ->select(['id', 'sortOrder']) + ->pairs(); + } else { + $oldSortOrders = $nestedElements + ->keyBy(fn(ElementInterface $element) => $element->id) + /** @phpstan-ignore-next-line */ + ->map(fn(NestedElementInterface $element) => $element->getSortOrder()) + ->all(); + } + + // Build the full list of IDs in the new sort order + $allIds = array_diff(array_keys($oldSortOrders), $elementIds); + array_splice($allIds, $offset, 0, $elementIds); + + // Update all the incorrect sort orders + foreach ($allIds as $i => $id) { + $sortOrder = $i + 1; + if (!isset($oldSortOrders[$id]) || $sortOrder !== $oldSortOrders[$id]) { + Db::update(Table::ELEMENTS_OWNERS, [ + 'sortOrder' => $sortOrder, + ], [ + 'ownerId' => $owner->id, + 'elementId' => $id, + ]); + } + } + + $this->invalidateCachesForElement($owner); + } + // Finding Elements // ------------------------------------------------------------------------- diff --git a/tests/unit/services/ElementsTest.php b/tests/unit/services/ElementsTest.php index e567e77dd62..66112cb2a84 100644 --- a/tests/unit/services/ElementsTest.php +++ b/tests/unit/services/ElementsTest.php @@ -12,6 +12,16 @@ use Craft; use craft\elements\Entry; +use craft\enums\PropagationMethod; +use craft\fieldlayoutelements\CustomField; +use craft\fieldlayoutelements\entries\EntryTitleField; +use craft\fields\Matrix; +use craft\fields\PlainText; +use craft\models\EntryType; +use craft\models\FieldLayout; +use craft\models\FieldLayoutTab; +use craft\models\Section; +use craft\models\Section_SiteSettings; use craft\services\Elements; use craft\test\TestCase; use craft\test\TestSetup; @@ -21,6 +31,7 @@ use crafttests\fixtures\settings\GeneralConfigSettingFixture; use crafttests\fixtures\SitesFixture; use crafttests\fixtures\UserFixture; +use RuntimeException; /** * Unit tests for the config service @@ -36,6 +47,12 @@ class ElementsTest extends TestCase */ public $elements; + private PlainText $blockTextField; + private Matrix $matrixField; + private EntryType $blockEntryType; + private EntryType $ownerEntryType; + private Section $section; + /** * @return void */ @@ -123,5 +140,135 @@ protected function _before(): void { parent::_before(); $this->elements = Craft::$app->getElements(); + + $primarySiteId = Craft::$app->getSites()->getPrimarySite()->id; + + // A single-site, non-versioned section with a Matrix field, built directly (rather than via + // shared fixtures) so this test doesn't have to reason about revision-creation or multi-site + // propagation - it only cares about reordering already-saved nested entries. + $this->blockTextField = new PlainText(); + $this->blockTextField->name = 'Block Text'; + $this->blockTextField->handle = 'blockText'; + if (!Craft::$app->getFields()->saveField($this->blockTextField)) { + throw new RuntimeException('Could not save block text field.'); + } + + $this->blockEntryType = new EntryType(); + $this->blockEntryType->name = 'Reorder Test Block'; + $this->blockEntryType->handle = 'reorderTestBlock'; + $this->blockEntryType->hasTitleField = false; + $this->blockEntryType->titleFormat = '{id}'; + $this->blockEntryType->setFieldLayout($this->_makeFieldLayout(Entry::class, [$this->blockTextField])); + if (!Craft::$app->getEntries()->saveEntryType($this->blockEntryType)) { + throw new RuntimeException('Could not save block entry type.'); + } + + $this->matrixField = new Matrix(); + $this->matrixField->name = 'Reorder Test Matrix'; + $this->matrixField->handle = 'reorderTestMatrix'; + $this->matrixField->propagationMethod = PropagationMethod::None; + $this->matrixField->setEntryTypes([$this->blockEntryType]); + if (!Craft::$app->getFields()->saveField($this->matrixField)) { + throw new RuntimeException('Could not save matrix field.'); + } + + $this->ownerEntryType = new EntryType(); + $this->ownerEntryType->name = 'Reorder Test Owner'; + $this->ownerEntryType->handle = 'reorderTestOwner'; + $this->ownerEntryType->hasTitleField = true; + $this->ownerEntryType->setFieldLayout($this->_makeFieldLayout(Entry::class, [$this->matrixField], [new EntryTitleField()])); + if (!Craft::$app->getEntries()->saveEntryType($this->ownerEntryType)) { + throw new RuntimeException('Could not save owner entry type.'); + } + + $this->section = new Section(); + $this->section->name = 'Reorder Test Section'; + $this->section->handle = 'reorderTestSection'; + $this->section->type = Section::TYPE_CHANNEL; + $this->section->enableVersioning = false; + $this->section->propagationMethod = PropagationMethod::All; + $this->section->setEntryTypes([$this->ownerEntryType]); + $this->section->setSiteSettings([ + new Section_SiteSettings([ + 'siteId' => $primarySiteId, + 'enabledByDefault' => true, + 'hasUrls' => false, + ]), + ]); + if (!Craft::$app->getEntries()->saveSection($this->section)) { + throw new RuntimeException('Could not save section.'); + } + } + + /** + * @inheritdoc + */ + protected function _after(): void + { + Craft::$app->getEntries()->deleteSection($this->section); + Craft::$app->getEntries()->deleteEntryType($this->ownerEntryType); + Craft::$app->getEntries()->deleteEntryType($this->blockEntryType); + Craft::$app->getFields()->deleteField($this->matrixField); + Craft::$app->getFields()->deleteField($this->blockTextField); + parent::_after(); + } + + /** + * Builds a single-tab field layout containing the given layout elements. + * + * @param class-string $type + * @param \craft\base\FieldInterface[] $fields + * @param \craft\base\FieldLayoutElement[] $leadingElements + */ + private function _makeFieldLayout(string $type, array $fields, array $leadingElements = []): FieldLayout + { + $fieldLayout = new FieldLayout(['type' => $type]); + $tab = new FieldLayoutTab(['name' => 'Content']); + $tab->setLayout($fieldLayout); + $tab->setElements([ + ...$leadingElements, + ...array_map(fn($field) => new CustomField($field), $fields), + ]); + $fieldLayout->setTabs([$tab]); + return $fieldLayout; + } + + // Covers #19321: reorderNestedElements() exposes the reordering logic that previously only lived + // inline in NestedElementsController::actionReorder(), so it can be called without going through + // a CP request. + public function testReorderNestedElementsMovesElementToNewOffset(): void + { + $owner = new Entry(); + $owner->sectionId = $this->section->id; + $owner->typeId = $this->ownerEntryType->id; + $owner->title = 'Reorder Test Owner Entry'; + $owner->setFieldValue('reorderTestMatrix', [ + 'new1' => ['type' => 'reorderTestBlock', 'fields' => ['blockText' => 'First']], + 'new2' => ['type' => 'reorderTestBlock', 'fields' => ['blockText' => 'Second']], + 'new3' => ['type' => 'reorderTestBlock', 'fields' => ['blockText' => 'Third']], + ]); + + if (!$this->elements->saveElement($owner)) { + throw new RuntimeException('Could not save owner entry: ' . implode(', ', $owner->getFirstErrors())); + } + + $nested = Entry::find() + ->ownerId($owner->id) + ->siteId($owner->siteId) + ->status(null) + ->orderBy(['elements_owners.sortOrder' => SORT_ASC]) + ->all(); + self::assertSame(['First', 'Second', 'Third'], array_map(fn(Entry $e) => $e->getFieldValue('blockText'), $nested)); + + // Move "Third" to the front + $this->elements->reorderNestedElements($owner, $owner->getFieldValue('reorderTestMatrix'), [$nested[2]->id], 0); + + $reordered = Entry::find() + ->ownerId($owner->id) + ->siteId($owner->siteId) + ->status(null) + ->orderBy(['elements_owners.sortOrder' => SORT_ASC]) + ->all(); + self::assertSame(['Third', 'First', 'Second'], array_map(fn(Entry $e) => $e->getFieldValue('blockText'), $reordered)); } } From 8de14b0b771755298507ba0bdd6e7c3df8867380 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Sat, 1 Aug 2026 14:49:44 -0700 Subject: [PATCH 10/17] yii2-debug 2.1.28 --- CHANGELOG-5.11.md | 1 + composer.json | 2 +- composer.lock | 36 +++++++++++++----------------------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index a51212998a5..7badb992211 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -12,3 +12,4 @@ ### System - Updated Twig to 3.28. +- Updated yii2-debug to 2.1.28. diff --git a/composer.json b/composer.json index 8113c66be57..51937eb976d 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,7 @@ "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.3.5", "yiisoft/yii2": "~2.0.55.0", - "yiisoft/yii2-debug": "~2.1.27.0", + "yiisoft/yii2-debug": "~2.1.28.0", "yiisoft/yii2-queue": "~2.3.2", "yiisoft/yii2-symfonymailer": "^4.0.0" }, diff --git a/composer.lock b/composer.lock index 52697b00029..d31926594b5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5e1315c0b13d8578cc2b675b2e0bb09c", + "content-hash": "59806a0cd9847927bf3207761bb247e3", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7350,45 +7350,35 @@ }, { "name": "yiisoft/yii2-debug", - "version": "2.1.27", + "version": "2.1.28", "source": { "type": "git", "url": "https://github.com/yiisoft/yii2-debug.git", - "reference": "44e158914911ef81cd7111fd6d46b918f65fae7c" + "reference": "0a2ce3cb91d36d5558c7189d9012795874e1ee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-debug/zipball/44e158914911ef81cd7111fd6d46b918f65fae7c", - "reference": "44e158914911ef81cd7111fd6d46b918f65fae7c", + "url": "https://api.github.com/repos/yiisoft/yii2-debug/zipball/0a2ce3cb91d36d5558c7189d9012795874e1ee60", + "reference": "0a2ce3cb91d36d5558c7189d9012795874e1ee60", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=5.4", - "yiisoft/yii2": "~2.0.13" + "php": ">=7.4", + "yiisoft/yii2": "~2.0.54" }, "require-dev": { - "cweagans/composer-patches": "^1.7", - "phpunit/phpunit": "4.8.34", - "yiisoft/yii2-coding-standards": "~2.0", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "yiisoft/yii2-coding-standards": "~3.0", "yiisoft/yii2-swiftmailer": "*" }, "type": "yii2-extension", "extra": { - "patches": { - "phpunit/phpunit": { - "Fix PHP 7 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php7.patch", - "Fix PHP 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php8.patch", - "Fix PHP 8.1 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php81.patch" - }, - "phpunit/phpunit-mock-objects": { - "Fix PHP 7 and 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_mock_objects.patch" - } - }, "branch-alias": { "dev-master": "2.0.x-dev" - }, - "composer-exit-on-patch-failure": true + } }, "autoload": { "psr-4": { @@ -7437,7 +7427,7 @@ "type": "tidelift" } ], - "time": "2025-06-08T13:32:11+00:00" + "time": "2026-04-22T10:35:38+00:00" }, { "name": "yiisoft/yii2-queue", From 4ad6779ea337a111b26050cd749651acb73dfffe Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 3 Aug 2026 09:04:44 -0700 Subject: [PATCH 11/17] Add support for .well-known/passkey-endpoints --- src/controllers/WellKnownController.php | 30 +++++++++++++++++++ src/web/UrlManager.php | 39 +++++++++++++++++++++++-- tests/functional/WellKnownCest.php | 29 ++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/controllers/WellKnownController.php create mode 100644 tests/functional/WellKnownCest.php diff --git a/src/controllers/WellKnownController.php b/src/controllers/WellKnownController.php new file mode 100644 index 00000000000..72d4a7dd60c --- /dev/null +++ b/src/controllers/WellKnownController.php @@ -0,0 +1,30 @@ + + * @since 5.11.0 + */ +class WellKnownController extends Controller +{ + protected array|bool|int $allowAnonymous = self::ALLOW_ANONYMOUS_LIVE; + + public function actionPasskeyEndpoints(): Response + { + // Just return an empty object to signal support for passkeys, w/o leaking the CP URL. + $this->response->format = Response::FORMAT_JSON; + $this->response->content = '{}'; + return $this->response; + } +} diff --git a/src/web/UrlManager.php b/src/web/UrlManager.php index a055444b9d4..cae4ec74904 100644 --- a/src/web/UrlManager.php +++ b/src/web/UrlManager.php @@ -470,9 +470,23 @@ private function _getMatchedUrlRoute(Request $request): array|false */ private function _getMatchedDiscoverableUrlRoute(Request $request): array|false { - $redirectUri = $request->getPathInfo() === '.well-known/change-password' - ? Craft::$app->getConfig()->getGeneral()->getSetPasswordRequestPath(Craft::$app->getSites()->getCurrentSite()->handle) - : null; + return match ($request->getPathInfo()) { + '.well-known/change-password' => $this->_changePasswordRoute(), + '.well-known/passkey-endpoints' => $this->_passkeyEndpointsRoute(), + default => false, + }; + } + + /** + * Returns the route for `.well-known/change-password` requests, per + * https://w3c.github.io/webappsec-change-password-url/. + * + * @return array|false + */ + private function _changePasswordRoute(): array|false + { + $redirectUri = Craft::$app->getConfig()->getGeneral() + ->getSetPasswordRequestPath(Craft::$app->getSites()->getCurrentSite()->handle); if (App::devMode()) { Craft::debug([ @@ -495,6 +509,25 @@ private function _getMatchedDiscoverableUrlRoute(Request $request): array|false ]; } + /** + * Returns the route for `.well-known/passkey-endpoints` requests, per + * https://www.w3.org/TR/passkey-endpoints/. + * + * @return array + */ + private function _passkeyEndpointsRoute(): array + { + if (App::devMode()) { + Craft::debug([ + 'rule' => 'Discoverable passkey endpoints', + 'match' => true, + 'parent' => null, + ], __METHOD__); + } + + return ['well-known/passkey-endpoints']; + } + /** * Returns whether the current path is "public" (no segments that start with the privateTemplateTrigger). * diff --git a/tests/functional/WellKnownCest.php b/tests/functional/WellKnownCest.php new file mode 100644 index 00000000000..f795251d9d9 --- /dev/null +++ b/tests/functional/WellKnownCest.php @@ -0,0 +1,29 @@ + + * @since 5.11.0 + */ +class WellKnownCest +{ + /** + * @param FunctionalTester $I + */ + public function testPasskeyEndpoints(FunctionalTester $I) + { + $I->amOnPage('/.well-known/passkey-endpoints'); + $I->seeResponseCodeIs(200); + $I->assertSame('{}', $I->grabPageSource()); + } +} From 9ae131664ffe1bdbdf750941f73e71fb1d2341c6 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 3 Aug 2026 09:35:51 -0700 Subject: [PATCH 12/17] Release note [ci skip] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6beb1bd488e..daabe83da07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added support for `.well-known/passkey-endpoints` requests. ([#19364](https://github.com/craftcms/cms/pull/19364)) - Fixed a bug where image transforms could be generated from a stale locally-cached copy of an asset’s source file, after the file was replaced on a non-local filesystem. ([#19328](https://github.com/craftcms/cms/issues/19328)) - Fixed a bug where warnings were getting logged for SSO attributes that mapped to native user properties. ([#19294](https://github.com/craftcms/cms/pull/19294)) - Fixed an error that could occur when applying a draft. ([#19277](https://github.com/craftcms/cms/issues/19277)) From c796b83afbf9ef6f4a535a51cd61e5e6da8df60c Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 3 Aug 2026 09:51:37 -0700 Subject: [PATCH 13/17] Wrong changelog [ci skip] --- CHANGELOG-5.11.md | 1 + CHANGELOG.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 7badb992211..b57f7ad6f5f 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -11,5 +11,6 @@ - The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) ### System +- Added support for `.well-known/passkey-endpoints` requests. ([#19364](https://github.com/craftcms/cms/pull/19364)) - Updated Twig to 3.28. - Updated yii2-debug to 2.1.28. diff --git a/CHANGELOG.md b/CHANGELOG.md index daabe83da07..6beb1bd488e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## Unreleased -- Added support for `.well-known/passkey-endpoints` requests. ([#19364](https://github.com/craftcms/cms/pull/19364)) - Fixed a bug where image transforms could be generated from a stale locally-cached copy of an asset’s source file, after the file was replaced on a non-local filesystem. ([#19328](https://github.com/craftcms/cms/issues/19328)) - Fixed a bug where warnings were getting logged for SSO attributes that mapped to native user properties. ([#19294](https://github.com/craftcms/cms/pull/19294)) - Fixed an error that could occur when applying a draft. ([#19277](https://github.com/craftcms/cms/issues/19277)) From 991b950670fa2bbae428aff856803f318bcb21fe Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 5 Aug 2026 08:14:16 -0700 Subject: [PATCH 14/17] Simplify the migration Only create missing assets_sites rows for assets that have globally-defined alt text, and null/missing translated alt text --- ...0_065719_translatable_alt_text_content.php | 69 ++++++++----------- 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/src/migrations/m260610_065719_translatable_alt_text_content.php b/src/migrations/m260610_065719_translatable_alt_text_content.php index 913f78b2723..43ec20294ab 100644 --- a/src/migrations/m260610_065719_translatable_alt_text_content.php +++ b/src/migrations/m260610_065719_translatable_alt_text_content.php @@ -6,7 +6,6 @@ use craft\db\Query; use craft\db\Table; use craft\helpers\Db; -use craft\records\Asset; /** * m260610_065719_translatable_alt_text_content migration. @@ -18,49 +17,39 @@ class m260610_065719_translatable_alt_text_content extends Migration */ public function safeUp(): bool { - // get all asset records - we only need the id, alt and siteId - $query = Asset::find() - ->leftJoin('{{%elements_sites}}', '[[elements_sites.elementId]] = [[assets.id]]') - ->select(['assets.id', 'elements_sites.siteId', 'assets.alt']); + // Find assets that have globally-defined alt text and null/missing translated alt text, + // and fill in their translated values. - $assetsRecords = $query->createCommand()->queryAll(); + $query = (new Query()) + // fetch assets_sites.assetId so we know whether the row already exists + ->select(['assets.id', 'elements_sites.siteId', 'assets.alt', 'assets_sites.assetId']) + ->from(['assets' => Table::ASSETS]) + ->innerJoin(['elements_sites' => Table::ELEMENTS_SITES], '[[elements_sites.elementId]] = [[assets.id]]') + ->leftJoin(['assets_sites' => Table::ASSETS_SITES], [ + 'and', + '[[assets_sites.assetId]] = [[assets.id]]', + '[[assets_sites.siteId]] = [[elements_sites.siteId]]', + ]) + ->where(['not', ['assets.alt' => null]]) + ->andWhere(['not', ['assets.alt' => '']]) + ->andWhere(['assets_sites.alt' => null]); - // for each of those records, - foreach ($assetsRecords as $assetRecord) { - $exists = (new Query()) - ->from(Table::ASSETS_SITES) - ->where([ - 'assetId' => $assetRecord['id'], - 'siteId' => $assetRecord['siteId'], ] - ) - ->exists(); - - if ($exists) { - // if the assets_sites row exists and the alt column is null, copy over the value from the assets.alt - Db::update( - Table::ASSETS_SITES, - [ - 'alt' => $assetRecord['alt'], - ], - [ - 'assetId' => $assetRecord['id'], - 'siteId' => $assetRecord['siteId'], - 'alt' => null, - ] - ); + foreach (Db::each($query) as $row) { + // If the assets_sites.assetId value came back, the row already exists + if (isset($row['assetId'])) { + Db::update(Table::ASSETS_SITES, [ + 'alt' => $row['alt'], + ], [ + 'assetId' => $row['id'], + 'siteId' => $row['siteId'], + ]); } else { - // if the assets_sites row doesn't exist, insert one with the assets.alt value - Db::insert( - Table::ASSETS_SITES, - [ - 'assetId' => $assetRecord['id'], - 'siteId' => $assetRecord['siteId'], - 'alt' => $assetRecord['alt'], - ] - ); + Db::insert(Table::ASSETS_SITES, [ + 'assetId' => $row['id'], + 'siteId' => $row['siteId'], + 'alt' => $row['alt'], + ]); } - // the above ensures we're not changing the alt text developers might expect during the update, - // but at the same time we're all set to no longer use the assets.alt column } return true; From c516b0ee7682fceac63d6dd6bb9e86efff4d0790 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 5 Aug 2026 08:15:21 -0700 Subject: [PATCH 15/17] Cleanup --- src/elements/db/AssetQuery.php | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/elements/db/AssetQuery.php b/src/elements/db/AssetQuery.php index ac64058968b..1334cf6d0e6 100644 --- a/src/elements/db/AssetQuery.php +++ b/src/elements/db/AssetQuery.php @@ -1015,25 +1015,25 @@ protected function beforePrepare(): bool protected function afterPrepare(): bool { if ($this->hasAlt !== null) { - $hasAltCondition = [ + $this->subQuery->leftJoin(['assets_sites' => Table::ASSETS_SITES], [ 'and', - ['not', ['assets_sites.alt' => '']], - ['not', ['assets_sites.alt' => null]], - ]; + '[[assets_sites.assetId]] = [[assets.id]]', + '[[assets_sites.siteId]] = [[elements_sites.siteId]]', + ]); - $withoutAltCondition = [ - 'or', - ['assets_sites.alt' => ''], - ['assets_sites.alt' => null], - ]; - - $this->subQuery - ->leftJoin(['assets_sites' => Table::ASSETS_SITES], [ + if ($this->hasAlt) { + $this->subQuery->andWhere([ 'and', - '[[assets_sites.assetId]] = [[assets.id]]', - '[[assets_sites.siteId]] = [[elements_sites.siteId]]', - ]) - ->andWhere($this->hasAlt ? $hasAltCondition : $withoutAltCondition); + ['not', ['assets_sites.alt' => '']], + ['not', ['assets_sites.alt' => null]], + ]); + } else { + $this->subQuery->andWhere([ + 'or', + ['assets_sites.alt' => ''], + ['assets_sites.alt' => null], + ]); + } } return parent::afterPrepare(); From 24371bd859aa3f16b3e53cb73917c268ce8cf0aa Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 5 Aug 2026 15:25:59 -0700 Subject: [PATCH 16/17] Fill in the gaps when converting a LinkData object to an array Resolves craftcms/element-api#201 --- CHANGELOG-5.11.md | 1 + src/fields/data/LinkData.php | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index b57f7ad6f5f..78b75435980 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -3,6 +3,7 @@ ### Development - The `params` argument of the `url()` Twig function now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) - Added `craft\web\DbSession`, which should be used instead of `yii\web\DbSession` to prevent “headers already sent” warnings from getting logged. ([#19139](https://github.com/craftcms/cms/issues/19139)) +- Arrays created from `craft\fields\data\LinkData` objects now include `type`, `value`, `url`, `label`, `filename`, `link`, `attributes`, `defaultLabel`, `elementType`, `elementId`, `elementSiteId`, and `elementTitle` keys. ([craftcms/element-api#201](https://github.com/craftcms/element-api/issues/201)) ### Extensibility - Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) diff --git a/src/fields/data/LinkData.php b/src/fields/data/LinkData.php index d453755f352..ec9d7f8b05d 100644 --- a/src/fields/data/LinkData.php +++ b/src/fields/data/LinkData.php @@ -18,6 +18,8 @@ use craft\helpers\Template; use craft\web\twig\AllowedInSandbox; use Twig\Markup; +use yii\base\Arrayable; +use yii\base\ArrayableTrait; use yii\base\BaseObject; /** @@ -35,8 +37,12 @@ * @author Pixel & Tonic, Inc. * @since 5.3.0 */ -class LinkData extends BaseObject implements Serializable +class LinkData extends BaseObject implements Serializable, Arrayable { + use ArrayableTrait { + fields as traitFields; + } + /** * @var string|null The link’s URL suffix value. * @since 5.6.0 @@ -110,6 +116,29 @@ public function __toString(): string return $this->getUrl(); } + /** + * @inheritdoc + */ + public function fields(): array + { + $fields = $this->traitFields(); + + $fields['type'] = 'type'; + $fields['value'] = 'value'; + $fields['url'] = 'url'; + $fields['label'] = 'label'; + $fields['filename'] = 'filename'; + $fields['link'] = 'link'; + $fields['attributes'] = 'attributes'; + $fields['defaultLabel'] = fn() => $this->getLabel(false); + $fields['elementType'] = fn() => $this->getElement() ? $this->getElement()::class : null; + $fields['elementId'] = fn() => $this->getElement()?->id; + $fields['elementSiteId'] = fn() => $this->getElement()?->siteId; + $fields['elementTitle'] = fn() => $this->getElement() ? (string)$this->getElement() : null; + + return $fields; + } + /** * Returns the link type ID. * From 312a3101dbc79b315a49890bc7f8749037c667c2 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 6 Aug 2026 10:53:43 -0700 Subject: [PATCH 17/17] Release note [ci skip] --- CHANGELOG-5.11.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 78b75435980..6a4c4815899 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -1,5 +1,8 @@ # Release Notes for Craft CMS 5.11 (WIP) +### Content Management +- Asset alt text no longer gets propagated to all sites the first time it’s filled in. ([#19067](https://github.com/craftcms/cms/pull/19067)) + ### Development - The `params` argument of the `url()` Twig function now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) - Added `craft\web\DbSession`, which should be used instead of `yii\web\DbSession` to prevent “headers already sent” warnings from getting logged. ([#19139](https://github.com/craftcms/cms/issues/19139))