Validation
Pearl validates incoming request bodies using FormRequest classes backed by Zod. Define the shape you expect, call FormRequest.validate(ctx), and Pearl rejects invalid requests with a 422 automatically — your controller never sees bad data.
Create a FormRequest
Generate one with the CLI, then define a Zod schema on the schema property:
pearl make:request CreatePostRequest
# → src/requests/CreatePostRequest.tsimport { FormRequest } from '@pearl-framework/pearl'
import { z } from 'zod'
export class CreatePostRequest extends FormRequest {
schema = z.object({
title: z.string().min(3).max(255),
content: z.string().min(10),
published: z.boolean().optional().default(false),
})
async authorize(): Promise<boolean> {
return true // return false to send a 403 before validation runs
}
}Use in a controller
Call the static FormRequest.validate(ctx) method. The returned data object is fully typed from your Zod schema — no casting needed.
import { CreatePostRequest } from '../requests/CreatePostRequest.js'
async store(ctx: HttpContext) {
// Throws a 422 automatically if validation fails
const req = await CreatePostRequest.validate(ctx)
// req.data is typed: { title: string; content: string; published: boolean }
const [post] = await this.db.db
.insert(posts)
.values({ ...req.data, userId: ctx.user()!.id })
.returning()
ctx.response.created({ data: post })
}ValidationPipe middleware
Use ValidationPipe to validate before your handler runs — good for keeping handler code clean:
import { ValidationPipe } from '@pearl-framework/pearl'
router.post('/posts', createPost, [ValidationPipe(CreatePostRequest)])Validation error response
When validation fails, Pearl automatically returns a 422 Unprocessable Entity with this structure:
{
"message": "Validation failed",
"errors": {
"title": ["String must contain at least 3 character(s)"],
"content": ["Required"]
}
}Authorization
Override authorize() to enforce access control before validation runs. Returning false sends a 403 Forbidden immediately:
export class UpdatePostRequest extends FormRequest {
schema = z.object({
title: z.string().min(3).max(255),
content: z.string().min(10),
})
async authorize(): Promise<boolean> {
const user = this.ctx.user()
const post = await Post.find(db, this.ctx.request.param('id'))
return post?.userId === user?.id // only the author can update
}
}Common Zod validation rules
schema = z.object({
// Strings
name: z.string().min(2).max(100),
email: z.string().email(),
slug: z.string().regex(/^[a-z0-9-]+$/),
// Numbers
age: z.number().int().min(18).max(120),
price: z.number().positive(),
// Enums
role: z.enum(['admin', 'editor', 'viewer']),
status: z.enum(['draft', 'published']).default('draft'),
// Optional and nullable
bio: z.string().optional(), // may be absent
website: z.string().url().nullable(), // may be null
// Arrays
tags: z.array(z.string()).max(5).optional(),
ids: z.array(z.number().int()).nonempty(),
// Booleans
agree: z.boolean(),
notify: z.boolean().default(false),
})Custom error messages
schema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'You must be 18 or older to sign up'),
})