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
<?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;
}<?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;
}<?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.
<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
<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
<?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.