Store-View-spezifische Sichtbarkeit im Formular ergänzen
Store-View-spezifische Sichtbarkeit im Formular ergänzen
~10 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Block 4 schließt mit der letzten fachlichen Anforderung aus Kapitel 14: eine Kundenstimme soll gezielt für einzelne Store Views sichtbar (oder nicht sichtbar) sein - dasselbe Muster wie bei CMS-Blöcken. Das braucht drei Ergänzungen: ein Formularfeld mit dem Store-View-Baum, Lese-/Schreiblogik im ResourceModel für die zweite Tabelle aus Kapitel 14, und optional einen Collection-Filter.
Das Store-View-Feld
Magento bringt für genau diesen Fall bereits eine fertige Komponente mit - denselben Store-View-Auswahlbaum, den auch CMS-Blöcke und -Seiten nutzen:
<field name="store_id" formElement="multiselect" sortOrder="20">
<settings>
<dataType>text</dataType>
<label translate="true">Store Views</label>
</settings>
<formElements>
<multiselect>
<settings>
<options class="Magento\Store\Ui\Component\Listing\Column\Store\Options"/>
</settings>
</multiselect>
</formElements>
</field>Magento\Store\Ui\Component\Listing\Column\Store\Options liefert automatisch den kompletten Website/Store-Group/Store-View-Baum inklusive einer "Alle Stores"-Option - dieselbe Klasse, mit der auch der Produkt-Grid im Core seinen Store-Filter füllt.
ResourceModel um Store-Relationen erweitern
Damit store_id als Array beim Laden befüllt und beim Speichern korrekt in mironsoft_testimonial_store geschrieben wird, überschreibt das ResourceModel _afterLoad() und _afterSave():
<?php
declare(strict_types=1);
namespace Mironsoft\Testimonial\Model\ResourceModel;
use Magento\Framework\Model\AbstractModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
/**
* Testimonial resource model, including store-view relation handling.
*/
class Testimonial extends AbstractDb
{
private const STORE_TABLE = 'mironsoft_testimonial_store';
/**
* Initializes the main table and primary key column.
*
* @return void
*/
protected function _construct(): void
{
$this->_init('mironsoft_testimonial', 'testimonial_id');
}
/**
* Loads the assigned store IDs after loading the entity itself.
*
* @param AbstractModel $object Loaded testimonial entity.
* @return $this
*/
protected function _afterLoad(AbstractModel $object): self
{
if ($object->getId()) {
$connection = $this->getConnection();
$select = $connection->select()
->from($this->getTable(self::STORE_TABLE), ['store_id'])
->where('testimonial_id = ?', (int) $object->getId());
$object->setData('store_id', $connection->fetchCol($select));
}
return parent::_afterLoad($object);
}
/**
* Persists the assigned store IDs after saving the entity itself.
*
* @param AbstractModel $object Saved testimonial entity.
* @return $this
*/
protected function _afterSave(AbstractModel $object): self
{
$connection = $this->getConnection();
$testimonialId = (int) $object->getId();
$connection->delete(
$this->getTable(self::STORE_TABLE),
['testimonial_id = ?' => $testimonialId]
);
$storeIds = (array) $object->getData('store_id');
if ($storeIds !== []) {
$rows = [];
foreach ($storeIds as $storeId) {
$rows[] = ['testimonial_id' => $testimonialId, 'store_id' => (int) $storeId];
}
$connection->insertMultiple($this->getTable(self::STORE_TABLE), $rows);
}
return parent::_afterSave($object);
}
}"Erst alle Relationen löschen, dann neu einfügen" ist bewusst einfacher als ein Abgleich alter/neuer IDs - bei einer überschaubaren Anzahl Store Views pro Kundenstimme ist der Performance-Unterschied vernachlässigbar, und der Code bleibt deutlich weniger fehleranfällig.
Collection-Filter für die Storefront
Für eine spätere Storefront-Anzeige (außerhalb dieser Serie) braucht die Collection einen Join auf die Store-Tabelle - hier als Ausblick, mit korrekter addFieldToFilter()-Array-Form:
/**
* Restricts the collection to testimonials assigned to a given store.
*
* @param int $storeId Store ID to filter by.
* @return $this
*/
public function addStoreFilter(int $storeId): self
{
$this->getSelect()->join(
['store_relation' => $this->getTable('mironsoft_testimonial_store')],
'main_table.testimonial_id = store_relation.testimonial_id',
[]
);
$this->addFieldToFilter('store_relation.store_id', ['eq' => $storeId]);
return $this;
}Achtung: Ein häufiger Fehler beim Nachbau dieses Musters: _afterLoad() wird auch beim Grid-Laden pro Zeile nicht automatisch aufgerufen, weil Grids über die Collection direkt lesen, nicht über einzelne Model-Loads. Für eine Store-Spalte im Grid selbst braucht es einen eigenen Collection-Join, nicht die _afterLoad()-Logik des ResourceModels.
Tipp: Weil _afterSave() alle Relationen für die aktuelle ID löscht und neu schreibt, landet jede Store-Zuordnung automatisch korrekt in einer einzelnen Datenbanktransaktion mit dem Haupt-save()-Aufruf, solange der ResourceModel-Aufruf selbst in einer Transaktion läuft - ein manuelles beginTransaction() ist hier nicht nötig, das übernimmt AbstractDb::save() bereits.
Damit ist das durchgehende Grundprojekt fertig: Tabellen, Grid, Formular, Löschen und Store-Sichtbarkeit. Block 5 baut auf genau diesem Modul fortgeschrittene Techniken auf - beginnend mit Custom-Renderern in Kapitel 19.