The basics
Validation
Oriel checks the data your app receives with Laravel's validation rules, and types the validated data from the rules you write.
Introduction#
Oriel validates in three ways: request().validate() in a route or a controller, form request classes, and
validators you make yourself with Validator.make(). Each takes the same rules, with Laravel's names, such as
required|email|max:255, and fails with Laravel's messages. When validation fails, the user goes back to the form
with the errors, or an API client receives them as JSON.
Validation quickstart#
Here is a complete example: a form that creates a blog post, validated, with its errors shown to the user.
Defining the routes#
Two routes in routes/: a GET route shows the form, and a POST route stores the post:
import { Route } from '@orieljs/framework'import { PostController } from '../app/Http/Controllers/PostController'Route.get('/post/create', [PostController, 'create'])Route.post('/post', [PostController, 'store'])Creating the controller#
The controller shows the form and stores the post. store() is empty for now:
import { redirect, view } from '@orieljs/framework'import { Controller } from './Controller'export class PostController extends Controller { /** Show the form to create a new blog post. */ create() { return view('post.create') } /** Store a new blog post. */ async store() { // Validate and store the blog post... return redirect('/posts') }}Writing the validation logic#
request().validate() takes the rules for each field. When the input passes, it returns the validated data and your
code goes on. When it fails, it throws, and Oriel sends the response:
- to a browser's form, a redirect back to the previous page, with the errors and the input flashed to the session;
- to a request that expects JSON, a 422 response with the errors (see the error response format).
import { redirect, request } from '@orieljs/framework'import { Controller } from './Controller'export class PostController extends Controller { /** Store a new blog post. */ async store() { const validated = await request().validate({ title: 'required|unique:posts|max:255', body: 'required', }) // The blog post is valid... return redirect('/posts') }}The rules are a string with | between them, or a list. A list can also hold rule objects:
const validated = await request().validate({ title: ['required', 'unique:posts', 'max:255'], body: ['required'],})The validated data is typed from the rules. Here title and body are always there but unknown, since no rule
says what they are; with string among their rules, both are typed string. Working with validated
input covers the types.
A rule name Oriel doesn't know, such as a misspelt one, is an error when it runs on a field the request sends, and the
request ends with a 500:
Validation rule "requird" is not registered. Register it with ValidationFactory.extend() first. On a field the request
leaves out or sends blank, the rule doesn't run: a misspelt required lets a missing field through.
Stopping on first validation failure#
bail stops a field's rules at its first failure. Without it, every rule of the field runs and each failure adds a
message:
const validated = await request().validate({ title: 'bail|required|unique:posts|max:255', body: 'required',})Here a failing unique skips max. Rules run in the order you write them. A failing required, or another rule
that runs on a missing field, always stops its field.
A note on nested attributes#
Fields the request sends nested, as JSON or as author[name] in a form, are named with dots:
const validated = await request().validate({ title: 'required|unique:posts|max:255', 'author.name': 'required', 'author.description': 'required',})Laravel escapes a dot that belongs to a field's name with a backslash, as v1\.0; Oriel has no such escape.
Displaying the validation errors#
When validation fails, the user is sent back to the form, with the errors and the input flashed to the session.
errors() reads the errors in your views:
import { errors } from '@orieljs/framework'export default function Create() { const messages = errors() return ( <main> <h1>Create Post</h1> {!messages.isEmpty() && ( <div class="alert alert-danger"> <ul> {messages.all().map((message) => ( <li>{message}</li> ))} </ul> </div> )} {/* Create Post Form */} </main> )}errors() returns an empty bag when there are no errors, so a view can call it at any time.
Customizing the error messages#
Each rule has Laravel's English message, such as "The title field is required.". Give your own to a form request or to a validator.
XHR requests and validation#
A request that expects JSON, as a script's fetch() with Accept: application/ does, gets no redirect: a
failed validation answers with a 422 and the errors as JSON. Error handling covers how Oriel tells the
two kinds of request apart.
Showing a field's errors#
errors().first('title') returns a field's first message, or undefined, so a field can show its own error:
import { errors } from '@orieljs/framework'export default function Create() { const error = errors().first('title') return ( <form method="post" action="/post"> <label for="title">Post Title</label> <input id="title" type="text" name="title" class={{ 'is-invalid': error !== undefined }} /> {error && <div class="alert alert-danger">{error}</div>} </form> )}With named error bags, errors('post').first('title') reads the post bag.
Repopulating forms#
The input of a failed validation is flashed with the errors, except the password, password_confirmation and
current_password fields. old() reads it on the next request, so a form shows what the user typed:
const input = <input type="text" name="title" value={old('title', '')} />Without old input for the field, old() returns the default you give it. Requests has
more on old input.
A note on optional fields#
The TrimStrings and ConvertEmptyStringsToNull middleware tidy every request's input, so a field left empty
arrives as null. Mark an optional field nullable, so null passes its other rules:
const validated = await request().validate({ title: 'required|unique:posts|max:255', body: 'required', publish_at: 'nullable|date',})Without nullable, null fails the date rule.
Validation error response format#
A failed validation answers a request that expects JSON with a 422, a message (the first error, and how many more
there are) and errors, by field. Nested fields are named with dots:
{ "message": "The team name field must be a string. (and 4 more errors)", "errors": { "team_name": ["The team name field must be a string.", "The team name field must be at least 1 characters."], "authorization.role": ["The selected authorization.role is invalid."], "users.0.email": ["The users.0.email field is required."], "users.2.email": ["The users.2.email field must be a valid email address."] }}Displaying errors in Inertia pages#
In the Inertia starter kits, a failed validation sends the user back to the page, and the errors reach it as the
errors prop: one message per field. Each adapter's useForm() holds them in errors:
resources/js/pages/Auth/ForgotPassword.tsx
import { route, useForm } from '@orieljs/inertia-react'import type { FormEvent } from 'react'export default function ForgotPassword() { const { data, setData, post, processing, errors } = useForm<{ email: string }>({ email: '', }) const submit = (event: FormEvent) => { event.preventDefault() post(route('password.email')) } return ( <form onSubmit={submit}> <input type="email" value={data.email} onChange={(event) => setData('email', event.target.value)} /> {errors.email && <p className="error">{errors.email}</p>} <button type="submit" disabled={processing}> Email password reset link </button> </form> )}resources/js/pages/Auth/ForgotPassword.vue
<script setup lang="ts">import { route, useForm } from '@orieljs/inertia-vue'const form = useForm({ email: '',})const submit = () => { form.post(route('password.email'))}</script><template> <form @submit.prevent="submit"> <input v-model="form.email" type="email" /> <p v-if="form.errors.email" class="error">{{ form.errors.email }}</p> <button type="submit" :disabled="form.processing">Email password reset link</button> </form></template>resources/js/pages/Auth/ForgotPassword.svelte
<script lang="ts"> import { route, useForm } from '@orieljs/inertia-svelte' const form = useForm({ email: '', }) function submit(event: SubmitEvent) { event.preventDefault() form.post(route('password.email')) }</script><form onsubmit={submit}> <input type="email" bind:value={form.email} /> {#if form.errors.email} <p class="error">{form.errors.email}</p> {/if} <button type="submit" disabled={form.processing}>Email password reset link</button></form>resources/js/pages/Auth/ForgotPassword.tsx
import { route, useForm } from '@orieljs/inertia-solid'import { Show } from 'solid-js'export default function ForgotPassword() { const form = useForm<{ email: string }>({ email: '', }) const submit = (event: SubmitEvent) => { event.preventDefault() form.post(route('password.email')) } return ( <form onSubmit={submit}> <input type="email" value={form.data.email} onInput={(event) => form.setData('email', event.currentTarget.value)} /> <Show when={form.errors.email}> <p class="error">{form.errors.email}</p> </Show> <button type="submit" disabled={form.processing}> Email password reset link </button> </form> )}resources/js/pages/Auth/ForgotPassword.component.ts
import { Component } from '@angular/core'import { route, useForm } from '@orieljs/inertia-angular'@Component({ selector: 'forgot-password-page', template: ` <form (submit)="submit($event)"> <input #emailInput type="email" [value]="form.data.email" (input)="form.setData('email', emailInput.value)" /> @if (form.errors.email) { <p class="error">{{ form.errors.email }}</p> } <button type="submit" [disabled]="form.processing">Email password reset link</button> </form> `,})export default class ForgotPassword { protected readonly form = useForm<{ email: string }>({ email: '' }) protected submit(event: SubmitEvent): void { event.preventDefault() this.form.post(route('password.email')) }}Inertia covers forms in more depth.
Precognition#
A route with the precognitive middleware can validate a form while the user fills it in: the React, Vue and
Svelte forms send a precognitive request, which runs the action up to its first validation and answers with the
errors, or a 204 when the data passes. Precognitive requests describes the
requests. The Solid and Angular forms don't offer Precognition yet.
Displaying errors in Live components#
A Live component validates its own properties with this.validate(). A failed validation stops the action, and the
component renders again with its errors in this.errors:
import { Component, request, Str } from '@orieljs/framework'import type { User } from '../Models/User'export default class CreatePost extends Component { title = '' async save(): Promise<void> { const data = await this.validate({ title: 'required|string|max:255' }) const user = (await request().user<User>())! await user.posts().create({ title: data.title, slug: Str.slug(data.title), body: '' }) } render() { return ( <form o:submit="save"> <input type="text" o:model="title" /> {this.errors.has('title') && <p class="error">{this.errors.first('title')}</p>} <button type="submit">Save</button> </form> ) }}await request().user() returns the signed-in user, or undefined for a guest. The page's route has the auth
middleware, which each update runs again, so here there is always one. Live covers validation in
components.
Form request validation#
Creating form requests#
A form request is a class that holds the rules of one form, and the check that the user may send it. make:request
writes one in app/:
node oriel make:request StorePostRequestThe class it writes has two methods: authorize(), which decides whether the signed-in user may make the request,
and rules(), which returns the rules:
import { FormRequest } from '@orieljs/framework'export class StorePostRequest extends FormRequest { override authorize(): boolean { return true } /** Get the validation rules that apply to the request. */ rules() { return { title: 'required|string|unique:posts|max:255', body: 'required|string', } as const }}The action validates the request by calling the class's validate(), which returns the validated data:
import { redirect, request, Str } from '@orieljs/framework'import type { User } from '../../Models/User'import { StorePostRequest } from '../Requests/StorePostRequest'import { Controller } from './Controller'export class PostController extends Controller { /** Store a new blog post. */ async store() { const validated = await StorePostRequest.validate() const user = (await request().user<User>())! const post = await user.posts().create({ ...validated, slug: Str.slug(validated.title) }) return redirect().route('posts.show', post) }}The route to store() has the auth middleware, so await request().user() is the signed-in user. A failure is
answered as request().validate()'s is: a redirect back with the errors, or a 422 with the errors as JSON.
class WithoutAsConstRequest extends FormRequest { rules() { return { title: 'required|string' } }}const data = await WithoutAsConstRequest.validate()// @ts-expect-error: without as const, title is unknown and may be missingconst title: string = data.titlerequest().validate() needs neither: it reads the rules' text from the call.
Authorizing form requests#
authorize() decides whether the user may make the request. It may be async, as checking a policy is. When it returns
false, validate() throws before validating, and the request ends with a 403, "This action is unauthorized.":
import { Auth, FormRequest, request } from '@orieljs/framework'import type { Post } from '../../Models/Post'import type { User } from '../../Models/User'export class UpdatePostRequest extends FormRequest { override async authorize(): Promise<boolean> { const post = request().route<Post>('post') const user = await Auth.user<User>() return user !== undefined && (await user.can('update', post)) } rules() { return { title: 'required|string|max:255' } as const }}request().route('post') returns the route's post parameter, after model binding,
and Auth.user() the signed-in user, or undefined for a guest. Without an authorize(), every request may proceed.
Gates and policies are covered on the Authorization page.
Customizing the error messages#
messages() returns messages for a field's rule, and attributes() the names that replace :attribute in them:
import { FormRequest } from '@orieljs/framework'export class StorePostRequest extends FormRequest { rules() { return { title: 'required|string|max:255', body: 'required|string', } as const } override messages(): Record<string, string> { return { 'title.required': 'A title is required', 'body.required': 'A message is required', } } override attributes(): Record<string, string> { return { body: 'post body', } }}Laravel's form requests also have after(), stopOnFirstFailure, $redirect, prepareForValidation() and
passedValidation(), which Oriel's don't. Change the input before you validate with request().merge(), and check
more with a validator of your own.
Manually creating validators#
The Validator facade makes a validator from any data and rules, when you want to decide what a failure
does:
import { redirect, request, Validator } from '@orieljs/framework'import { Controller } from './Controller'export class PostController extends Controller { /** Store a new blog post. */ async store() { const validator = Validator.make(request().all(), { title: 'required|unique:posts|max:255', body: 'required', }) if (await validator.fails()) { return redirect('/post/create').withErrors(validator.errors()).withInput() } // Retrieve the validated input... const validated = await validator.validated() // Store the blog post... return redirect('/posts') }}make() takes the data to validate, the rules, and then custom messages and attribute names. passes() and
fails() run the rules once, and errors() returns the messages afterwards. validated() returns the data that has
rules, and throws when it fails. Its data is typed from the rules, as request().validate()'s is.
Automatic redirection#
A failing validated() throws the same error as request().validate(), so the redirect back, or the 422, is sent
for you:
const validated = await Validator.make(request().all(), { title: 'required|unique:posts|max:255', body: 'required',}).validated()Named error bags#
A page with several forms keeps each form's errors apart in a named error bag. withErrors() takes the bag's name
as its second argument:
const res = redirect('/register').withErrors(validator.errors(), 'login')The view reads it with errors('login'):
const message = <p>{errors('login').first('email')}</p>To send a failure to a named bag from anywhere, throw a ValidationException with the bag's name; a third argument
redirects somewhere other than back:
throw new ValidationException({ email: ['These credentials do not match our records.'] }, 'login')ValidationException.withMessages({ email: 'These credentials do not match our records.' }) fails the request
with your own messages, in the default bag, as the starter kits' login does. Laravel's validateWithBag() isn't in
Oriel.
Customizing the error messages#
The third argument of make() replaces the messages of rules. :attribute is the field's name:
const validator = Validator.make(input, rules, { required: 'The :attribute field is required.',})A message can use the placeholders its rule fills: :other for the field same compares with, :size, :min,
:max, :values and so on, as Laravel's messages do.
Specifying a custom message for a given attribute#
Name the field, then the rule, to change one field's message. A wildcard names every item of a list:
const messages = { 'email.required': 'We need to know your email address!', 'users.*.email.unique': 'Each user must have a unique email address',}Specifying custom attribute values#
The fourth argument names fields in the messages, in place of their key:
const validator = Validator.make(input, rules, messages, { email: 'email address',})Without one, a key is written in words: first_name is "first name", productName "product name", and
users.0.email stays as it is.
Performing additional validation#
after() runs a function once the rules have run, to check more. The function receives the validator, whose
fail() adds a message:
const validator = Validator.make(request().all(), { title: 'required' })validator.after((v) => { if (somethingElseIsInvalid()) { v.fail('field', 'Something is wrong with this field!') }})if (await validator.fails()) { // ...}The function may be async, and runs whether the rules passed or not. Each call to after() adds a function and
returns the validator, so the calls chain.
Working with validated input#
validate() and validated() return only the fields that have rules, nested as the request sent them: a key without
rules is left out, and so is a key the request didn't send.
const validated = await request().validate({ name: 'required|string', email: 'required|email' })const { name, email } = validatedThe data is a plain object. Laravel's safe(), with only(), except() and merge(), isn't in Oriel: pick the
keys you want from the object.
The validated data's type#
Each field's type comes from its rules. The type rules decide what a value is, and the presence rules whether the key is there:
const data = await request().validate({ title: 'required|string|max:255', votes: 'nullable|integer', status: 'required|in:draft,published', tags: 'array|in:php,laravel', 'author.name': 'required|string',})const title: string = data.titleconst votes: NumericValue | null | undefined = data.votesconst status: 'draft' | 'published' = data.statusconst tags: Array<'php' | 'laravel'> | undefined = data.tagsconst author: string = data.author.namestring,email,url,dateand the other text rules make astring;fileandimageanUploadedFile.required,acceptedanddeclinedput the key in the data and rule outnull;presentputs the key in. Without one of them, the key is optional.nullableaddsnull, unless one of those rules, orfilled, rules it out.in:a,blets the listed values through, and witharraymakes a list of them.arrayis a list,unknown[], andarray:name,emailan object with those optional keys.- Dotted keys nest:
author.nameis{ author: { name } }. - Rules that only check a value, such as
max,confirmedorunique, change nothing, and a field without a type rule isunknown.
A few rules make a key optional, or keep it from being typed as a list:
sometimesskips a missing field, andexclude_if,exclude_unless,exclude_withandexclude_withoutcan take a field out: with any of them the key is optional, even withrequired.prohibitedpasses a missing or empty field, so its value is typednull | string | [] | Record<string, never>.- A
*level whose parent has noarrayorlistrule is typedV[] | Record<string, V>: the request may send an object there, and a sparse match keeps its indexes as keys. Give the parentarrayto make it a list:
const data = await request().validate({ items: 'required|array', 'items.*.qty': 'required|integer',})const quantities: NumericValue[] = data.items.map((item) => item.qty)Rule strings TypeScript only knows as string, such as a form request's rules() without as const, give
optional unknown fields.
Working with error messages#
A validator's errors() returns its messages, by field. errors() in a view returns the same kind of bag, read from
the session:
const errors = validator.errors()const first = errors.first('email')Retrieving all error messages for a field#
get() returns a field's messages, or an empty list. Fields under a wildcard are named by their index, as
attachments.0:
for (const message of errors.get('email')) { // ...}Retrieving all error messages for all fields#
for (const message of errors.all()) { // ...}Determining if messages exist for a field#
if (errors.has('email')) { // ...}isEmpty() and count() tell you whether there are messages at all and how many, and toObject() returns them
as { field: [messages] }.
Laravel reads its messages, custom messages and attribute names from lang/. Oriel has Laravel's
English messages built in and no validation language file: give your own messages to the validator or the form
request.
Available validation rules#
The rules by what they check, then each rule in alphabetical order.
Booleans#
accepted, accepted_if, boolean, declined,
declined_if
Strings#
active_url, alpha, alpha_dash, alpha_num,
ascii, confirmed, current_password, different,
doesnt_start_with, doesnt_end_with, email,
ends_with, enum, hex_color, in, ip, ipv4,
ipv6, json, lowercase, mac_address, max,
min, not_in, regex, not_regex, same, size,
starts_with, string, uppercase, url, ulid,
uuid
Numbers#
between, decimal, different, digits,
digits_between, gt, gte, integer, lt,
lte, max, max_digits, min, min_digits,
multiple_of, numeric, same, size
Arrays#
array, between, contains, doesnt_contain,
distinct, in_array, in_array_keys, list,
max, min, size
Dates#
after, after_or_equal, before,
before_or_equal, date, date_equals,
date_format, different, timezone
Files#
between, dimensions, extensions, file,
image, max, min, mimetypes, mimes, size
Database#
Utilities#
bail, exclude, exclude_if, exclude_unless,
exclude_with, exclude_without, filled,
missing, missing_if, missing_unless,
missing_with, missing_with_all, nullable,
present, present_if, present_unless,
present_with, present_with_all, prohibited,
prohibited_if, prohibited_if_accepted,
prohibited_if_declined, prohibited_unless,
prohibits, required, required_if,
required_if_accepted, required_if_declined,
required_unless, required_with,
required_with_all, required_without,
required_without_all, required_array_keys,
sometimes
Most rules don't run on a field that is missing or blank text, so they pass it. Only required, accepted,
declined, filled, present and missing, their _if, _unless, _with and _without forms, and the exclude
rules run on one.
accepted#
The field is 'yes', 'on', 1, '1', true or 'true', as a ticked "terms of service" checkbox sends. A
missing field fails.
accepted_if#
accepted_if:anotherfield,value,...: the field is accepted when the other field holds one of the values.
active_url#
The URL's host has an address. The value is read as a URL, and its host looked up.
after#
after:date: a date after the one given. The parameter is another field's name, now, today, tomorrow,
yesterday, an offset such as +1 week or -3 days, or a date. With date_format on the field, the value and the
date are read with that format. A parameter that is neither, and doesn't look like a field's name, is an error in
your rules (a 500). Laravel reads any phrase PHP's strtotime() knows; Oriel reads only these, and takes another
word, such as monday, for a field's name. A number passes as a timestamp, as in Laravel, so the value is typed
string | number, or string with date among the rules; the other date comparisons are typed the same way.
after_or_equal#
after_or_equal:date: a date after or equal to the one given, read as after reads it.
alpha#
Only the letters A to Z and a to z. Laravel's accepts letters of any script unless you write alpha:ascii;
Oriel's accepts these letters only.
alpha_dash#
Only the letters A to Z and a to z, digits, - and _.
alpha_num#
Only the letters A to Z and a to z, and digits.
array#
A list: a JavaScript array. array:name,email is an object whose keys are all among those named, as is
Rule.array(['name', 'email']):
const data = await request().validate({ user: 'required|array:name,username' })ascii#
Only printable ASCII characters, tabs and line breaks: text or a number.
bail#
Stops the field's rules at its first failure (see stopping on first validation failure).
before#
before:date: a date before the one given, read as after reads it.
before_or_equal#
before_or_equal:date: a date before or equal to the one given, read as after reads it.
between#
between:min,max: a size between min and max, both included. Sizes are measured as size measures them.
boolean#
true, false, 1, 0, '1' or '0'. boolean:strict accepts only true and false. The text 'true' fails,
as in Laravel.
confirmed#
The field equals <field>_confirmation: password is compared with password_confirmation.
confirmed:repeat_password names the other field.
contains#
contains:foo,bar: an array that holds every value given. 1 and '1' count as the same value. Rule.contains([...])
does the same.
current_password#
The signed-in user's password. current_password:api checks the user of that guard. A guest fails.
date#
Text that reads as a date: an ISO date, with or without a time, a date such as 10/, month first, or one
with a month name. A YYYY-MM-DD date without a time must be a real day.
date_equals#
date_equals:date: the same date as the one given, read as after reads it.
date_format#
date_format:format,...: a date in one of the formats, written with PHP's format characters, as
date_format:Y-m-d. The value must read with the format and write back the same, so 2026-02-30 fails. Oriel
reads the characters d j D l S F M m n Y y a A g h G H i s v u O P p U, and \ to escape one; a format with one of
PHP's other characters, such as T or z, is an error in your rules (a 500). Commas separate the formats, so a
format can't hold one.
decimal#
decimal:min,max: a number with min decimal places, or between min and max: decimal:2 takes 1.25.
declined#
The field is 'no', 'off', 0, '0', false or 'false'. A missing field fails.
declined_if#
declined_if:anotherfield,value,...: the field is declined when the other field holds one of the values.
different#
different:field: the field's value isn't the other field's. The values are compared exactly, so 1 and '1' are
different.
digits#
digits:value: only digits, exactly value of them.
digits_between#
digits_between:min,max: only digits, between min and max of them.
dimensions#
dimensions:min_width=100,min_height=200: an image whose size meets the constraints. The constraints are width,
height, min_width, max_width, min_height, max_height and ratio, written 3/ or 1.5. The size is read
from the image's header, for JPEG, PNG, GIF, WebP, AVIF and BMP; an SVG fails. Rule.dimensions() builds the rule:
const rule = Rule.dimensions().maxWidth(1000).maxHeight(500).ratio(3 / 2)distinct#
On a list's items, none holds the same value as another: 'tags.*': 'distinct'. 1 and '1' count as the same;
distinct:strict compares exactly, and distinct:ignore_case ignores case.
doesnt_contain#
doesnt_contain:foo,bar: an array that holds none of the values. Rule.doesntContain([...]) does the same.
doesnt_end_with#
doesnt_end_with:foo,bar: text or a number that doesn't end with any of the values.
doesnt_start_with#
doesnt_start_with:foo,bar: text or a number that doesn't start with any of the values.
email#
An email address: one @ with text before it, no spaces, and a dot inside the part after it.
ends_with#
ends_with:foo,bar: text or a number that ends with one of the values.
enum#
One of an enum's values, with Rule.enum(). It takes a TypeScript enum, or an object of values as const, and
types the field with the enum's values:
enum ServerStatus { Active = 'active', Inactive = 'inactive',}const data = await request().validate({ status: ['required', Rule.enum(ServerStatus)],})const status: ServerStatus = data.statusonly() and except() narrow the values:
Rule.enum(ServerStatus).only([ServerStatus.Active])A numeric enum takes its numbers, or exactly their text, '1' but not '01', and its field is typed as either.
exclude#
The field is left out of the validated data, and the rules after it don't run.
exclude_if#
exclude_if:anotherfield,value: the field is left out when the other field holds the value, and nothing happens
while the other field is missing. Write the exclude rules first: the rules before one run before it decides.
const validator = Validator.make(data, { has_appointment: 'required|boolean', appointment_date: 'exclude_if:has_appointment,false|required|date', doctor_name: 'exclude_if:has_appointment,false|required|string',})exclude_unless#
exclude_unless:anotherfield,value: the field is left out unless the other field holds the value.
exclude_with#
exclude_with:anotherfield: the field is left out when the other field's key is there.
exclude_without#
exclude_without:anotherfield,...: the field is left out when one of the others is missing or empty.
exists#
exists:table,column: a row of the table holds the value in the column. Without a column, the field's name is the
column, or its last part for a list's items (email for users.*.email). The query runs on the default
connection. Rule.exists('users', 'email') does the same. Laravel's rule also takes a model's class, a
connection.table and extra where conditions, which Oriel's doesn't.
extensions#
extensions:jpg,png: an uploaded file whose name ends with one of the extensions. It checks the name only: pair it
with mimes or image.
file#
An uploaded file.
filled#
When the key is there, its value isn't empty. A missing key passes.
gt#
gt:field: greater than the other field. Numbers, and the text of numbers, compare by value, and two values of one
kind (text, lists, files) by size; values of different kinds fail. gt:10 compares a numeric value with the number,
and any other value fails.
gte#
gte:field: greater than or equal to the other field, compared as gt compares.
hex_color#
A hexadecimal colour: #rgb, #rgba, #rrggbb or #rrggbbaa.
image#
An image, by its bytes: JPEG, PNG, GIF, WebP, AVIF or BMP. image:allow_svg accepts SVG too, which can carry
scripts.
in#
in:foo,bar: the value's text is one of those listed, so in:1,2 takes 1 and '1'. Rule.in([...]) does the
same, and types the field with its values. An object fails, and so does an array unless the field also has array;
then every item must be listed:
const data = await request().validate({ zones: 'required|array|in:first-zone,second-zone',})A comma always separates the values: Laravel's quoted values (in:"a,b",c) aren't read.
in_array#
in_array:anotherfield.*: the value is one of the other field's values.
in_array_keys#
in_array_keys:foo,bar: an object with at least one of the keys.
integer#
A whole number: 5, or text of digits with an optional minus, '5' or '-3'. integer:strict refuses text. The
field is typed NumericValue (number with :strict), and nothing is converted.
ip#
An IPv4 or IPv6 address.
ipv4#
An IPv4 address.
ipv6#
An IPv6 address.
json#
Text that is valid JSON: '{"name":"Taylor"}' or '5'.
list#
A list: a JavaScript array.
lowercase#
Text or a number with no upper case letters.
lt#
lt:field: less than the other field, compared as gt compares.
lte#
lte:field: less than or equal to the other field, compared as gt compares.
mac_address#
A MAC address: six pairs of hexadecimal digits joined all by - or all by :, or three groups of four joined by ..
max#
max:value: a size of value or less, measured as size measures it.
max_digits#
max_digits:value: only digits, at most value of them.
mimes#
mimes:jpg,pdf: an uploaded file of one of the types; jpg and jpeg are one. Oriel recognizes JPEG, PNG, GIF,
WebP, AVIF, BMP, SVG, PDF, HTML and XML by their bytes, and a file of one of these kinds passes only by its own type.
Plain text, also read from the bytes, passes txt, or its name's extension, such as csv or yaml, when the list
names it and it isn't one of the kinds above. A file of a kind the bytes don't show, such as a docx or an mp4,
passes by its name's extension, unless that extension names one of the kinds above or a text format such as csv.
An empty file fails.
mimetypes#
mimetypes:text/: an uploaded file whose type, read from its bytes, is listed, or matches a type/. A
file of a kind Oriel doesn't recognize is application/.
min#
min:value: a size of value or more, measured as size measures it.
min_digits#
min_digits:value: only digits, at least value of them.
missing#
The key isn't there. A key holding null is there.
missing_if#
missing_if:anotherfield,value,...: the key isn't there when the other field holds one of the values.
missing_unless#
missing_unless:anotherfield,value: the key isn't there unless the other field holds the value.
missing_with#
missing_with:foo,bar: the key isn't there when one of the other keys is.
missing_with_all#
missing_with_all:foo,bar: the key isn't there when all of the other keys are.
multiple_of#
multiple_of:value: a multiple of the number, in exact decimals: 0.3 is a multiple of 0.1.
not_in#
not_in:foo,bar: the value's text isn't one of those listed. Rule.notIn([...]) does the same. An array or an object
passes, unless the field has array; then it fails only when every item is listed. Pair not_in with string to
refuse a list, or use doesnt_contain to refuse a list holding any of the values.
not_regex#
not_regex:pattern: text or a number the pattern doesn't match.
nullable#
null passes the field's other rules. See a note on optional fields.
numeric#
A number, or text of one, as PHP's is_numeric() reads it: a sign, digits, a decimal point and an exponent, with
spaces around. Hexadecimal text and Infinity fail. numeric:strict refuses text. The field is typed
NumericValue, and nothing is converted.
present#
The key is there, whatever its value, null and empty text included.
present_if#
present_if:anotherfield,value,...: the key is there when the other field holds one of the values.
present_unless#
present_unless:anotherfield,value: the key is there unless the other field holds the value.
present_with#
present_with:foo,bar: the key is there when one of the other keys is.
present_with_all#
present_with_all:foo,bar: the key is there when all of the other keys are.
prohibited#
The field is missing or empty: null, blank text, [] or {}.
prohibited_if#
prohibited_if:anotherfield,value,...: the field is missing or empty when the other field holds one of the values.
Rule.prohibitedIf(condition) prohibits the field when the condition, or the function you give, is true.
prohibited_if_accepted#
prohibited_if_accepted:anotherfield: the field is missing or empty when the other field is accepted.
prohibited_if_declined#
prohibited_if_declined:anotherfield: the field is missing or empty when the other field is declined.
prohibited_unless#
prohibited_unless:anotherfield,value,...: the field is missing or empty unless the other field holds one of the
values.
prohibits#
prohibits:anotherfield,...: when the field has a value, the others are missing or empty.
regex#
regex:pattern: text the pattern matches, written as a JavaScript regular expression, with or without slashes and
flags: regex:/. A pattern with a | must go in a list of rules, as a rule string splits on |.
required#
The field is there and not empty: not null, not text made only of spaces, not [] and not {}. 0 and false
pass. Rule.requiredIf(condition) requires the field when the condition, or the function you give, is true.
required_array_keys#
required_array_keys:foo,bar: an object with every one of the keys. A list fails, [] included.
required_if#
required_if:anotherfield,value,...: required when the other field holds one of the values. true and false
match a boolean, and '1' and '0' too when the other field has the boolean rule; null matches a null field.
Nothing is required while the other field is missing.
required_if_accepted#
required_if_accepted:anotherfield: required when the other field is accepted.
required_if_declined#
required_if_declined:anotherfield: required when the other field is declined.
required_unless#
required_unless:anotherfield,value,...: required unless the other field holds one of the values.
required_with#
required_with:foo,bar: required when one of the other fields has a value.
required_with_all#
required_with_all:foo,bar: required when all of the other fields have a value.
required_without#
required_without:foo,bar: required when one of the other fields is missing or empty.
required_without_all#
required_without_all:foo,bar: required when all of the other fields are missing or empty.
same#
same:field: the same value as the other field, compared exactly.
size#
size:value: a size of exactly value. What a size is depends on the field:
- with
numeric,integerordecimalamong its rules, the number itself; - for a list, its number of items, and for an object, its number of keys;
- for an uploaded file, its size in kilobytes;
- for anything else, its length in characters. A number without a numeric rule is measured by its text:
150is 3.
sometimes#
The field's rules run only when its key is there: email: 'sometimes|required|email' checks an email that is sent,
and lets a request leave it out.
starts_with#
starts_with:foo,bar: text or a number that starts with one of the values.
string#
A string.
timezone#
A time zone name, in its own case: Europe/, not europe/. Without a parameter, a zone in one of PHP's
regions, or UTC. timezone:Europe takes that region's zones, timezone:utc only UTC, timezone:all_with_bc any
name the runtime knows (US/ too), and timezone:per_country,US the zones of a country.
ulid#
A ULID.
unique#
unique:table,column: no row of the table holds the value in the column. Without a column, the field's name is the
column, or its last part for a list's items. To ignore one row, the user's own when they update their profile, use
Rule.unique():
const data = await request().validate({ email: ['required', 'email', Rule.unique('users').ignore(user)],})ignore() takes a model or an id, and the id's column as its second argument. The string rule takes them as
unique:users,email,5,id. The query runs on the default connection. Laravel's rule also takes a model's class, a
connection.table and extra where conditions, which Oriel's doesn't.
uppercase#
Text or a number with no lower case letters.
url#
A URL: one of Laravel's schemes followed by :// and a host, so javascript: and mailto: URLs fail.
url:http,https allows only those schemes.
uuid#
A UUID of any version. uuid:4 takes version 4 only; the versions are 1 to 8, nil (or 0) and max.
Conditionally adding rules#
Skipping validation when fields have certain values#
exclude_if and exclude_unless leave a field out of validation, and of the validated data, depending on another
field (see exclude_if).
Validating when present#
sometimes validates a field only when the request sends it:
const validator = Validator.make(data, { email: 'sometimes|required|email',})Complex conditional validation#
Rule.when() chooses a field's rules from the data: its function receives the data, and the rules apply when it
returns true. A third argument gives the rules for false, and Rule.unless() is the opposite:
const data = await request().validate({ games: 'required|integer|min:0', reason: [Rule.when((input) => Number(input.games) >= 100, 'required|max:500', 'nullable')],})Laravel's validator also has a sometimes() method that adds rules after the validator is made. Oriel's doesn't:
give the field a Rule.when().
Validating arrays#
array with keys, array:name,username, checks that an object has only the keys you name. Without one, an object
fails array (see array):
const validator = Validator.make(input, { user: 'array:name,username',})Validating nested array input#
Dots name the fields inside an object, and * every item of a list or every value of an object:
const validator = Validator.make(input, { 'photos.profile': 'required|image', 'users.*.email': 'email|unique:users', 'users.*.first_name': 'required_with:users.*.last_name',})Here required_with fills the * of users.*.last_name with the item's index, so each user's first_name is
checked against the same user's last_name. The rules that compare two fields, such as same, gt, after and the
_if and _with rules, do the same; in_array reads every item. Errors are named by the item, as users.2.email.
Laravel's :index and :position placeholders, and Rule::forEach(), aren't in Oriel.
Validating files#
The file rules check an uploaded file: file, image, mimes, mimetypes, extensions and dimensions, with
size, min, max and between in kilobytes:
const data = await request().validate({ photo: ['required', 'image', 'max:2048', Rule.dimensions().maxWidth(1000).maxHeight(500)],})image, mimes and mimetypes judge a file by its bytes, not by the type the browser sent; mimes reads the
name only for plain text and for the kinds the bytes don't show (see mimes). A file named like a PHP
script, such as .php or .phtml, fails image, and fails mimes, mimetypes and extensions unless they list
php. A body larger than the body size limit is refused before your rules run.
Laravel's File::types() builder isn't in Oriel: write the rules.
Validating passwords#
Rule.password() checks a password against your app's password rule: by default, text of at least 8 characters.
PasswordRule builds a stricter one:
// Require at least 8 characters...PasswordRule.min(8)// Require at least one letter...PasswordRule.min(8).letters()// Require at least one uppercase and one lowercase letter...PasswordRule.min(8).mixedCase()// Require at least one number...PasswordRule.min(8).numbers()// Require at least one symbol...PasswordRule.min(8).symbols()max() sets a maximum length. Each check that fails adds its own message.
Defining default password rules#
PasswordRule.defaults() sets the rule Rule.password() returns. Call it in a service provider's boot():
import { app, PasswordRule, ServiceProvider } from '@orieljs/framework'export class AppServiceProvider extends ServiceProvider { override boot(): void { PasswordRule.defaults(() => { const rule = PasswordRule.min(8) return app().environment('production') ? rule.mixedCase().numbers() : rule }) }}Then Rule.password() returns that rule wherever you use it, as the starter kits do:
const data = await request().validate({ password: ['required', 'confirmed', Rule.password()],})Laravel's rule is the Password class; Oriel's Password is the password broker, so the rule is PasswordRule.
Laravel's uncompromised(), which asks an outside service whether a password has leaked, and rules() aren't in
Oriel.
Custom validation rules#
Rule objects#
A rule object is a class with a validate() method. make:rule writes one in app/:
node oriel make:rule Uppercasevalidate() receives the field's name, its value, a fail() function to call with a message, and all of the data
under validation:
import type { ValidationRule } from '@orieljs/framework/contracts'export class Uppercase implements ValidationRule { validate(attribute: string, value: unknown, fail: (message: string) => void): void { if (typeof value !== 'string' || value.toUpperCase() !== value) { fail(`The ${attribute} field must be uppercase.`) } }}Put an instance in the field's list of rules:
import { Uppercase } from './app/Rules/Uppercase'const data = await request().validate({ name: ['required', 'string', new Uppercase()],})validate() may be async. :attribute in the message becomes the field's name, as in the built-in messages, and
your custom attribute names apply to it. Each call to fail() adds a message. The fourth argument, the data,
replaces Laravel's DataAwareRule.
A rule object doesn't change the field's type unless it says what passes. Declare a __typing field, which is
never set:
import type { TypedValidationRule } from '@orieljs/framework/contracts'export class Uppercase implements TypedValidationRule<{ value: string }> { declare readonly __typing?: { value: string } validate(attribute: string, value: unknown, fail: (message: string) => void): void { if (typeof value !== 'string' || value.toUpperCase() !== value) { fail(`The ${attribute} field must be uppercase.`) } }}Using closures#
A rule you need once can be an object with a validate() method, written in place:
const validator = Validator.make(request().all(), { title: [ 'required', 'max:255', { validate(attribute: string, value: unknown, fail: (message: string) => void) { if (value === 'foo') { fail(`The ${attribute} is invalid.`) } }, }, ],})Laravel takes a closure in the list of rules; Oriel's list takes strings and rule objects, so the closure becomes an
object's validate().
Registering string rules#
Validator.extend() registers a rule you write as a string, with its message, in a service provider's boot(). Its
function receives the field's name, its value, the rule's parameters and the data, and returns whether the value
passes:
import { ServiceProvider, Validator } from '@orieljs/framework'export class AppServiceProvider extends ServiceProvider { override boot(): void { Validator.extend( 'slug', (attribute, value) => typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value), 'The :attribute field must be a slug.', ) }}The rule's message fills :attribute, and custom messages replace it as they replace a built-in rule's. A rule
registered with a built-in rule's name replaces the built-in rule. To type the fields that pass the rule, add it to
ValidationRuleTypes in a declaration file:
import type {} from '@orieljs/framework/contracts'declare module '@orieljs/framework/contracts' { interface ValidationRuleTypes<Parameters extends string = string> { slug: { value: string } }}export {}Implicit rules#
Rule objects and the rules of Validator.extend() don't run on a field that is missing or blank text, as most
built-in rules don't. Laravel's implicit rules, which run on such a field too, aren't in Oriel: combine your rule with
required.
Rules that don't apply to Node#
Some of Laravel's rules check what PHP does, and have no counterpart on Node and Bun:
email's styles:rfc,strict,spoof,filterandfilter_unicodeare the checks of PHP'segulias/andemail-validator filter_var(). Oriel'semailaccepts them and doesn't act on them (seeemail).- The
uploadedfailure: Laravel fails a file PHP's own body parser couldn't receive whole. Oriel reads the body itself, so a file that arrives is whole. mimesandmimetypes: they judge the bytes with Oriel's own detector, not PHP'sfinfo. A file of a kind Oriel doesn't recognize passesmimesby its name's extension, and isapplication/tooctet-stream mimetypes(seemimes).
Others apply, with the runtime's data:
timezonechecks against the runtime's time zone data, not PHP's lists (seetimezone).active_urlasks the system's resolver (seeactive_url).date_formatreads the format characters listed indate_format; PHP's others are an error.
And some aren't in Oriel yet: email:dns, encoding, Password::uncompromised(), Laravel's quoted parameters
(in:"a,b",c), and the builders Rule::forEach(), Rule::date(), Rule::email(), Rule::numeric(),
Rule::file(), Rule::imageFile(), Rule::anyOf() and Rule::can().