Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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():

app/code/Mironsoft/Testimonial/Model/ResourceModel/Testimonial.php (erweitert)
<?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.