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

Building the Testimonials Form: Fields, Image Upload, Validation

Building the Testimonials Form: Fields, Image Upload, Validation

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

The testimonial form combines every field type from block 3 - including a real image upload with an upload controller. This chapter also deliberately uses the repository pattern instead of the direct resource model access from chapter 12: this project's coding conventions favor service contracts (Api/Interfaces) and repositories wherever a clean, cross-module interface makes sense.

Service contract: TestimonialInterface

app/code/Mironsoft/Testimonial/Api/Data/TestimonialInterface.php
<?php

declare(strict_types=1);

namespace Mironsoft\Testimonial\Api\Data;

/**
 * Data contract for a single testimonial record.
 */
interface TestimonialInterface
{
    public const TESTIMONIAL_ID = 'testimonial_id';
    public const CUSTOMER_NAME = 'customer_name';
    public const COMPANY = 'company';
    public const RATING = 'rating';
    public const TESTIMONIAL_TEXT = 'testimonial_text';
    public const IMAGE = 'image';
    public const IS_ACTIVE = 'is_active';

    /**
     * @return int|null
     */
    public function getTestimonialId(): ?int;

    /**
     * @return string
     */
    public function getCustomerName(): string;

    /**
     * @param string $customerName Full name of the reviewing customer.
     * @return $this
     */
    public function setCustomerName(string $customerName): self;

    /**
     * @return int
     */
    public function getRating(): int;

    /**
     * @param int $rating Rating between 1 and 5.
     * @return $this
     */
    public function setRating(int $rating): self;
}
app/code/Mironsoft/Testimonial/Api/TestimonialRepositoryInterface.php
<?php

declare(strict_types=1);

namespace Mironsoft\Testimonial\Api;

use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Testimonial\Api\Data\TestimonialInterface;

/**
 * Service contract for reading and writing testimonials.
 */
interface TestimonialRepositoryInterface
{
    /**
     * Loads a testimonial by its ID.
     *
     * @param int $testimonialId Primary key of the testimonial.
     * @return TestimonialInterface
     * @throws NoSuchEntityException
     */
    public function getById(int $testimonialId): TestimonialInterface;

    /**
     * Persists a testimonial.
     *
     * @param TestimonialInterface $testimonial Entity to persist.
     * @return TestimonialInterface
     * @throws CouldNotSaveException
     */
    public function save(TestimonialInterface $testimonial): TestimonialInterface;
}
app/code/Mironsoft/Testimonial/Model/TestimonialRepository.php
<?php

declare(strict_types=1);

namespace Mironsoft\Testimonial\Model;

use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Testimonial\Api\Data\TestimonialInterface;
use Mironsoft\Testimonial\Api\TestimonialRepositoryInterface;
use Mironsoft\Testimonial\Model\ResourceModel\Testimonial as TestimonialResource;

/**
 * Persists and loads testimonials through the resource model.
 */
class TestimonialRepository implements TestimonialRepositoryInterface
{
    /**
     * @param TestimonialResource $resource Resource model for load/save.
     * @param TestimonialFactory $testimonialFactory Factory for the testimonial model.
     */
    public function __construct(
        private readonly TestimonialResource $resource,
        private readonly TestimonialFactory $testimonialFactory,
    ) {
    }

    /**
     * @param int $testimonialId Primary key of the testimonial.
     * @return TestimonialInterface
     * @throws NoSuchEntityException
     */
    public function getById(int $testimonialId): TestimonialInterface
    {
        $testimonial = $this->testimonialFactory->create();
        $this->resource->load($testimonial, $testimonialId);

        if (!$testimonial->getId()) {
            throw new NoSuchEntityException(
                __('Testimonial with ID "%1" does not exist.', $testimonialId)
            );
        }

        return $testimonial;
    }

    /**
     * @param TestimonialInterface $testimonial Entity to persist.
     * @return TestimonialInterface
     * @throws CouldNotSaveException
     */
    public function save(TestimonialInterface $testimonial): TestimonialInterface
    {
        try {
            /** @var Testimonial $testimonial */
            $this->resource->save($testimonial);
        } catch (\Exception $exception) {
            throw new CouldNotSaveException(__('Could not save the testimonial.'), $exception);
        }

        return $testimonial;
    }
}

Testimonial itself (chapter 14) additionally needs to implement TestimonialInterface for this to work - the getters/setters from the interface are trivially passed through via AbstractModel::getData()/setData(), omitted here for space.

app/code/Mironsoft/Testimonial/etc/di.xml (excerpt)
<preference for="Mironsoft\Testimonial\Api\Data\TestimonialInterface"
            type="Mironsoft\Testimonial\Model\Testimonial"/>
<preference for="Mironsoft\Testimonial\Api\TestimonialRepositoryInterface"
            type="Mironsoft\Testimonial\Model\TestimonialRepository"/>

form.xml with every field type from block 3

app/code/Mironsoft/Testimonial/view/adminhtml/ui_component/mironsoft_testimonial_form.xml (fieldset excerpt)
<fieldset name="general">
    <settings>
        <label translate="true">General Information</label>
    </settings>
    <field name="customer_name" formElement="input">
        <settings>
            <dataType>text</dataType>
            <label translate="true">Customer Name</label>
            <validation>
                <rule name="required-entry" xsi:type="boolean">true</rule>
            </validation>
        </settings>
    </field>
    <field name="company" formElement="input">
        <settings>
            <dataType>text</dataType>
            <label translate="true">Company</label>
        </settings>
    </field>
    <field name="rating" formElement="select">
        <settings>
            <options class="Mironsoft\Testimonial\Model\Source\Rating"/>
            <dataType>text</dataType>
            <label translate="true">Rating</label>
        </settings>
    </field>
    <field name="testimonial_text" formElement="textarea">
        <settings>
            <dataType>text</dataType>
            <label translate="true">Testimonial Text</label>
            <validation>
                <rule name="required-entry" xsi:type="boolean">true</rule>
            </validation>
        </settings>
    </field>
    <field name="image" formElement="imageUploader">
        <settings>
            <dataType>string</dataType>
            <label translate="true">Photo</label>
        </settings>
        <formElements>
            <imageUploader>
                <settings>
                    <uploaderConfig>
                        <param name="url" xsi:type="url" path="mironsoft_testimonial/testimonial/upload"/>
                    </uploaderConfig>
                    <previewTmpl>Magento_Catalog/image-preview</previewTmpl>
                    <allowedExtensions>jpg jpeg png</allowedExtensions>
                    <maxFileSize>2097152</maxFileSize>
                </settings>
            </imageUploader>
        </formElements>
    </field>
    <field name="is_active" formElement="checkbox">
        <settings>
            <valueMap>
                <map name="false" xsi:type="number">0</map>
                <map name="true" xsi:type="number">1</map>
            </valueMap>
            <dataType>boolean</dataType>
            <default>1</default>
            <label translate="true">Enabled</label>
        </settings>
    </field>
</fieldset>

Upload controller

app/code/Mironsoft/Testimonial/Controller/Adminhtml/Testimonial/Upload.php
<?php

declare(strict_types=1);

namespace Mironsoft\Testimonial\Controller\Adminhtml\Testimonial;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Exception\LocalizedException;
use Magento\MediaStorage\Model\File\UploaderFactory;

/**
 * Handles image uploads for the testimonial form.
 */
class Upload extends Action
{
    public const ADMIN_RESOURCE = 'Mironsoft_Testimonial::save';

    private const UPLOAD_DIR = 'mironsoft/testimonial';

    /**
     * @param Context $context Backend action context.
     * @param UploaderFactory $uploaderFactory Factory for the file uploader.
     * @param JsonFactory $resultJsonFactory Factory for the JSON result.
     */
    public function __construct(
        Context $context,
        private readonly UploaderFactory $uploaderFactory,
        private readonly JsonFactory $resultJsonFactory,
    ) {
        parent::__construct($context);
    }

    /**
     * Validates and stores the uploaded image, returns its relative path as JSON.
     *
     * @return Json
     */
    public function execute(): Json
    {
        $result = $this->resultJsonFactory->create();

        try {
            $uploader = $this->uploaderFactory->create(['fileId' => 'image']);
            $uploader->setAllowedExtensions(['jpg', 'jpeg', 'png']);
            $uploader->setAllowRenameFiles(true);
            $uploader->setFilesDispersion(true);

            $uploadResult = $uploader->save(self::UPLOAD_DIR);

            return $result->setData([
                'file' => $uploadResult['file'],
                'url' => $uploadResult['tmp_name'] ?? $uploadResult['file'],
                'error' => 0,
            ]);
        } catch (LocalizedException $exception) {
            return $result->setData(['error' => $exception->getMessage(), 'errorcode' => 0]);
        }
    }
}

Achtung: setAllowedExtensions() in the upload controller isn't a duplicate of the allowedExtensions value in the XML - the XML value only controls the client-side browser file picker (chapter 11). Without the server-side check in the controller, an arbitrary file could still be uploaded via a direct POST to the upload URL.

Save controller with repository

public function execute(): ResultInterface
{
    $resultRedirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
    $data = $this->getRequest()->getPostValue();

    if (!$data) {
        return $resultRedirect->setPath('mironsoft_testimonial/testimonial/index');
    }

    $id = (int) ($data['testimonial_id'] ?? 0);

    try {
        $testimonial = $id
            ? $this->testimonialRepository->getById($id)
            : $this->testimonialFactory->create();

        $testimonial->setData($data);
        $this->testimonialRepository->save($testimonial);

        $this->messageManager->addSuccessMessage(__('You saved the testimonial.'));

        return $resultRedirect->setPath('mironsoft_testimonial/testimonial/index');
    } catch (LocalizedException $exception) {
        $this->messageManager->addErrorMessage($exception->getMessage());

        return $resultRedirect->setPath('mironsoft_testimonial/testimonial/edit', ['id' => $id]);
    }
}

The difference from chapter 12 is deliberate: instead of calling AnnouncementResource->load()/save() directly in the controller, this controller delegates to TestimonialRepositoryInterface - the controller no longer knows any implementation details, and the same repository method could later be reused from a REST API or a CLI command as well.