Validation
Guren's primary validation path is schema-first. Use Zod-compatible schemas in controllers, route definitions, or middleware so request parsing and type inference stay in one place. A legacy FormRequest compatibility layer still exists for migrations.
Validation
Guren's primary validation path is schema-first. Use Zod-compatible schemas in controllers, route definitions, or middleware so request parsing and type inference stay in one place. A legacy FormRequest compatibility layer still exists for migrations.
Supported Zod version: the zod 4 API only. Runtime validation duck-types any schema with
safeParse, but the tools that read schemas structurally —guren codegen, OpenAPI generation,guren context— refuse schemas authored with the zod v3 API (the oldzod@3package or thezod/v3subpath) and say so with a warning. Author schemas withimport { z } from 'zod'.
Quick Start
Use controller validation helpers for the mainline path:
import { Controller, paginate } from '@guren/core'
import { z } from 'zod'
import { Post } from '@/app/Models/Post'
import { PostResource } from '@/app/Http/Resources/PostResource'
import { pages } from '@/.guren/pages.gen'
const StorePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
})
const PageQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
})
export default class PostsController extends Controller {
async index() {
const { page } = this.validateQuery(PageQuerySchema)
const result = await Post.paginate({ page, perPage: 10 })
const paginator = paginate(result, { path: this.request.path ?? '/posts' })
return this.inertia(pages.posts.Index, {
data: result.data.map((post) => new PostResource(post).toJSON()),
pagination: {
meta: paginator.meta(),
links: paginator.links(),
},
})
}
async store() {
const data = await this.validateBody(StorePostSchema)
const post = await Post.create(data)
return this.created({ post: new PostResource(post).toJSON() })
}
}
Controller Validation Helpers
The simplest way to validate in controllers is with validateBody, validateQuery, and validateParams. They accept any Zod-like schema (anything with safeParse()) and throw ValidationException (422) on failure:
import { Controller } from '@guren/core'
import { z } from 'zod'
const StorePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
})
const PostIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
})
const PageQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
})
export default class PostsController extends Controller {
async index() {
const { page } = this.validateQuery(PageQuerySchema)
const posts = await Post.paginate({ page })
return this.json(posts)
}
async show() {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findOrFail(id)
return this.json(post)
}
async store() {
const data = await this.validateBody(StorePostSchema)
const post = await Post.create(data)
return this.created({ post })
}
}
| Helper | Input Source | Async | Description |
|---|---|---|---|
this.validateBody(schema) |
Request body | Yes | Parses JSON or form body |
this.validateQuery(schema) |
Query string | No | Parses ?page=1&sort=desc |
this.validateParams(schema) |
Route params | No | Parses :id, :slug, etc. |
ok
These helpers work with any schema library that implements safeParse() — Zod, Valibot, or custom validators.
Array-Style Query Parameters
Repeated query keys reach your schema as arrays: ?tag=a&tag=b becomes { tag: ['a', 'b'] }. A key that appears only once stays a plain string, so use union when a parameter may appear one or more times:
const FilterQuerySchema = z.object({
// ?tag=a&tag=b -> ['a', 'b'] / ?tag=a -> 'a'
tag: z.union([z.string(), z.array(z.string())]).optional()
.transform((value) => (typeof value === 'string' ? [value] : value ?? [])),
})
This applies to this.validateQuery() and to query: schemas attached via route contracts.
Middleware Validation
validateRequest(schema) Compatibility Middleware
Factory that creates validation middleware:
import { Router, validateRequest } from '@guren/core'
import { z } from 'zod'
const schema = z.object({
email: z.email(),
password: z.string().min(8),
})
const router = new Router()
router.post('/login', [AuthController, 'login'], validateRequest(schema))
By default, validation errors return a 422 response with error details.
validateRequestWith(schemaFactory)
For dynamic schemas based on request context:
import { Router, validateRequestWith } from '@guren/core'
const router = new Router()
router.put('/users/:id', [UserController, 'update'], validateRequestWith((ctx) => {
const isAdmin = ctx.get('user')?.role === 'admin'
return z.object({
name: z.string().min(1),
email: z.email(),
// Only admins can change roles
role: isAdmin ? z.enum(['user', 'admin']) : z.never().optional(),
})
}))
Getting Validated Data
After validation middleware runs, retrieve typed data with getValidatedData():
import { getValidatedData } from '@guren/core'
import type { z } from 'zod'
import { Router } from '@guren/core'
const router = new Router()
router.post('/posts', async (ctx) => {
const data = getValidatedData<z.infer<typeof createPostSchema>>(ctx)
// TypeScript knows the exact shape
console.log(data.title) // string
console.log(data.content) // string
console.log(data.published) // boolean
return ctx.json({ post: await Post.create(data) })
}, validateRequest(createPostSchema))
Manual Validation
For validation outside middleware, use validate() or validateSafe():
import { validate, validateSafe } from '@guren/core'
// Throws on validation failure
const data = validate(schema, requestData)
// Returns result object (never throws)
const result = validateSafe(schema, requestData)
if (result.success) {
console.log(result.data)
} else {
console.log(result.error)
}
Custom Error Handling
Override the default error response:
validateRequest(schema, {
onError: (ctx, error) => {
// Custom error format
return ctx.json({
message: 'Validation failed',
errors: error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
}, 422)
},
})
Schema Interface
Guren's validation is schema-library agnostic. Any object implementing ValidationSchema works:
interface ValidationSchema<T> {
parse(data: unknown): T
safeParse(data: unknown): { success: true; data: T } | { success: false; error: unknown }
}
This means you can use Zod, Valibot, or custom validators:
// With Valibot
import * as v from 'valibot'
import { Router } from '@guren/core'
const schema = v.object({
name: v.string([v.minLength(1)]),
email: v.string([v.email()]),
})
const router = new Router()
router.post('/users', handler, validateRequest(schema))
Common Patterns
Nested Objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
postalCode: z.string().regex(/^\d{5}$/),
})
const userSchema = z.object({
name: z.string(),
address: addressSchema,
})
Arrays
const schema = z.object({
tags: z.array(z.string()).min(1).max(10),
items: z.array(z.object({
productId: z.number(),
quantity: z.number().positive(),
})),
})
Optional with Defaults
const schema = z.object({
page: z.coerce.number().positive().default(1),
perPage: z.coerce.number().positive().max(100).default(20),
sortBy: z.enum(['created', 'updated', 'name']).default('created'),
})
Transformations
const schema = z.object({
email: z.email().toLowerCase(),
tags: z.string().transform(s => s.split(',').map(t => t.trim())),
date: z.string().transform(s => new Date(s)),
})
Refinements
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
})
Form Validation Errors
When validation fails, the default response format is:
{
"error": "Validation failed",
"issues": [
{
"path": ["email"],
"message": "Invalid email"
},
{
"path": ["password"],
"message": "String must contain at least 8 character(s)"
}
]
}
Automatic Handling on Inertia Requests
When a ValidationException is thrown during an Inertia request (one carrying the X-Inertia header), Guren does not return the JSON payload above. Instead it behaves like Laravel: the errors are flashed to the session and the request is redirected back (303) to the previous page. On the next page load the flashed errors are injected into the shared errors prop, flattened to one message per field:
function Login({ errors }: { errors?: Record<string, string> }) {
return (
<form>
<input name="email" />
{errors?.email && <span className="error">{errors.email}</span>}
</form>
)
}
This applies to validateBody / validateQuery / validateParams failures as well as ValidationException.withMessages(...) thrown from your own code. Flashing requires session middleware, which is mounted automatically when the auth option is set. To customize the behavior, register your own renderer for ValidationException in a service provider — it takes precedence over the built-in one.
Displaying in Inertia
Use page definitions with ValidationErrors<T> so controller and component share the same error shape.
import { type ValidationErrors } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
type CreateUserFields = 'email' | 'password'
type CreateUserProps = {
errors?: ValidationErrors<CreateUserFields>
}
async store() {
const result = await this.validateBodySafe(schema)
if (!result.success) {
return this.inertia<CreateUserProps>(pages.users.Create, {
errors: result.errors,
})
}
await User.create(result.data)
return this.redirect('/users')
}
import type { PageProps } from '@guren/inertia-client'
import { pages } from '@/.guren/pages.gen'
type Props = PageProps<typeof pages.users.Create>
function CreateUser({ errors }: Props) {
return (
<form>
<input name="email" />
{errors?.email && <span className="error">{errors.email}</span>}
<input name="password" type="password" />
{errors?.password && <span className="error">{errors.password}</span>}
</form>
)
}
Type-Safe Request Parsing
For complete type safety, combine with request parsing:
import { Router, parseRequestPayload, validateRequest, getValidatedData } from '@guren/core'
const schema = z.object({
title: z.string(),
content: z.string(),
})
const router = new Router()
router.post('/posts', async (ctx) => {
const data = getValidatedData<z.infer<typeof schema>>(ctx)!
// Fully typed, validated data ready to use
return ctx.json({ post: await Post.create(data) })
}, validateRequest(schema))
Compiled Parsing (zod 4.5+)
zod 4.5 can compile schemas into a generated fast path. Apps scaffolded with create-guren-app enable it out of the box: the first import in src/app.ts is
import 'zod/compile'
Every schema built after that import parses through the compiled path automatically. Because Guren's validation helpers, route contracts (params, query, body, output), and validation middleware all call your schema's own parse/safeParse, they speed up without any further changes.
To adopt it in an existing app, move your zod dependency to ^4.5.0 and add the import as the first line of your entry module, before any module that defines schemas.
In our benchmarks on Bun, validating a 100-item list output dropped from about 19µs to 1.2µs, and a five-field body parsed about four times faster. End to end, an API endpoint returning a validated 100-item list served roughly 10% more requests per second at a lower median latency. Your numbers will scale with how much of a route's time is spent validating.
Three things to know:
- Import order matters. Schemas built before the import keep the regular parser. Keep the import first in the entry module.
- Restricted runtimes are handled. Call
z.config({ jitless: true })if your runtime forbids generated code (strict CSP); compilation is skipped and everything still works. Unsupported schema features silently keep the regular parser too — compilation never throws. - Keep refinements side-effect free. On invalid input,
.refine()and.transform()callbacks can run twice (the fast path first, then the fallback that builds the full error). Validation results are unchanged, but side effects inside those callbacks would double.
Schema-agnostic validation is unaffected: Valibot or custom validators simply keep their own parse/safeParse behavior.