Project Introduction: A Custom Module for Testimonials, DB Schema With db_schema.xml
Project Introduction: A Custom Module for Testimonials, DB Schema With db_schema.xml
~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
From here on, a single project runs through the rest of this series: managing testimonials in the admin area, as a standalone module Mironsoft\Testimonial. Each of the following 17 chapters extends this exact module - no more switching examples.
Functional requirements
A testimonial consists of the customer's name and company, a rating (1 to 5 stars), the actual text, an optional photo, and an active status. In addition, a testimonial should be selectively shown or hidden per store view (block 4, chapter 18) - a pattern known from Magento_Cms (CMS blocks/pages) that we deliberately rebuild here.
Two tables instead of one
Store view assignment needs an n:m relationship: a testimonial can be visible on multiple stores, and a store can show multiple testimonials. A single store_id column isn't enough for that - it needs a second, linking table, exactly like cms_block_store in Magento core.
Starting state of the testimonial module (block 4)
app/code/Mironsoft/Testimonial/
├── registration.php
├── composer.json
├── etc/
│ ├── module.xml
│ ├── db_schema.xml
│ ├── acl.xml
│ └── adminhtml/
│ ├── routes.xml
│ └── menu.xml
└── Model/
├── Testimonial.php
└── ResourceModel/
├── Testimonial.php
└── Testimonial/
└── Collection.php<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_testimonial" resource="default" engine="innodb"
comment="Mironsoft Testimonial Table">
<column xsi:type="int" name="testimonial_id" padding="10" unsigned="true"
nullable="false" identity="true" comment="Testimonial ID"/>
<column xsi:type="varchar" name="customer_name" nullable="false" length="255"
comment="Customer Name"/>
<column xsi:type="varchar" name="company" nullable="true" length="255"
comment="Company"/>
<column xsi:type="smallint" name="rating" padding="5" unsigned="true"
nullable="false" identity="false" default="5" comment="Rating 1-5"/>
<column xsi:type="text" name="testimonial_text" nullable="false"
comment="Testimonial Text"/>
<column xsi:type="varchar" name="image" nullable="true" length="255"
comment="Image Path"/>
<column xsi:type="int" name="position" padding="10" unsigned="false"
nullable="false" identity="false" default="0" comment="Sort Position"/>
<column xsi:type="smallint" name="is_active" padding="5" unsigned="true"
nullable="false" identity="false" default="1" comment="Is Active"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false"
default="CURRENT_TIMESTAMP" comment="Created At"/>
<column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false"
default="CURRENT_TIMESTAMP" comment="Updated At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="testimonial_id"/>
</constraint>
</table>
<table name="mironsoft_testimonial_store" resource="default" engine="innodb"
comment="Mironsoft Testimonial To Store Linkage Table">
<column xsi:type="int" name="testimonial_id" padding="10" unsigned="true"
nullable="false" identity="false" comment="Testimonial ID"/>
<column xsi:type="smallint" name="store_id" padding="5" unsigned="true"
nullable="false" identity="false" comment="Store ID"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="testimonial_id"/>
<column name="store_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_TESTIMONIAL_STORE_TESTIMONIAL_ID_TESTIMONIAL_TESTIMONIAL_ID"
table="mironsoft_testimonial_store" column="testimonial_id"
referenceTable="mironsoft_testimonial" referenceColumn="testimonial_id"
onDelete="CASCADE"/>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_TESTIMONIAL_STORE_STORE_ID_STORE_STORE_ID"
table="mironsoft_testimonial_store" column="store_id"
referenceTable="store" referenceColumn="store_id" onDelete="CASCADE"/>
</table>
</schema>onDelete="CASCADE" on both foreign keys ensures that deleting a testimonial automatically removes its store assignments too - no orphaned rows, and the delete controller (chapter 17) doesn't have to handle that explicitly itself.
Model, ResourceModel, Collection
<?php
declare(strict_types=1);
namespace Mironsoft\Testimonial\Model;
use Magento\Framework\Model\AbstractModel;
use Mironsoft\Testimonial\Model\ResourceModel\Testimonial as TestimonialResource;
/**
* Testimonial entity model.
*/
class Testimonial extends AbstractModel
{
/**
* Initializes the resource model.
*
* @return void
*/
protected function _construct(): void
{
$this->_init(TestimonialResource::class);
}
}<?php
declare(strict_types=1);
namespace Mironsoft\Testimonial\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
/**
* Testimonial resource model, maps the entity onto mironsoft_testimonial.
* Store relations are added in chapter 18.
*/
class Testimonial extends AbstractDb
{
/**
* Initializes the main table and primary key column.
*
* @return void
*/
protected function _construct(): void
{
$this->_init('mironsoft_testimonial', 'testimonial_id');
}
}<?php
declare(strict_types=1);
namespace Mironsoft\Testimonial\Model\ResourceModel\Testimonial;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Mironsoft\Testimonial\Model\ResourceModel\Testimonial as TestimonialResource;
use Mironsoft\Testimonial\Model\Testimonial as TestimonialModel;
/**
* Collection of testimonial entities, used by grid DataProviders.
*/
class Collection extends AbstractCollection
{
/**
* Binds the collection to its model and resource model pair.
*
* @return void
*/
protected function _construct(): void
{
$this->_init(TestimonialModel::class, TestimonialResource::class);
}
}ACL, menu, routes - mirroring block 1
These files follow the exact pattern from chapter 3 - ACL resource Mironsoft_Testimonial::testimonial, route mironsoft_testimonial, a menu item under Content. Chapter 25 later splits this ACL resource into three separate nodes (view, form, delete) - a single node is enough to get started here.
bin/magento setup:upgrade
bin/magento cache:cleanTipp: After the first setup:upgrade, it's worth checking the database (bin/mysql) to confirm both tables, including foreign keys, were created as expected - SHOW CREATE TABLE mironsoft_testimonial_store; shows the actual generated constraint names.
With tables, model triad, and backend access in place, the foundation is ready. Chapter 15 builds the grid on top of it.