Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

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/web.ts: a GET route shows the form, and a POST route stores the post:

routes/web.ts
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:

app/Http/Controllers/PostController.ts
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).
app/Http/Controllers/PostController.ts
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:

resources/views/post/create.tsx
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/json 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:

resources/views/post/create.tsx
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

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>  )}

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:

app/Live/CreatePost.tsx
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/Http/Requests:

Terminal
node oriel make:request StorePostRequest

The class it writes has two methods: authorize(), which decides whether the signed-in user may make the request, and rules(), which returns the rules:

app/Http/Requests/StorePostRequest.ts
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:

app/Http/Controllers/PostController.ts
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.title

request().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.":

app/Http/Requests/UpdatePostRequest.ts
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:

app/Http/Requests/StorePostRequest.ts
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:

app/Http/Controllers/PostController.ts
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 } = validated

The 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.name
  • string, email, url, date and the other text rules make a string; file and image an UploadedFile.
  • required, accepted and declined put the key in the data and rule out null; present puts the key in. Without one of them, the key is optional.
  • nullable adds null, unless one of those rules, or filled, rules it out.
  • in:a,b lets the listed values through, and with array makes a list of them.
  • array is a list, unknown[], and array:name,email an object with those optional keys.
  • Dotted keys nest: author.name is { author: { name } }.
  • Rules that only check a value, such as max, confirmed or unique, change nothing, and a field without a type rule is unknown.

A few rules make a key optional, or keep it from being typed as a list:

  • sometimes skips a missing field, and exclude_if, exclude_unless, exclude_with and exclude_without can take a field out: with any of them the key is optional, even with required.
  • prohibited passes a missing or empty field, so its value is typed null | string | [] | Record<string, never>.
  • A * level whose parent has no array or list rule is typed V[] | Record<string, V>: the request may send an object there, and a sparse match keeps its indexes as keys. Give the parent array to 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/en/validation.php. 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#

exists, unique

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/19/2026, 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/2 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.status

only() 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/plain,video/*: 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/octet-stream.

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-z]+$/i. 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, integer or decimal among 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: 150 is 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/London, not europe/london. 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/Eastern 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():

app/Providers/AppServiceProvider.ts
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/Rules:

Terminal
node oriel make:rule Uppercase

validate() receives the field's name, its value, a fail() function to call with a message, and all of the data under validation:

app/Rules/Uppercase.ts
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:

app/Rules/Uppercase.ts
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:

app/Providers/AppServiceProvider.ts
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:

types/validation.d.ts
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, filter and filter_unicode are the checks of PHP's egulias/email-validator and filter_var(). Oriel's email accepts them and doesn't act on them (see email).
  • The uploaded failure: 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.
  • mimes and mimetypes: they judge the bytes with Oriel's own detector, not PHP's finfo. A file of a kind Oriel doesn't recognize passes mimes by its name's extension, and is application/octet-stream to mimetypes (see mimes).

Others apply, with the runtime's data:

  • timezone checks against the runtime's time zone data, not PHP's lists (see timezone).
  • active_url asks the system's resolver (see active_url).
  • date_format reads the format characters listed in date_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().

Privacy policy ·

esc