Writing a custom Extbase validator 

When the built-in validators cannot express a domain rule — for example "a conference end date must be after its start date" or "registration is only open while seats remain" — write a custom validator class and attach it with #[Validate] just like any built-in validator.

Model property vs. action parameter — where you place the validator determines what it can see and when it runs.

A validator on a model property receives only that property's value. It runs on every action that takes the model as an argument. Use it for self-contained rules that must always hold: $title must not be empty, $contactEmail must be a valid address.

A validator on an action parameter receives the whole object and runs only for that specific action. This makes it the right choice for two distinct situations: rules that only apply in a particular context (a seat count check that matters for registerAction() but not for showAction()), and rules that span multiple properties. For example: if a conference starts more than four weeks from now, a speaker assignment is optional — but if it starts sooner, a speaker is required. That rule cannot be expressed on any single property; it needs both $startDate and $speaker at the same time:

EXT:my_extension/Classes/Validation/Validator/ConferenceSpeakerValidator.php
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;

class ConferenceSpeakerValidator extends AbstractValidator
{
    protected function isValid(mixed $value): void
    {
        $weeksUntilStart = (int)(($value->getStartDate()->getTimestamp() - time()) / 604800);
        if ($weeksUntilStart < 4 && $value->getSpeaker() === null) {
            $this->addErrorForProperty(
                'speaker',
                $this->translateErrorMessage('my_extension.messages:validator.conference.speakerRequired'),
                1716300100,
            );
        }
    }
}
Copied!

Both approaches can be combined on the same type — property validators run first, and action-parameter validators run afterwards.

Structure of a custom validator 

Custom validators extend \TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator and implement a single method isValid(mixed $value): void. If a value fails validation, call $this->addError() or $this->addErrorForProperty() — do not throw an exception and do not return anything. The framework reads $this->result after isValid() returns.

Place validators in Classes/Validation/Validator/ inside your extension, following the naming convention <Subject>Validator:

EXT:my_extension/Classes/Validation/Validator/ConferenceDateValidator.php
<?php

namespace MyVendor\MyExtension\Validation\Validator;

use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;

class ConferenceDateValidator extends AbstractValidator
{
    protected string $message = 'my_extension.messages:validator.conference.endBeforeStart';

    protected array $translationOptions = ['message'];

    protected $supportedOptions = [
        'message' => [null, 'Custom translation key or message text', 'string'],
    ];

    protected function isValid(mixed $value): void
    {
        if ($value->getEndDate() <= $value->getStartDate()) {
            $this->addErrorForProperty(
                'endDate',
                $this->translateErrorMessage($this->message),
                1716299001,
            );
        }
    }
}
Copied!

Key points:

  • Do not declare the class as final — the same convention that applies to controllers applies here. Third parties can extend the validator.
  • $acceptsEmptyValues = true (the default) means isValid() is skipped when a value is null or an empty string. Set it to false only if a validator should explicitly reject empty values (as NotEmptyValidator does).
  • The error code is an arbitrary integer that must be unique across your extension. Using the Unix timestamp at the time of writing is a convenient way to generate a unique number.
  • Translation keys use the domain syntax introduced in TYPO3 v14: my_extension.messages:some.key resolves to EXT:my_extension/Resources/Private/Language/locallang.xlf. See Translation domain syntax as shorter alternative to LLL:EXT: (TYPO3 v14) for the full syntax including non-default language files.

Reporting errors on a specific property 

$this->addErrorForProperty() attaches the error to a named property of the validated object rather than to the object itself. The Form.validationResults ViewHelper <f:form.validationResults> view helper can then display the message adjacent to the right form field:

EXT:my_extension/Resources/Private/Templates/Conference/New.fluid.html
<f:form.validationResults for="conference.endDate">
    <f:for each="{validationResults.errors}" as="error">
        <p class="error">{error.message}</p>
    </f:for>
</f:form.validationResults>
Copied!

Use $this->addError() instead when the error applies to the whole object and does not belong to any single field.

Supporting options and substitution values in messages 

If your validator needs to be configured, for example, there needs to be a minimum seat count, declare the options in $supportedOptions. Each option contains an array with values [default, description, type]. Access resolved values using $this->options:

EXT:my_extension/Classes/Validation/Validator/SeatCountValidator.php
<?php

namespace MyVendor\MyExtension\Validation\Validator;

use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;

class SeatCountValidator extends AbstractValidator
{
    protected string $message = 'my_extension.messages:validator.conference.notEnoughSeats';

    protected array $translationOptions = ['message'];

    protected $supportedOptions = [
        'minimum' => [1, 'Minimum number of available seats required', 'integer'],
        'message' => [null, 'Custom translation key or message text', 'string'],
    ];

    protected function isValid(mixed $value): void
    {
        $minimum = $this->options['minimum'];
        if ($value->getAvailableSeats() < $minimum) {
            $this->addError(
                $this->translateErrorMessage($this->message, '', [$minimum]),
                1716300001,
                [$minimum],
            );
        }
    }
}
Copied!

The error message in locallang.xlf uses a %s placeholder:

EXT:my_extension/Resources/Private/Language/locallang.xlf (excerpt)
<trans-unit id="validator.conference.notEnoughSeats">
    <source>At least %s seat(s) must be available for registration.</source>
</trans-unit>
Copied!

translateErrorMessage() accepts the values to be substituted as its third argument and passes them through vsprintf(). The same values are passed as the third argument to addError(). They are stored in the Error object for programmatic access:

Substitution values in translateErrorMessage() and addError()
$this->addError(
    $this->translateErrorMessage($this->message, '', [$minimum]),
    1716300001, // An arbitrary unique number, for example the timestamp when writing the code
    [$minimum],
);
Copied!

Attach the validator with its option on the action parameter:

EXT:my_extension/Classes/Controller/ConferenceController.php
use MyVendor\MyExtension\Validation\Validator\SeatCountValidator;
use TYPO3\CMS\Extbase\Attribute\Validate;

public function registerAction(
    #[Validate(SeatCountValidator::class, options: ['minimum' => 1])]
    Conference $conference,
): ResponseInterface {
    // Only reached when SeatCountValidator passes
}
Copied!

Making the error message overridable 

To allow callers to override the message text via the options array without having to subclass (the same mechanism used by built-in validators) declare a protected string $message property, add it to $translationOptions, and list it in $supportedOptions. The SeatCountValidator above follows this pattern. A caller can then replace the message at the call site:

Overriding the message at the call site
#[Validate(SeatCountValidator::class, options: [
    'minimum' => 1,
    'message' => 'my_extension.messages:validator.conference.notEnoughSeats.short',
])]
Copied!