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