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

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
app/code/Mironsoft/Testimonial/etc/db_schema.xml
<?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

app/code/Mironsoft/Testimonial/Model/Testimonial.php
<?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);
    }
}
app/code/Mironsoft/Testimonial/Model/ResourceModel/Testimonial.php
<?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');
    }
}
app/code/Mironsoft/Testimonial/Model/ResourceModel/Testimonial/Collection.php
<?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:clean

Tipp: 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.