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():
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):
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',
];
}
}
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): a422response with{"message": "...", "errors": {...}}. - Otherwise: a redirect to the
Referer, with the errors and the previous input flashed to the session — retrievable witherrors('field')andold('field')in the view.
<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
| Rule | Checks | Example |
|---|---|---|
required | Present and not empty (non-blank string, non-empty array). | 'name' => 'required' |
required_if:field,value | Required only if field equals value. | 'phone' => 'required_if:contact_method,phone' |
required_with:field | Required only if field is filled in. | 'shipping_city' => 'required_with:shipping_address' |
required_without:field | Required only if field is empty. | 'email' => 'required_without:phone' |
string | is_string(). | 'name' => 'string' |
numeric | is_numeric(). | 'price' => 'numeric' |
integer | FILTER_VALIDATE_INT. | 'quantity' => 'integer' |
boolean | A boolean, or 0/1/'0'/'1'/'true'/'false'. | 'accepted' => 'boolean' |
array | is_array(). | 'tags' => 'array' |
email | FILTER_VALIDATE_EMAIL. | 'email' => 'required|email' |
url | FILTER_VALIDATE_URL. | 'website' => 'url' |
date | strtotime() succeeds. | 'birthdate' => 'date' |
date_format:format | Matches 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,max | Between the two. | 'age' => 'between:18,99' |
in:a,b,c | One of the listed values. | 'role' => 'in:admin,editor,viewer' |
not_in:a,b,c | None of the listed values. | 'username' => 'not_in:admin,root' |
same:field | Identical to another field. | 'password_confirmation' => 'same:password' |
different:field | Different from another field. | 'new_password' => 'different:current_password' |
regex:pattern | Matches the PCRE pattern (delimiters included). | 'code' => 'regex:/^[A-Z]{3}-\d{4}$/' |
confirmed | An 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,column | A row exists with this value (column defaults to id). | 'category_id' => 'exists:categories,id' |
file | An uploaded file that arrived without error (see File uploads). | 'cv' => 'required|file' |
image | The 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
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:
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').