Formular für Kundenstimmen bauen: Felder, Bild-Upload, Validierung
Formular für Kundenstimmen bauen: Felder, Bild-Upload, Validierung
~10 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Das Testimonial-Formular kombiniert alle Feldtypen aus Block 3 - inklusive echtem Bild-Upload mit Upload-Controller. Zusätzlich nutzt dieses Kapitel bewusst das Repository-Pattern statt des direkten ResourceModel-Zugriffs aus Kapitel 12: Die Coding-Konventionen dieses Projekts bevorzugen Service Contracts (Api/Interfaces) und Repositories, wo eine saubere, modulübergreifende Schnittstelle sinnvoll ist.
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 selbst (Kapitel 14) muss dafür zusätzlich TestimonialInterface implementieren - die Getter/Setter aus dem Interface werden über AbstractModel::getData()/setData() trivial durchgereicht, hier aus Platzgründen nicht erneut abgedruckt.
<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 mit allen Feldtypen aus 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() im Upload-Controller ist keine Doppelung der allowedExtensions-Angabe in der XML - die XML-Angabe steuert nur die clientseitige Browser-Auswahl (Kapitel 11). Ohne die serverseitige Prüfung im Controller ließe sich über einen direkten POST an die Upload-URL trotzdem eine beliebige Datei hochladen.
Save-Controller mit 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]);
}
}Der Unterschied zu Kapitel 12 ist bewusst: Statt AnnouncementResource->load()/save() direkt im Controller aufzurufen, delegiert dieser Controller an TestimonialRepositoryInterface - der Controller kennt keine Implementierungsdetails mehr, und dieselbe Repository-Methode ließe sich später auch aus einer REST-API oder einem CLI-Kommando heraus wiederverwenden.