From ef2ce9ea2cc957a68de3cefe1de9973bbd216a5a Mon Sep 17 00:00:00 2001 From: i-just Date: Tue, 28 Oct 2025 13:20:32 +0100 Subject: [PATCH 1/8] when publishing element, ensure required attrs & fields have values --- src/base/Element.php | 1 + src/base/ElementTrait.php | 7 +++++ src/base/Field.php | 1 + src/controllers/ElementsController.php | 5 +++ src/services/Elements.php | 42 ++++++++++++++++++++------ 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/base/Element.php b/src/base/Element.php index 24c7eba2866..36eba18f8f8 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -2751,6 +2751,7 @@ public function attributes(): array $names['isNewSite'], $names['previewing'], $names['propagateAll'], + $names['propagateRequired'], $names['propagating'], $names['propagatingFrom'], $names['resaving'], diff --git a/src/base/ElementTrait.php b/src/base/ElementTrait.php index d0a64591447..cb3ae567958 100644 --- a/src/base/ElementTrait.php +++ b/src/base/ElementTrait.php @@ -196,6 +196,13 @@ trait ElementTrait */ public bool $propagateAll = false; + /** + * @var bool Whether all required element attributes should be propagated across all its supported sites, but only if otherwise + * they wouldn’t validate. + * @since 5.9.0 + */ + public bool $propagateRequired = false; + /** * @var int[] The site IDs that the element was just propagated to for the first time. * @since 3.2.9 diff --git a/src/base/Field.php b/src/base/Field.php index 859826a0708..668e2ccf408 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -214,6 +214,7 @@ abstract class Field extends SavableComponent implements FieldInterface, Iconic, 'prevSibling', 'previewing', 'propagateAll', + 'propagateRequired', 'propagating', 'ref', 'relatedToAssets', diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index da6b495f2a2..fe9b24bc611 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -2135,6 +2135,11 @@ public function actionApplyDraft(): ?Response $element->setScenario(Element::SCENARIO_LIVE); } + // if we're about to apply an unpublished draft, set propagateRequired to true + if ($isUnpublishedDraft) { + $element->propagateRequired = true; + } + $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); if (!$elementsService->saveElement($element, crossSiteValidate: ($namespace === null && Craft::$app->getIsMultiSite()))) { return $this->_asAppyDraftFailure($element); diff --git a/src/services/Elements.php b/src/services/Elements.php index ddccdad1b8b..3d7a0a02f11 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -4328,7 +4328,10 @@ private function _propagateElement( // Copy the title value? if ( $element::hasTitles() && - $siteElement->getTitleTranslationKey() === $element->getTitleTranslationKey() + ( + $siteElement->getTitleTranslationKey() === $element->getTitleTranslationKey() || + ($element->propagateRequired && empty($siteElement->title)) + ) ) { $siteElement->title = $element->title; } @@ -4336,7 +4339,10 @@ private function _propagateElement( // Copy the slug value? if ( $element->slug !== null && - $siteElement->getSlugTranslationKey() === $element->getSlugTranslationKey() + ( + $siteElement->getSlugTranslationKey() === $element->getSlugTranslationKey() || + ($element->propagateRequired && empty($siteElement->slug)) + ) ) { $siteElement->slug = $element->slug; } @@ -4359,6 +4365,15 @@ private function _propagateElement( } } + // Save it + $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); + + // validate element against "live" scenario across all sites, if element is enabled for the site + if ($crossSiteValidate && $siteElement->enabled && $siteElement->getEnabledForSite()) { + $siteElement->setScenario(Element::SCENARIO_LIVE); + } + + // Copy the dirty attributes (except title, slug and uri, which may be translatable) $siteElement->setDirtyAttributes(array_filter($element->getDirtyAttributes(), fn(string $attribute): bool => $attribute !== 'title' && $attribute !== 'slug')); @@ -4385,18 +4400,25 @@ private function _propagateElement( $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); } } + + // if propagateRequired is true and site element doesn't already validate + if ($element->propagateRequired && !$siteElement->validate()) { + // iterate through the custom fields again + foreach ($fieldLayout->getCustomFields() as $field) { + // the layout element is required and invalid + if ( + $field->layoutElement->required && + !empty($siteElement->getErrors($field->handle)) + ) { + // copy the initial element’s value over + $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); + } + } + } } } } - // Save it - $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); - - // validate element against "live" scenario across all sites, if element is enabled for the site - if ($crossSiteValidate && $siteElement->enabled && $siteElement->getEnabledForSite()) { - $siteElement->setScenario(Element::SCENARIO_LIVE); - } - $siteElement->propagating = true; $siteElement->propagatingFrom = $element; From cd6fa231ff2923e83044e87bb1b09d440f6fde02 Mon Sep 17 00:00:00 2001 From: i-just Date: Tue, 28 Oct 2025 14:40:46 +0100 Subject: [PATCH 2/8] apply the same logic to nested elements (cards, element index) --- src/services/Elements.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/Elements.php b/src/services/Elements.php index 3d7a0a02f11..89634a36757 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -4369,7 +4369,11 @@ private function _propagateElement( $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); // validate element against "live" scenario across all sites, if element is enabled for the site - if ($crossSiteValidate && $siteElement->enabled && $siteElement->getEnabledForSite()) { + if ( + ($crossSiteValidate || $element->propagateRequired) && + $siteElement->enabled && + $siteElement->getEnabledForSite() + ) { $siteElement->setScenario(Element::SCENARIO_LIVE); } From 6beaf8b85ab06b9c69fd85d25dc8c7c464e44b55 Mon Sep 17 00:00:00 2001 From: i-just Date: Wed, 29 Oct 2025 10:00:32 +0100 Subject: [PATCH 3/8] propagateRequired for nested "block" elements (mtx blocks, content block) --- src/elements/NestedElementManager.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index 761c98100e0..69cd022f370 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -680,7 +680,7 @@ public function maintainNestedElements(ElementInterface $owner, bool $isNew): vo $this->duplicateNestedElements($owner->duplicateOf, $owner, true, !$isNew); } $resetValue = true; - } elseif ($this->isDirty($owner) || !empty($owner->newSiteIds)) { + } elseif ($this->isDirty($owner) || !empty($owner->newSiteIds) || $owner->propagateRequired) { $this->saveNestedElements($owner); } elseif ($owner->mergingCanonicalChanges) { $this->mergeCanonicalChanges($owner); @@ -798,6 +798,11 @@ private function saveNestedElements(ElementInterface $owner): void $elementsService->restoreElement($element); } + // if the owner is propagating required fields and attributes, so should the nested elements + if ($owner->propagateRequired) { + $element->propagateRequired = true; + } + $sortOrder++; if ($saveAll || !$element->id || $element->forceSave) { $element->setOwner($owner); From 86a1bd2da31a7b9247933b2ef4234b0b45f02e11 Mon Sep 17 00:00:00 2001 From: i-just Date: Wed, 29 Oct 2025 13:09:08 +0100 Subject: [PATCH 4/8] account for various propagation methods --- src/elements/NestedElementManager.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index 69cd022f370..7be03c40212 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -855,7 +855,7 @@ private function saveNestedElements(ElementInterface $owner): void // Should we duplicate the elements to other sites? if ( $this->propagationMethod !== PropagationMethod::All && - ($owner->propagateAll || !empty($owner->newSiteIds)) + ($owner->propagateAll || !empty($owner->newSiteIds) || ($owner->propagateRequired && $this->field->layoutElement->required)) ) { // Find the owner's site IDs that *aren't* supported by this site's nested elements $ownerSiteIds = array_map( @@ -866,7 +866,7 @@ private function saveNestedElements(ElementInterface $owner): void $otherSiteIds = array_diff($ownerSiteIds, $fieldSiteIds); // If propagateAll isn't set, only deal with sites that the element was just propagated to for the first time - if (!$owner->propagateAll) { + if (!$owner->propagateAll && !$owner->propagateRequired) { $preexistingOtherSiteIds = array_diff($otherSiteIds, $owner->newSiteIds); $otherSiteIds = array_intersect($otherSiteIds, $owner->newSiteIds); } else { @@ -920,7 +920,16 @@ private function saveNestedElements(ElementInterface $owner): void } else { // Duplicate the elements, but **don't track** the duplications, so the edit page doesn’t think // its elements have been replaced by the other sites’ nested elements - $this->duplicateNestedElements($owner, $localizedOwner, force: true); + if (!$owner->propagateRequired) { + $this->duplicateNestedElements($owner, $localizedOwner, force: true); + } elseif ($this->field->layoutElement->required) { + // if we're propagating required and the field is required, and it doesn't validate because of this field, + // duplicate like above + $localizedOwner->setScenario(Element::SCENARIO_LIVE); + if (!$localizedOwner->validate() && !empty($localizedOwner->getErrors($this->field->handle))) { + $this->duplicateNestedElements($owner, $localizedOwner, force: true); + } + } } // Make sure we don't duplicate elements for any of the sites that were just propagated to From d90a21919dc74a726bf20bfd76211ad081a20a4e Mon Sep 17 00:00:00 2001 From: i-just Date: Thu, 30 Oct 2025 10:25:49 +0100 Subject: [PATCH 5/8] bug fixes --- src/controllers/ElementsController.php | 1 + src/elements/NestedElementManager.php | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index fe9b24bc611..3f926b832f9 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -2159,6 +2159,7 @@ public function actionApplyDraft(): ?Response } try { + $element->propagateRequired = false; $canonical = Craft::$app->getDrafts()->applyDraft($element, $attributes); } catch (InvalidElementException) { return $this->_asAppyDraftFailure($element); diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index 7be03c40212..540f1bc954d 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -854,8 +854,11 @@ private function saveNestedElements(ElementInterface $owner): void // Should we duplicate the elements to other sites? if ( - $this->propagationMethod !== PropagationMethod::All && - ($owner->propagateAll || !empty($owner->newSiteIds) || ($owner->propagateRequired && $this->field->layoutElement->required)) + ( + $this->propagationMethod !== PropagationMethod::All && + ($owner->propagateAll || !empty($owner->newSiteIds)) + ) || + ($owner->propagateRequired && $this->field->layoutElement->required) ) { // Find the owner's site IDs that *aren't* supported by this site's nested elements $ownerSiteIds = array_map( @@ -920,9 +923,9 @@ private function saveNestedElements(ElementInterface $owner): void } else { // Duplicate the elements, but **don't track** the duplications, so the edit page doesn’t think // its elements have been replaced by the other sites’ nested elements - if (!$owner->propagateRequired) { + if ($owner->propagateAll) { $this->duplicateNestedElements($owner, $localizedOwner, force: true); - } elseif ($this->field->layoutElement->required) { + } elseif ($owner->propagateRequired && $this->field->layoutElement->required) { // if we're propagating required and the field is required, and it doesn't validate because of this field, // duplicate like above $localizedOwner->setScenario(Element::SCENARIO_LIVE); From 71ebe4ec0cf8708860b6caf8e70f6e7255a47a72 Mon Sep 17 00:00:00 2001 From: i-just Date: Thu, 30 Oct 2025 12:19:11 +0100 Subject: [PATCH 6/8] give fields a chance to handle propagate required --- src/base/Field.php | 14 ++++++++++++++ src/elements/NestedElementManager.php | 2 +- src/services/Elements.php | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/base/Field.php b/src/base/Field.php index 668e2ccf408..d5deff7ce27 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -1368,4 +1368,18 @@ protected function isFresh(?ElementInterface $element = null): bool return true; } + + /** + * Gives fields a chance to handle special cases when propagating required fields. + * + * @param ElementInterface $element + * @param ElementInterface $siteElement + * @return void + * @since 5.9.0 + */ + public function handlePropagateRequired(ElementInterface $element, ElementInterface $siteElement): void + { + // by default, no extra processing is needed when propagating required fields, + // but plugins can use this method when they need to; + } } diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index 540f1bc954d..c3cc456861b 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -1010,7 +1010,7 @@ private function deleteOtherNestedElements(ElementInterface $owner, array $excep * which weren’t included in the duplication * @param bool $force Whether to force duplication, even if it looks like only the nested element ownership was duplicated */ - private function duplicateNestedElements( + public function duplicateNestedElements( ElementInterface $source, ElementInterface $target, bool $checkOtherSites = false, diff --git a/src/services/Elements.php b/src/services/Elements.php index 89634a36757..49da666a6ea 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -4416,6 +4416,8 @@ private function _propagateElement( ) { // copy the initial element’s value over $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); + // give plugins a chance to do special processing if required + $field->handlePropagateRequired($element, $siteElement); } } } From a3133d80563e02dfb40061478b24bdd1e780be73 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 7 Nov 2025 11:08:42 -0800 Subject: [PATCH 7/8] Combine the propagateAll & propagateRequired logic --- src/base/Field.php | 12 +++--------- src/base/FieldInterface.php | 9 +++++++++ src/services/Elements.php | 29 ++++++++--------------------- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/base/Field.php b/src/base/Field.php index d5deff7ce27..d8dd502f2a1 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -1370,16 +1370,10 @@ protected function isFresh(?ElementInterface $element = null): bool } /** - * Gives fields a chance to handle special cases when propagating required fields. - * - * @param ElementInterface $element - * @param ElementInterface $siteElement - * @return void - * @since 5.9.0 + * @inheritdoc */ - public function handlePropagateRequired(ElementInterface $element, ElementInterface $siteElement): void + public function propagateValue(ElementInterface $from, ElementInterface $to): void { - // by default, no extra processing is needed when propagating required fields, - // but plugins can use this method when they need to; + $to->setFieldValue($this->handle, $from->getFieldValue($this->handle)); } } diff --git a/src/base/FieldInterface.php b/src/base/FieldInterface.php index 46607f58e74..8f53fd99b76 100644 --- a/src/base/FieldInterface.php +++ b/src/base/FieldInterface.php @@ -500,6 +500,15 @@ public function modifyElementIndexQuery(ElementQueryInterface $query): void; */ public function setIsFresh(?bool $isFresh = null): void; + /** + * Copies the field value from one site to another. + * + * @param ElementInterface $from + * @param ElementInterface $to + * @since 5.9.0 + */ + public function propagateValue(ElementInterface $from, ElementInterface $to): void; + /** * Returns whether the field should be included in the given GraphQL schema. * diff --git a/src/services/Elements.php b/src/services/Elements.php index 49da666a6ea..a3785b58343 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -4390,35 +4390,22 @@ private function _propagateElement( $fieldLayout = $element->getFieldLayout(); if ($fieldLayout !== null) { - // Only copy the non-translatable field values foreach ($fieldLayout->getCustomFields() as $field) { - // Has this field changed, and does it produce the same translation key as it did for the initial element? if ( $element->propagateAll || + // If propagateRequired is set, is the field value invalid on the propagated site element? + ( + $element->propagateRequired && + $field->layoutElement->required && + !$siteElement->validate("field:$field->handle") + ) || + // Has this field changed, and does it produce the same translation key as it did for the initial element? ( $element->isFieldDirty($field->handle) && $field->getTranslationKey($siteElement) === $field->getTranslationKey($element) ) ) { - // Copy the initial element’s value over - $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); - } - } - - // if propagateRequired is true and site element doesn't already validate - if ($element->propagateRequired && !$siteElement->validate()) { - // iterate through the custom fields again - foreach ($fieldLayout->getCustomFields() as $field) { - // the layout element is required and invalid - if ( - $field->layoutElement->required && - !empty($siteElement->getErrors($field->handle)) - ) { - // copy the initial element’s value over - $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); - // give plugins a chance to do special processing if required - $field->handlePropagateRequired($element, $siteElement); - } + $field->propagateValue($element, $siteElement); } } } From 1f4bf4093867109546fa5d6d8a45f4bec0287ea1 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 7 Nov 2025 11:14:37 -0800 Subject: [PATCH 8/8] Release notes --- CHANGELOG-WIP.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 5fe2e77f914..e9ff6c9d0f7 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -46,6 +46,8 @@ - Subnav items within the global control panel navigation can now have icons. ([#17879](https://github.com/craftcms/cms/pull/17879)) - Added `craft\base\ElementIndex::multiPageSources()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) - Added `craft\base\ElementTrait::$hasProvisionalChanges`. ([#17915](https://github.com/craftcms/cms/pull/17915)) +- Added `craft\base\ElementTrait::$propagateRequired`. +- Added `craft\base\FieldInterface::propagateValue()`. - Added `craft\elements\User::isInGroups()`. ([#17989](https://github.com/craftcms/cms/discussions/17989)) - Added `craft\elements\conditions\HintableConditionRuleTrait`. ([#17909](https://github.com/craftcms/cms/pull/17909)) - Added `craft\events\RegisterElementCardAttributesEvent::$fieldLayout`. ([#17920](https://github.com/craftcms/cms/pull/17920)) @@ -100,4 +102,5 @@ - Fixed a bug where elements with unsaved changes could show outdated attribute/field values within element index tables, chips, and cards throughout the control panel. ([#17915](https://github.com/craftcms/cms/pull/17915)) - Fixed a bug where Table fields with the “Static Rows” setting enabled would lose track of which values belonged to which row headings, if the “Default Values” table was reordered. ([#17090](https://github.com/craftcms/cms/issues/17090)) - Fixed a bug where requests with invalid tokens would throw an exception before the application was fully initialized, which could lead to other errors. ([#18000](https://github.com/craftcms/cms/issues/18000)) +- Fixed a bug where titles, slugs, and required custom field values weren’t always getting propagated to other sites when creating a new element. ([#17955](https://github.com/craftcms/cms/issues/17955)) - Updated Twig to 3.21. ([#17603](https://github.com/craftcms/cms/discussions/17603))