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

Adding Store-View-Specific Visibility to the Form

Adding Store-View-Specific Visibility to the Form

~10 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Block 4 wraps up with the final functional requirement from chapter 14: a testimonial should be selectively visible (or not) per store view - the same pattern used for CMS blocks. That needs three additions: a form field with the store view tree, read/write logic in the resource model for the second table from chapter 14, and optionally a collection filter.

The store view field

Magento already ships a ready-made component for exactly this case - the same store view selection tree used by CMS blocks and pages:

<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 automatically supplies the complete website/store group/store view tree, including an "All Store Views" option - the same class the core product grid uses to fill its own store filter.

Extending the resource model with store relations

For store_id to be populated as an array on load and correctly written to mironsoft_testimonial_store on save, the resource model overrides _afterLoad() and _afterSave():

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

"Delete all relations first, then insert fresh" is deliberately simpler than diffing old and new IDs - with a manageable number of store views per testimonial, the performance difference is negligible, and the code stays significantly less error-prone.

Collection filter for the storefront

For a later storefront display (outside the scope of this series), the collection needs a join onto the store table - shown here as a preview, with the correct 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: A common mistake when rebuilding this pattern: _afterLoad() is not automatically called per row when a grid loads its data, because grids read directly through the collection, not through individual model loads. A store column in the grid itself needs its own collection join, not the resource model's _afterLoad() logic.

Tipp: Because _afterSave() deletes and rewrites all relations for the current ID, every store assignment automatically lands correctly in a single database transaction together with the main save() call, as long as the resource model call itself runs inside a transaction - a manual beginTransaction() isn't needed here, AbstractDb::save() already handles that.

That completes the continuous base project: tables, grid, form, delete, and store visibility. Block 5 builds advanced techniques on top of this exact module - starting with custom renderers in chapter 19.