Skip to content

marko/validation

Input validation with built-in rules, string-based rule parsing, and custom rule support — validate data before it reaches your domain. Validation provides a ValidatorInterface for checking data against rules. Rules can be specified as pipe-delimited strings ('required|email|max:255'), arrays, or RuleInterface objects. Validation errors are collected into a ValidationErrors bag with per-field messages. Use validateOrFail() to throw on invalid data.

Terminal window
composer require marko/validation

Inject ValidatorInterface and pass data with rules:

use Marko\Validation\Contracts\ValidatorInterface;
readonly class UserController
{
public function __construct(
private ValidatorInterface $validator,
) {}
public function store(
array $input,
): void {
$errors = $this->validator->validate($input, [
'name' => 'required|string|max:100',
'email' => 'required|email',
'age' => 'nullable|integer|min:18',
]);
if ($errors->isNotEmpty()) {
// handle errors
}
}
}

Use validateOrFail() to throw ValidationException when data is invalid:

$this->validator->validateOrFail($input, [
'title' => 'required|string|max:200',
'body' => 'required|string',
]);
// Throws ValidationException with errors() method

The ValidationException includes rich context --- call errors() to get the ValidationErrors bag, or getContext() and getSuggestion() for diagnostic details.

ValidationException implements Marko\Core\Exceptions\HttpExceptionInterface. When it escapes a controller, the routing pipeline renders it as 422 Unprocessable Content with the field errors:

{
"message": "The given data was invalid.",
"errors": {
"email": ["The email field must be a valid email address."]
}
}

API clients get JSON; browsers get a minimal HTML page with the message. See Errors and HTTP Exceptions. To redirect HTML forms back with their errors and old input instead, install marko/forms: its ValidationErrorResponder takes over for every non-JSON request.

if ($this->validator->passes($input, ['email' => 'required|email'])) {
// data is valid
}
if ($this->validator->fails($input, ['email' => 'required|email'])) {
// data is invalid
}

ValidationErrors provides methods for inspecting failures:

$errors = $this->validator->validate($input, $rules);
$errors->has('email'); // true if email has errors
$errors->get('email'); // ['The email field must be a valid email.']
$errors->first('email'); // 'The email field must be a valid email.'
$errors->all(); // ['email' => [...], 'name' => [...]]
$errors->toFlatArray(); // ['email: ...', 'name: ...']
$errors->isEmpty(); // true if no errors
$errors->count(); // total error count across all fields
RuleString SyntaxDescription
RequiredrequiredMust be present and non-empty
RequiredIfrequired_if:type,video,audioRequired when another field holds one of the values (see Implicit Rules)
RequiredUnlessrequired_unless:status,approvedRequired unless another field holds one of the values
RequiredWithrequired_with:email,phoneRequired when any of the other fields is filled
RequiredWithoutrequired_without:email,phoneRequired when any of the other fields is empty or absent
PresentpresentThe key must exist; the value may be empty or null
ProhibitedprohibitedMust be absent or empty
Acceptedacceptedyes, on, 1, true, "1" or "true" (a ticked checkbox); an absent key fails
Declineddeclinedno, off, 0, false, "0" or "false"; an absent key fails
NullablenullableSkips every rule, implicit ones included, if null/empty
SometimessometimesSkips every rule when the key is absent; validates a present key, even null (see sometimes vs nullable)
StringstringMust be a string
IntegerintegerMust be an integer
NumericnumericMust be numeric
BooleanbooleanMust be boolean-like
EmailemailMust be a valid email
URLurl or url:http,https,ftpMust be a valid URL with a host and an allowed scheme (http/https unless listed; see URLs)
AlphaalphaLetters only
AlphaNumericalpha_numLetters and numbers only
Minmin:5Minimum value (int/float, or numeric string with numeric/integer), minimum length (string), or minimum count (array). Fails for a file --- use min_size
Maxmax:255Maximum value (int/float, or numeric string with numeric/integer), maximum length (string), or maximum count (array). Fails for a file --- use max_size
Betweenbetween:1,100Value range (int/float, or numeric string with numeric/integer), length range (string), or count range (array). Fails for a file --- use min_size/max_size
Sizesize:10Exact value (int/float, or numeric string with numeric/integer), exact length (string), or exact count (array). Fails for a file --- use min_size:n|max_size:n
Inin:draft,publishedMust be one of the listed values; numeric strings are compared numerically
NotInnot_in:admin,rootMust not be one of the listed values; numeric strings are compared numerically
Samesame:other_fieldMust match another field (a dot path; see wildcards)
Differentdifferent:other_fieldMust differ from another field (a dot path; see wildcards)
ConfirmedconfirmedMust have a matching _confirmation field next to it (account.password checks account.password_confirmation)
Regexregex:/^\d{3}$/Must match the pattern
Datedate or date:Y-m-dMust be a valid date
Afterafter:today or after:starts_atA date later than a date or another field (see Comparing Dates)
AfterOrEqualafter_or_equal:starts_atA date no earlier than a date or another field
Beforebefore:2027-01-01 or before:ends_atA date earlier than a date or another field
BeforeOrEqualbefore_or_equal:ends_atA date no later than a date or another field
Uuiduuid or uuid:4A UUID in 8-4-4-4-12 hex form, either case; with a version (1—8), that version and the RFC 9562 variant
UlidulidA 26-character ULID (Crockford base32, either case)
JsonjsonA string that is valid JSON
Digitsdigits:4Exactly this many digits: a string of 0—9 only, or a non-negative integer (0123 has 4)
DigitsBetweendigits_between:4,6A number of digits within the range, inclusive
ArrayarrayMust be an array
ListlistAn array whose keys are 0, 1, 2, ... in order (array_is_list)
Distinctdistinct, distinct:strict, distinct:ignore_caseNo other value under the same wildcard key is equal (see Distinct Values)
EnumEnum::of(Status::class) (no string form)A case of a backed enum, or its backing value (see Enums)
ExistsRecordRules::exists(Channel::class) (no string form)A stored record of the entity has this value; needs marko/validation-database (see Exists and Unique)
UniqueRecordRules::unique(User::class, 'email') (no string form)No stored record of the entity has this value yet; needs marko/validation-database
FilefileMust be an uploaded file that arrived without an upload error
ImageimageMust be a JPEG, PNG, GIF or WebP image, judged from its contents (never SVG)
Mimesmimes:jpg,png,pdfThe extension for the file’s sniffed MIME type must be listed (jpeg and jpg are the same)
MimeTypesmimetypes:image/*,application/pdfThe sniffed MIME type must be listed; type/* matches any subtype except image/svg+xml
MaxSizemax_size:2048File size at most this many kilobytes (1 KB = 1024 bytes)
MinSizemin_size:1File size at least this many kilobytes
CurrentPassword$authRules->currentPassword() (no string form)The signed-in user’s password; from marko/authentication
Password$authRules->password() (no string form)Password strength from the app-wide authentication.password defaults; from marko/authentication

Rules that take a number (size, digits, digits_between, max_size, min_size) throw InvalidArgumentException when it is missing or not a number, as do after/before without a reference and uuid with a version outside 1—8.

The validator skips an ordinary rule when the value is empty (null, a blank string or []), so email never complains about a field that was left out. Implicit rules are the exception: they run even when the value is empty or the key is absent, because that is the case they exist for.

RuleRuns the field’s other rules on an empty value?
requiredAlways
required_if, required_unless, required_with, required_withoutOnly when their condition makes the field required
present, prohibited, accepted, declinedNever (they check the value themselves)
$this->validator->validate($input, [
'type' => 'required|in:text,video',
'url' => 'required_if:type,video|url', // checked only when type is video
'phone' => 'required_without:email', // one of the two must be filled
'terms' => 'accepted', // the checkbox must be ticked
'tags' => 'present|array', // the key must be sent, even as []
'role' => 'prohibited', // clients may not set it
'items.*.url' => 'required_if:items.*.type,video', // per row: items.2.type for items.2.url
]);

When type is text, an empty url is skipped entirely. When type is video, an empty url fails required_if and every other rule on the field runs too.

required_if and required_unless compare values as strings: true/false and null read as those words, numbers as their digits, and a boolean field also matches 1/0 (required_if:subscribe,1 matches true). An array never matches. required_with and required_without count a field as filled when it is present and not empty. Their field references are absolute dot paths whose * are filled from the current row, as for same.

A required_if or required_unless without a field and at least one value, or a required_with/required_without without a field, throws InvalidArgumentException.

Both make a field optional, but they ask different questions:

  • sometimes asks was the key sent? An absent key skips every rule on the field. A present key is fully validated, including an explicit null, so sometimes|required|email lets a PATCH request leave email out but rejects "email": null.
  • nullable asks is the value empty? An empty value (null, '', []), present or not, skips every rule on the field, implicit ones included. Leave nullable off fields with a required_* rule: those rules already let the field stay empty when their condition is false.
$this->validator->validate($input, [
'email' => 'sometimes|required|email', // optional key, but never null or invalid when sent
'nickname' => 'nullable|string|max:30', // may be null, '' or left out
]);

Min, Max, and Between measure a value by its type: an int or float by its value, an array by its item count, and a string by its length (mb_strlen). A string of digits is still a string: string|min:8 rejects the password "9", and string|max:255 rejects a 300-character run of digits.

To compare a form string by value, add numeric or integer to the field. The parser then switches that field’s min, max, and between to numeric mode, so integer|min:18 accepts "25" and numeric|max:100 rejects "150":

$errors = $this->validator->validate(
['age' => '25', 'password' => '9'],
[
'age' => 'required|integer|min:18', // "25" compared by value: 25 >= 18
'password' => 'required|string|min:8', // "9" measured by length: 1 < 8
],
);
// Only password fails: "The password field must be at least 8 characters."

Numeric mode applies whether the rules are a string, an array, or rule objects ([new Numeric(), new Min(8)]). A size rule object built on its own measures strings by length; call asNumeric() (or pass numeric: true to the constructor) to compare numeric strings by value.

Size follows the same numeric mode: integer|size:10 accepts "10", while string|size:10 requires ten characters.

In and NotIn compare numeric strings numerically: in:1,2,3 accepts "2" even though it is not strictly identical to the integer 2.

after, before, after_or_equal and before_or_equal take one reference: another field or a date. When a field of that name (a dot path) is present in the data, the value is compared with that field; otherwise the reference is read as a date that strtotime() understands. Both sides are parsed like the date rule, so a DateTimeInterface value works too:

$this->validator->validate($input, [
'starts_at' => 'required|date|after_or_equal:today',
'ends_at' => 'required|date|after:starts_at',
'items.*.ends_at' => 'required|date|after:items.*.starts_at', // compared within each row
]);
// "The ends_at field must be a date after starts_at."

A * in the reference is replaced by the current row’s index, exactly as for same and different. The rule fails when the value, or the referenced field’s value, is not a date.

When the reference is neither a date nor a field present in the data, the rule fails rather than passing: a client that sends ends_at but leaves out starts_at gets the usual 422 error on ends_at, never an exception. The rule cannot see the rest of the rule set, so a misspelled reference (after:starts_att, after:tomorow) also shows up only as a failing field. Cover each comparison with a test that expects it to pass.

distinct belongs on a wildcard key and fails every value that another item under the same key repeats:

$errors = $this->validator->validate(
['items' => [['sku' => 'A1'], ['sku' => 'B2'], ['sku' => 'A1']]],
['items.*.sku' => 'required|distinct'],
);
// items.0.sku and items.2.sku: "The items.0.sku field has a duplicate value."

By default numbers compare by value (1, "1" and "01" are duplicates) and other scalars by their string form. distinct:strict compares with ===, distinct:ignore_case lowercases strings first, and distinct:strict,ignore_case does both. With nested wildcards (orders.*.lines.*.sku) every matched value is compared with every other. distinct on a key without a * throws InvalidArgumentException.

Enum accepts a case of a backed enum, given as the case itself or its backing value. An int-backed enum also accepts an integer string such as "2", since form input arrives as strings. There is no enum: rule string --- a class name inside a rule string is the kind of hidden magic Marko avoids --- so pass the rule object in an array:

use App\Blog\Enums\PostStatus;
use Marko\Validation\Rules\Enum;
$this->validator->validate($input, [
'status' => ['required', Enum::of(PostStatus::class)],
'public_status' => ['required', Enum::of(PostStatus::class)->except(PostStatus::Archived)],
'draft_status' => ['required', Enum::of(PostStatus::class)->only(PostStatus::Draft, PostStatus::Review)],
]);
// "The status field must be one of: 'draft', 'review', 'published', 'archived'."

only() and except() return a new rule and leave the original unchanged. Enum::of() throws InvalidArgumentException for a pure enum (its cases have no value to validate) or a class that is not an enum, and only()/except() throw for a case of another enum.

Exists checks that a stored record has the value (a foreign-key id from a form), and Unique checks that none has it yet (an email, a slug). They name an entity class and a property --- never a table or a column --- and the lookup maps them through the entity’s metadata. Build them with RecordRules, an injectable factory, and pass the rules in an array:

use App\Blog\Entity\Channel;
use App\Blog\Entity\User;
use Marko\Validation\Contracts\ValidatorInterface;
use Marko\Validation\Rules\RecordRules;
public function __construct(
private readonly ValidatorInterface $validator,
private readonly RecordRules $recordRules,
) {}
public function update(
User $user,
array $input,
): void
{
$this->validator->validateOrFail($input, [
'channel_id' => ['required', $this->recordRules->exists(Channel::class)->where('archived', false)],
'email' => [
'required',
'email',
$this->recordRules->unique(User::class, 'email')->caseInsensitive()->ignore($user->id),
],
]);
}
// "The selected channel_id is invalid."
// "The email has already been taken."
MethodReturnsDescription
RecordRules::exists(string $entityClass, string $property = 'id')ExistsA record whose $property equals the value must exist
RecordRules::unique(string $entityClass, string $property)UniqueNo record may already have the value in $property
where(string $property, mixed $value)a new ruleOn either rule: the record must also have $property equal to $value; null matches NULL
Unique::ignore(mixed $id)a new ruleLeave out the record with this primary key (the one being updated)
Unique::caseInsensitive()a new ruleCompare with LOWER() on both sides, on every database

Every method returns a new rule and leaves the original unchanged. Like the other rules, both pass an empty value (add required to reject it), and both fail an array.

marko/validation holds the rules and the RecordLookupInterface contract, but no lookup. Install marko/validation-database for one: until a driver is installed, resolving RecordRules throws NoDriverException naming the package. There is no exists: or unique: rule string, because it would name a table and columns the entity already knows; the parser throws InvalidArgumentException pointing to RecordRules. See marko/validation-database for case sensitivity per database, and for why Unique does not replace a unique index.

url accepts only http and https URLs that have a host. Values such as javascript://%0aalert(1), file:///etc/passwd and gopher://127.0.0.1:6379/_x fail, so a validated URL is safe to render as a link’s href. List the schemes to allow others; the list replaces the default, and schemes compare case-insensitively:

use Marko\Validation\Rules\Url;
$errors = $this->validator->validate($input, [
'website' => 'nullable|url', // http and https only
'mirror' => 'nullable|url:http,https,ftp', // also ftp
'api' => ['required', new Url('https')], // https only
]);

url is not an SSRF guard. It checks the shape and scheme of a URL, not where it points: http://127.0.0.1/ and http://169.254.169.254/ pass. Before your server fetches a user-supplied URL (webhooks, link previews, imports), resolve the host and reject private, loopback and link-local addresses.

The validator checks an array, and uploaded files live apart from the form fields on the request. Merge them into the data you validate:

app/profile/src/Controllers/AvatarController.php
use Marko\Routing\Attributes\Post;
use Marko\Routing\Http\Request;
use Marko\Routing\Http\Response;
use Marko\Validation\Contracts\ValidatorInterface;
class AvatarController
{
public function __construct(
private ValidatorInterface $validator,
) {}
#[Post('/profile/avatar')]
public function upload(Request $request): Response
{
$this->validator->validateOrFail([...$request->input(), ...$request->files()], [
'name' => 'required|string|max:100',
'avatar' => 'required|file|image|max_size:2048',
'resume' => 'nullable|file|mimes:pdf,docx|max_size:5120',
]);
$avatar = $request->file('avatar');
$avatar->moveTo('/var/www/storage/avatars/' . bin2hex(random_bytes(16)) . '.' . $avatar->guessExtension());
return new Response('Saved');
}
}

A failure throws ValidationException, which renders as a 422 with the file errors under their field names, exactly like any other rule. A file field overrides a form field of the same name in the merged array.

The type rules never trust what the client sent. image, mimes and mimetypes read the MIME type that UploadedFile::mimeType() detects from the file contents, so a text file named avatar.png with a Content-Type of image/png fails image and mimes:png. image accepts JPEG, PNG, GIF and WebP only: SVG can carry script, so allow it explicitly with mimes:svg or mimetypes:image/svg+xml when you serve it safely. The image/* wildcard does not match SVG either: mimetypes:image/* rejects an SVG upload, and mimetypes:image/*,image/svg+xml accepts it.

Every file rule fails a value that is not an uploaded file, and an upload that failed or was already moved. Failed uploads get a message that says why --- for example The avatar file is larger than the server allows. when the file exceeded upload_max_filesize.

Sizes are in kilobytes (1 KB = 1024 bytes) and the limits are inclusive. max, min and between keep their meaning for strings, numbers and arrays; given a file, they fail with a message that points to max_size / min_size (The avatar field is a file: use max_size:2048 to limit its size in kilobytes.). max_size and min_size without a number throw InvalidArgumentException, as do mimes and mimetypes without a list.

For a multi-file input (name="photos[]"), array and max:5 on photos check the list and count its files, and rules on photos.* check each file (see Multi-File Uploads).

The file rules depend only on Marko\Core\Contracts\UploadedFileInterface, which Marko\Routing\Http\UploadedFile implements, so marko/validation does not require marko/routing.

Rules keys use dot paths for nested data (address.city). A * segment matches every item of an array, so one rule checks each element:

$this->validator->validate($input, [
'tags' => 'required|array',
'tags.*' => 'string|max:30', // every tag
'items' => 'required|array|min:1',
'items.*.name' => 'required|string', // the name of every item
'items.*.quantity' => 'required|integer|min:1',
'matrix.*.*' => 'integer', // every cell of a list of lists
]);

The validator expands each wildcard key against the data before it runs the rules, so items.*.name becomes items.0.name, items.1.name, and so on. Errors are keyed by that concrete path, which tells the client exactly which item failed:

{
"message": "The given data was invalid.",
"errors": {
"items.1.name": ["The items.1.name field is required."],
"matrix.0.2": ["The matrix.0.2 field must be an integer."]
}
}

A few rules govern expansion:

  • Missing or empty parents expand to nothing. When items is absent, null, '' or [], items.*.name checks nothing and reports no errors. To require at least one item, put required|array or array|min:1 on the parent (items).
  • A non-array where a list is expected fails loudly. When a * meets any other value (a string, a number, a single uploaded file), the validator reports The items field must be an array to apply the items.*.name rules. under the path it reached. A wildcard rule never passes silently because the data has the wrong shape.
  • * is always a wildcard. A rules key cannot address a literal * array key.
  • Rule strings are parsed up front. An unknown rule under a wildcard key throws InvalidArgumentException even when the parent is empty.

confirmed looks for its _confirmation field next to the field it validates, so users.*.pin checks users.0.pin against users.0.pin_confirmation.

same and different take an absolute dot path. A * in that path is replaced by the current row’s index, matching the key’s wildcards from left to right:

$this->validator->validate($input, [
'shipping.email' => 'same:billing.email', // absolute path, no wildcard
'items.*.sku_check' => 'same:items.*.sku', // items.3.sku_check is compared with items.3.sku
'routes.*.legs.*.to' => 'different:routes.*.legs.*.from',
]);

A path with more * than its rules key ('code' => 'same:items.*.sku') has no row to fill in, so the validator throws InvalidArgumentException instead of comparing against nothing.

An <input type="file" name="photos[]" multiple> arrives as a list of uploaded files under photos. Validate the list on photos and each file on photos.*:

app/gallery/src/Controllers/GalleryController.php
use Marko\Routing\Attributes\Post;
use Marko\Routing\Http\Request;
use Marko\Routing\Http\Response;
use Marko\Validation\Contracts\ValidatorInterface;
class GalleryController
{
public function __construct(
private ValidatorInterface $validator,
) {}
#[Post('/gallery')]
public function store(Request $request): Response
{
$this->validator->validateOrFail([...$request->input(), ...$request->files()], [
'photos' => 'required|array|max:10',
'photos.*' => 'image|max_size:2048',
]);
// every file in $request->files('photos') is an image of at most 2 MB
return new Response('Saved');
}
}

An invalid upload fails with a 422 whose errors are keyed per file (photos.0, photos.3). A single file sent as photos instead of photos[] fails with the “must be an array” error rather than skipping the per-file rules.

Rules can be strings, arrays, or RuleInterface instances:

use Marko\Validation\Rules\Max;
use Marko\Validation\Rules\Required;
$this->validator->validate($input, [
'name' => 'required|string', // String format
'email' => ['required', 'email'], // Array of strings
'bio' => [new Required(), new Max(500)], // Rule objects
]);

Implement RuleInterface for custom validation logic:

use Marko\Validation\Contracts\RuleInterface;
class UniqueEmail implements RuleInterface
{
public function passes(
string $field,
mixed $value,
array $data,
): bool {
// Check uniqueness against database
return !$this->emailExists($value);
}
public function message(
string $field,
mixed $value,
): string {
return "The $field has already been taken.";
}
}
// Usage:
$this->validator->validate($input, [
'email' => ['required', 'email', new UniqueEmail()],
]);

A rule that checks several requirements, such as a password strength rule, can implement MultiMessageRuleInterface to report each failed requirement as its own error. When it fails, the validator adds every string from messages() instead of the single message():

use Marko\Validation\Contracts\MultiMessageRuleInterface;
class Slug implements MultiMessageRuleInterface
{
public function passes(
string $field,
mixed $value,
array $data,
): bool {
return $this->messages($field, $value) === [];
}
public function message(
string $field,
mixed $value,
): string {
return implode(' ', $this->messages($field, $value));
}
public function messages(
string $field,
mixed $value,
): array {
$messages = [];
if (preg_match('/[A-Z]/', (string) $value)) {
$messages[] = "The $field field must be lowercase.";
}
if (str_contains((string) $value, ' ')) {
$messages[] = "The $field field must not contain spaces.";
}
return $messages;
}
}

A custom rule that must run on an empty or absent value implements ImplicitRuleInterface. Its requiresValue() tells the validator whether the field must hold a value given the data. When it returns true, the field’s other rules run on the empty value as well; when it returns false, only the implicit rules run.

use Marko\Validation\Contracts\ImplicitRuleInterface;
class RequiredForCountry implements ImplicitRuleInterface
{
public function __construct(
private string $country,
) {}
public function passes(
string $field,
mixed $value,
array $data,
): bool {
if (!$this->requiresValue($field, $data)) {
return true;
}
return $value !== null
&& $value !== '';
}
public function message(
string $field,
mixed $value,
): string {
return "The $field field is required for $this->country addresses.";
}
public function requiresValue(
string $field,
array $data,
): bool {
return ($data['country'] ?? null) === $this->country;
}
}
// Usage: state is required, and must be two letters, only for US addresses
$this->validator->validate($input, [
'state' => [new RequiredForCountry('US'), 'alpha', 'size:2'],
]);

A rule that refers to another field which may contain * also implements WildcardAwareRuleInterface.

interface ValidatorInterface
{
public function validate(
array $data,
array $rules,
): ValidationErrors;
public function validateOrFail(
array $data,
array $rules,
): void;
public function passes(
array $data,
array $rules,
): bool;
public function fails(
array $data,
array $rules,
): bool;
}
interface RuleInterface
{
public function passes(
string $field,
mixed $value,
array $data,
): bool;
public function message(
string $field,
mixed $value,
): string;
}

Under a wildcard key, $field is the concrete path being checked (items.0.name).

interface ImplicitRuleInterface extends RuleInterface
{
public function requiresValue(
string $field,
array $data,
): bool;
}

The validator always runs an implicit rule, even on an empty value or an absent key. When any implicit rule’s requiresValue() returns true, the field’s other rules run on an empty value too. Required (always true), RequiredIf, RequiredUnless, RequiredWith, RequiredWithout (their condition), Present, Prohibited, Accepted and Declined (always false) implement it. See Custom Implicit Rules.

interface WildcardAwareRuleInterface extends RuleInterface
{
public function forWildcardIndexes(
string $key,
array $indexes,
): RuleInterface;
}

Implement it on a custom rule that refers to another field which may contain *. Before checking each concrete field, the validator passes the rules key and the indexes its wildcards matched (['3'] for items.3.sku_check under items.*.sku_check), and uses the rule it returns. Same, Different, After, Before, AfterOrEqual, BeforeOrEqual, Distinct, RequiredIf, RequiredUnless, RequiredWith and RequiredWithout implement it; throw InvalidArgumentException when the referenced field has more * than there are indexes.

interface NumericAwareRuleInterface extends RuleInterface
{
public function asNumeric(): RuleInterface;
}

Implement it on a custom size rule whose meaning for a numeric string depends on the field’s other rules. When a field’s rules include numeric or integer, the parser replaces each such rule with the one asNumeric() returns. Min, Max, Between, and Size implement it (see Numeric-Aware Rules).

interface RecordLookupInterface
{
public function exists(
string $entityClass,
string $property,
mixed $value,
array $where = [],
): bool;
public function isTaken(
string $entityClass,
string $property,
mixed $value,
mixed $ignoreId = null,
array $where = [],
bool $caseInsensitive = false,
): bool;
}

The contract behind Exists and Unique; $where maps property names to values. marko/validation-database implements it. Bind your own implementation to look records up somewhere else.

readonly class RecordRules
{
public function __construct(RecordLookupInterface $lookup);
public function exists(
string $entityClass,
string $property = 'id',
): Exists;
public function unique(
string $entityClass,
string $property,
): Unique;
}
interface MultiMessageRuleInterface extends RuleInterface
{
public function messages(
string $field,
mixed $value,
): array; // list<string>
}

When the rule fails, the validator adds each message to the field’s errors instead of message(). Password from marko/authentication implements it. See Rules With Several Messages.

class ValidationErrors implements Countable, IteratorAggregate
{
public function add(
string $field,
string $message,
): self;
public function has(string $field): bool;
public function get(string $field): array;
public function first(string $field): ?string;
public function all(): array;
public function keys(): array;
public function isEmpty(): bool;
public function isNotEmpty(): bool;
public function count(): int;
public function toFlatArray(): array;
}
class ValidationException extends Exception implements HttpExceptionInterface
{
public static function withErrors(ValidationErrors $errors): self;
public function errors(): ValidationErrors;
public function getContext(): string;
public function getSuggestion(): string;
public function getStatusCode(): int; // 422
public function getHeaders(): array; // []
public function getResponseData(): array; // ['message' => ..., 'errors' => $errors->all()]
}

The single reader of the form state a failed submission flashed for the next request. View helpers, Inertia and test assertions all read through it, so none of them knows how the state is stored. Reading never consumes it. marko/validation only defines the contract; marko/forms binds the implementation. Without marko/forms, resolving it throws NoDriverException naming the package.

use Marko\Validation\Contracts\FlashedFormStateInterface;
public function errors(): ValidationErrors; // empty when nothing was flashed
public function hasErrors(): bool;
public function old(
string $key,
mixed $default = null,
): mixed; // dot paths: 'address.city', 'items.0.sku'