Writing a custom validator
A validator is a small class that inspects one or more request parameters and decides whether they are acceptable — and, optionally, sanitizes them. Quiote ships validators for the common cases (string, email, number, regex, and more), but when none of them fit — an IBAN, a password strength score, a domain-specific rule — you write your own.
This page is for that case. Validation covers using the built-ins and Advanced validation shows the quick way to slot a custom class in with raw(). Here we go a level down: the Validator base class contract, the validators.xml file format, and how validators are discovered, compiled, and run.
The Validator contract
Section titled “The Validator contract”A custom validator extends Quiote\Validator\Validator and implements one method:
protected abstract function validate(): bool;Return true to pass, false to fail. Everything else — severity, error reporting, whitelisting, dependency ordering — is handled by the base class. The methods you use inside validate():
| Method | Purpose |
|---|---|
getData($paramName) | Read the input value (honours the source and base-path parameters). |
getArgument($name = null) | The request-parameter name this validator is bound to. |
getArguments() | All bound argument names (for multi-argument validators). |
getParameter($name, $default) | Read a validator parameter. |
throwError($index = null) | Record a validation error, using the named error message. |
export($value, $argument = null) | Write a sanitized value back into the request. |
Constructor dependencies
Section titled “Constructor dependencies”Validator construction goes through the container, so a validator may take collaborators like anything else:
final class VatNumberValidator extends Validator{ public function __construct(private readonly VatLookupService $lookup) {}
protected function validate(): bool { return $this->lookup->isRegistered((string) $this->getData($this->getArgument())); }}This is purely additive: a validator with no constructor — every one the framework ships, and every one written before this — is new’d directly, so nothing about the existing path changes.
Parameters, argument names and error messages still arrive through initialize(), not the constructor. Those are per-declaration data read out of a config file, so there’s nothing for the container to resolve them from.
A validator is built per validation and never cached, so unlike a singleton service it may depend on request-scoped state — the WebRequest, the user — directly. See a singleton cannot depend on request-scoped state for why that distinction exists.
Declaring accepted parameters
Section titled “Declaring accepted parameters”A validator whitelists the parameters it understands via a static method. Override it and merge with the parent’s:
public static function getAcceptedParameters(): array{ return array_merge(parent::getAcceptedParameters(), ['min_score']);}This is checked at compile time, not per request: ValidatorConfigHandler resolves the validator class while compiling validators.xml and rejects any XML attribute or <ae:parameter> name that isn’t in getAcceptedParameters(). By default this throws a ConfigurationException and fails the build — a misspelled or forgotten parameter name is a compile error, not a value that gets silently stored and never read. When the edit distance to a known parameter is small, the error includes a “Did you mean …?” hint.
The validation.reject_unknown_parameters setting
Section titled “The validation.reject_unknown_parameters setting”Quiote\Validator\Compiler\ValidatorPlanBuilder::checkParameters() reads a single config key, validation.reject_unknown_parameters, to decide what an unknown parameter does:
| Value | Behaviour |
|---|---|
throw (default) | Fails the build with a ConfigurationException. This is what you want in every environment that compiles config from source — dev, CI, and any deploy step that runs the compiler — because a rejected build is far cheaper than a validator that silently ignores a parameter someone meant it to read. |
warn | Logs a warning and records a Diagnostic, then continues. Useful for auditing an existing corpus of validators.xml files before flipping the whole app to throw — run a full compile in warn mode first and read the diagnostics before tightening it. |
off | Skips the check entirely. Only reach for this if you have a specific, understood reason getAcceptedParameters() can’t be kept accurate (e.g. a validator whose parameters are generated dynamically) — it reopens the exact silent-drop failure mode the check exists to close. |
If the validator class can’t be autoloaded yet at compile time (a custom class not resolvable from the compiler’s context), its parameters are left unchecked and a warning-level Diagnostic is recorded instead of silently assuming the config is fine — check compile diagnostics if a validator you expect to be strict-checked isn’t rejecting anything.
Set it like any other setting under a non-core. namespace — see The XML prefix attribute:
'validation.reject_unknown_parameters' => 'warn',validation.reject_unknown_parameters: warn<settings prefix="validation."> <setting name="reject_unknown_parameters">warn</setting></settings>ValidatorBuilder::raw() (used for PHP registration) runs the same check: it calls ValidatorPlanBuilder::checkParameterNames() — the class-agnostic core checkParameters() uses for the XML path — before registering, so a raw() call honours validation.reject_unknown_parameters exactly like validators.xml does. An unknown or misspelled parameter name is a compile-time ConfigurationException (or a warning, in warn mode) in both places.
A complete validator
Section titled “A complete validator”<?phpnamespace App\Validator;
use Quiote\Validator\Validator;
final class StrongPasswordValidator extends Validator{ public static function getAcceptedParameters(): array { return array_merge(parent::getAcceptedParameters(), ['min_score']); }
protected function validate(): bool { $password = $this->getData($this->getArgument());
if (!is_scalar($password)) { $this->throwError(); // required — see the caution above return false; }
if ($this->scoreOf((string) $password) < (int) $this->getParameter('min_score', 3)) { $this->throwError(); // uses the configured <error> message return false; }
return true; }
private function scoreOf(string $password): int { /* ... */ }}throwError() with no argument uses the validator’s default error message; pass an index (throwError('min')) to select a named message, matching an <error for="min"> in the config. The base class applies the translation_domain if you set one, so error messages can be translated.
Wiring it in
Section titled “Wiring it in”You register a validator two ways, and both end up feeding the same validation manager for the action — so you can mix them and migrate one at a time.
In PHP (recommended for new code)
Section titled “In PHP (recommended for new code)”Create Modules/{Module}/Validate/{Action}.php returning a callable that receives a ValidatorBuilder. The action picks it up automatically — no XML, no registration:
<?phpuse Quiote\Validator\Compiler\Runtime\ValidatorBuilder;
return function (ValidatorBuilder $v): void { $v->email('email'); $v->string('name', required: true)->minLength(2);
// Any validator without a dedicated helper — including your own: $v->raw(\App\Validator\StrongPasswordValidator::class, ['password'], ['min_score' => 3]);
if ($v->method() === 'write') { $v->group('or', function (ValidatorBuilder $g): void { $g->email('contact'); $g->regex('contact', '/^\+?\d{7,}$/'); }); }};ValidatorBuilder has typed helpers (string(), email(), number(), regex(), group() for and/or/not/xor, and raw() for any class). This is the same builder documented in Advanced validation; registering this way gives you the strict-mode whitelist automatically.
In XML
Section titled “In XML”The file-based format lives at Modules/{Module}/Validate/{Action}.xml. Unlike the Config/* kinds, a validator file is XML or a PHP builder file — there is no YAML form: ValidatorConfigHandler parses XML, and Modules/{Module}/Validate/{Action}.php (hand-written, or .generated.php) is the PHP half, loaded by the default registerValidators(). Both add to the same ValidationManager, so an action can use either or both. Declare a reusable alias with <validator_definition> (default parameters and messages), then apply validators to arguments:
<ae:configurations xmlns="http://quiote.dev/quiote/config/parts/validators/1.1" xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1" parent="%core.config_dir%/validators.xml"> <ae:configuration>
<validator_definitions> <validator_definition name="strongpassword" class="App\Validator\StrongPasswordValidator"> <ae:parameter name="min_score">3</ae:parameter> <error>Password is too weak.</error> </validator_definition> </validator_definitions>
<validators method="write"> <validator name="email" class="email" required="true" severity="error"> <argument>email</argument> <error>Please enter a valid email address.</error> </validator>
<validator name="pw" class="strongpassword"> <argument>password</argument> </validator>
<!-- grouping: contact must be an email OR a phone number --> <validator name="either" class="or"> <validator class="email"><argument>contact</argument></validator> <validator class="regex"> <ae:parameter name="pattern">/^\+?\d{7,}$/</ae:parameter> <argument>contact</argument> </validator> </validator> </validators>
</ae:configuration></ae:configurations>The class attribute takes either a registered alias (email, or, regex, string, …) or a fully-qualified class name. Common attributes on <validator>: name, class, required, severity (info/silent/notice/error/critical), source (parameters/files/headers/cookies), export, depends, provides, method, and translation_domain. The parent attribute chains a file to the module- or project-level validators, so shared rules live in one place.
The built-in aliases you can compose with are defined in the framework’s validators.xml — among them string, number, email, regex, datetime, inarray, isset, isnotempty, json, file, imagefile, and the operator groups and/or/not/xor.
Discovery and compilation
Section titled “Discovery and compilation”Both paths compile ahead of a request rather than validating interpretively:
- XML files matching
Modules/*/Validate/*.xmlare handled by the validator config handler — schema-validated, then compiled and cached like all Quiote config. - PHP registrars (
{Action}.php, or a generated{Action}.generated.php) are loaded by the action’sregisterValidators()hook via the compiled-validator registry.
Whichever you use, the validators run in ValidationMiddleware before your action, ordered by their depends/provides tokens, and only parameters that have a validator survive the strict-mode whitelist — everything else is pruned from the request. That is why a validator both checks a field and makes it readable: see strict parameter access.