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.ts
src/requests/CreatePostRequest.ts
import { 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.

src/controllers/PostController.ts
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:

src/requests/UpdatePostRequest.ts
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'),
})