Validation

Validation

Niang\Core\Validation\Validator: 29 rules, checked one by one against Validator::applyRule() — not against a list retyped from memory.

Validating from a controller

Controller::validate() wraps Validator::make(...)->validate():

php
public function store(Request $request): Response
{
    $data = $this->validate($request, [
        'name' => 'required|string|min:2',
        'email' => 'required|email',
        'message' => 'required|string|min:10',
    ]);

    // $data only contains the fields declared in the rules.
}

A field's rules are written as a string separated by |, or as an array (['required', 'string', 'min:2']) — both forms are equivalent.

FormRequest: validation by injection

A class that extends Niang\Core\Validation\FormRequest and declares rules() validates itself as soon as it is type-hinted in a controller — the Container builds it from the current request, checks authorize() then validates rules() before even entering the method (see reflection-based injection):

app/Requests/ContactRequest.php

namespace App\Requests;

use Niang\Core\Validation\FormRequest;

class ContactRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'required|string|min:2',
            'email' => 'required|email',
            'message' => 'required|string|min:10',
        ];
    }
}
php
public function store(ContactRequest $request): Response
{
    $data = $request->validated(); // already validated, guaranteed by the time we get here
}

authorize() returns true by default; overriding it to return false throws an AuthorizationException (403) before validation even runs — useful for a FormRequest that must check a permission on top of the shape of the data.

What happens when it fails

A failing rule throws a ValidationException, caught exactly once by Handler::render() (see request lifecycle) — never something to handle manually in the controller:

  • If the request expects JSON (Accept: application/json): a 422 response with {"message": "...", "errors": {...}}.
  • Otherwise: a redirect to the Referer, with the errors and the previous input flashed to the session — retrievable with errors('field') and old('field') in the view.
php
<input type="email" name="email" value="<?= e(old('email')) ?>">
<?= component('components/field-errors', ['field' => 'email']) ?>

Only one error is kept per field (the first rule that fails), to stay readable.

Every rule

RuleChecksExample
requiredPresent and not empty (non-blank string, non-empty array).'name' => 'required'
required_if:field,valueRequired only if field equals value.'phone' => 'required_if:contact_method,phone'
required_with:fieldRequired only if field is filled in.'shipping_city' => 'required_with:shipping_address'
required_without:fieldRequired only if field is empty.'email' => 'required_without:phone'
stringis_string().'name' => 'string'
numericis_numeric().'price' => 'numeric'
integerFILTER_VALIDATE_INT.'quantity' => 'integer'
booleanA boolean, or 0/1/'0'/'1'/'true'/'false'.'accepted' => 'boolean'
arrayis_array().'tags' => 'array'
emailFILTER_VALIDATE_EMAIL.'email' => 'required|email'
urlFILTER_VALIDATE_URL.'website' => 'url'
datestrtotime() succeeds.'birthdate' => 'date'
date_format:formatMatches a DateTime::createFromFormat() format exactly.'day' => 'date_format:Y-m-d'
min:n≥ n (string length, value if numeric, size in KB for a file).'password' => 'min:8'
max:n≤ n (same units as min).'bio' => 'max:500'
between:min,maxBetween the two.'age' => 'between:18,99'
in:a,b,cOne of the listed values.'role' => 'in:admin,editor,viewer'
not_in:a,b,cNone of the listed values.'username' => 'not_in:admin,root'
same:fieldIdentical to another field.'password_confirmation' => 'same:password'
different:fieldDifferent from another field.'new_password' => 'different:current_password'
regex:patternMatches the PCRE pattern (delimiters included).'code' => 'regex:/^[A-Z]{3}-\d{4}$/'
confirmedAn x_confirmation field exists and is identical to it.'password' => 'required|confirmed'
unique:table,column[,id,idColumn]No existing row with this value (the 3rd parameter ignores the current row during an update).'email' => "unique:users,email,{$id},id"
exists:table,columnA row exists with this value (column defaults to id).'category_id' => 'exists:categories,id'
fileAn uploaded file that arrived without error (see File uploads).'cv' => 'required|file'
imageThe real content is a JPEG, PNG, GIF, WebP or AVIF image (SVG excluded: it can contain JavaScript).'avatar' => 'image|max:2048'
mimes:ext,...The extension deduced from the content (never from the name sent) is in the list.'cv' => 'mimes:pdf,docx'
mimetypes:type,...The real MIME type is in the list; accepts a wildcard (image/*).'media' => 'mimetypes:image/*,video/mp4'
dimensions:...An image's width/height: min_width, max_width, min_height, max_height, width, height.'banner' => 'dimensions:min_width=1200,max_height=600'

nullable is not in this table: it is not a rule but a modifier (handled by runRules(), before the loop) — placed before the others, it skips every following rule when the field is empty, rather than making them fail.

Custom messages and attributes

php
Validator::make($data, [
    'email' => 'required|email',
], [
    'email.required' => 'We need an email address to get back to you.',
], [
    'email' => 'Email address',
])->validate();

The 3rd argument (messages) accepts a 'field.rule' key (takes precedence) or just 'rule' (applies to every field). The 4th (attributes) replaces the field name in the default messages, e.g. “The Email address field is required.” rather than “The email field is required.”.

The default messages come from lang/<locale>/validation.php, shipped in French and English (APP_LOCALE=en): edit a message there for the whole project, or give a field a label in its attributes section — see Languages.

Validating an array (items.* rules)

A key containing .* applies its rules to every element of an array:

php
Validator::make($data, [
    'items' => 'required|array',
    'items.*.name' => 'required|string',
    'items.*.quantity' => 'required|integer|min:1',
])->validate();

Each error is indexed by position: items.0.name, items.1.quantity, etc. — retrievable individually with errors('items.0.name').

⏱ 8.49 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200