Validation & DTOs
Learn how to validate incoming data with FormRequest classes, Zod schemas, and contract schemas that share types across your entire stack.
Contract Schemas — Single Source of Truth
Instead of defining Zod schemas inline in FormRequests and types separately on the frontend, define them once in a contract schema file. Every layer imports from it.
npx svelar make:schema Post --module=posts
This creates src/lib/modules/posts/post.schema.ts:
import { z } from 'zod';
// ── Response schema (what the API returns) ──────────────────
export const postSchema = z.object({
id: z.number(),
title: z.string(),
slug: z.string(),
body: z.string(),
published: z.boolean(),
author: z.string(),
created_at: z.string(),
});
// ── Input schemas (what the API accepts) ────────────────────
export const createPostSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(255),
slug: z.string().regex(/^[a-z0-9-]+$/).optional(),
body: z.string().min(10, 'Body must be at least 10 characters'),
published: z.boolean().optional().default(false),
});
export const updatePostSchema = createPostSchema.partial();
// ── Inferred types — shared between server and frontend ─────
export type PostData = z.infer<typeof postSchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
Now every layer imports from this one file — zero type duplication:
| Layer | Imports | Uses |
|---|---|---|
| FormRequest | createPostSchema |
Validation rules |
| Resource | PostData |
Resource<Post, PostData> output shape |
| Controller | nothing extra | data typed automatically |
| Frontend | PostData, CreatePostInput |
Type-safe forms and responses |
FormRequest Classes (DTOs)
FormRequest classes encapsulate validation logic and authorization checks. They import their schema from the contract file.
Creating a FormRequest
npx svelar make:request CreatePost --module=posts
Wire it to the contract schema:
// src/lib/modules/posts/CreatePostRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { createPostSchema } from './post.schema.js';
export class CreatePostRequest extends FormRequest {
rules() {
return createPostSchema;
}
authorize(event: any): boolean {
return !!event.locals.user;
}
passedValidation(data: any) {
if (!data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
return data;
}
}
The update request reuses the same schema with .partial():
// src/lib/modules/posts/UpdatePostRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { updatePostSchema } from './post.schema.js';
export class UpdatePostRequest extends FormRequest {
rules() {
return updatePostSchema;
}
async authorize(event: any): Promise<boolean> {
const post = await Post.find(event.params.id);
return post?.user_id === event.locals.user?.id;
}
}
Using FormRequest in Controllers
import { CreatePostRequest } from './CreatePostRequest.js';
import { PostResource } from './PostResource.js';
export class PostController extends Controller {
async store(event: any) {
// Validate and authorize — throws 422 or 403 on failure
// data is typed as CreatePostInput
const data = await CreatePostRequest.validate(event);
const post = await Post.create({
...data,
user_id: event.locals.user.id,
});
return PostResource.make(post).status(201).toResponse();
}
}
Using Types on the Frontend
// +page.svelte or any client component
import type { PostData, CreatePostInput } from '$lib/modules/posts/post.schema';
import { apiFetchJson } from '@beeblock/svelar/http';
// Form data is typed — IDE catches missing fields
let form: CreatePostInput = {
title: '',
body: '',
published: false,
};
// Response is typed — autocomplete on data.title, data.slug, etc.
const { data } = await apiFetchJson<{ data: PostData }>('/api/posts', {
method: 'POST',
body: JSON.stringify(form),
});
FormRequest Methods
rules()
Define Zod schema for validation. Prefer importing from a contract schema file:
// Recommended — import from contract schema
import { createUserSchema } from './user.schema.js';
rules() {
return createUserSchema;
}
Or define inline for simple cases:
rules() {
return z.object({
email: z.string().email(),
password: z.string().min(8),
password_confirmation: z.string(),
}).refine((data) => data.password === data.password_confirmation, {
message: 'Passwords do not match',
path: ['password_confirmation'],
});
}
messages()
Custom error messages for validation rules:
messages() {
return {
'email.invalid': 'Please enter a valid email address',
'password.too_small': 'Password must be at least 8 characters',
};
}
Localized Validation Messages
There are two approaches to localize validation messages with Paraglide.
Approach 1: In the contract schema (recommended — messages live with the schema):
// src/lib/modules/auth/user.schema.ts
import { z } from 'zod';
import * as m from '$lib/paraglide/messages';
export const registerSchema = z.object({
name: z.string().min(2, m.validation_name_min()),
email: z.string().email(m.validation_email_invalid()),
password: z.string().min(8, m.validation_password_min()),
password_confirmation: z.string(),
}).refine((data) => data.password === data.password_confirmation, {
message: m.validation_passwords_must_match(),
path: ['password_confirmation'],
});
// messages/en.json
{
"validation_name_min": "Name must be at least 2 characters",
"validation_email_invalid": "Please enter a valid email address",
"validation_password_min": "Password must be at least 8 characters",
"validation_passwords_must_match": "Passwords do not match"
}
// messages/es.json
{
"validation_name_min": "El nombre debe tener al menos 2 caracteres",
"validation_email_invalid": "Ingrese una direccion de correo valida",
"validation_password_min": "La contrasena debe tener al menos 8 caracteres",
"validation_passwords_must_match": "Las contrasenas no coinciden"
}
// messages/pt.json
{
"validation_name_min": "O nome deve ter pelo menos 2 caracteres",
"validation_email_invalid": "Insira um endereco de email valido",
"validation_password_min": "A senha deve ter pelo menos 8 caracteres",
"validation_passwords_must_match": "As senhas nao coincidem"
}
The FormRequest just imports the schema — messages are already localized:
import { registerSchema } from './user.schema.js';
export class RegisterRequest extends FormRequest {
rules() {
return registerSchema;
}
}
Approach 2: In the messages() override (useful when the schema is shared/generic):
import * as m from '$lib/paraglide/messages';
export class RegisterRequest extends FormRequest {
rules() {
return registerSchema; // schema with English defaults
}
messages() {
return {
'name.too_small': m.validation_name_min(),
'email.invalid_string': m.validation_email_invalid(),
'password.too_small': m.validation_password_min(),
'password_confirmation': m.validation_passwords_must_match(),
};
}
}
Localized Messages with Parameters
Paraglide messages support parameters for dynamic values:
// messages/en.json
{
"validation_min_length": "{field} must be at least {min} characters",
"validation_max_length": "{field} must not exceed {max} characters"
}
import * as m from '$lib/paraglide/messages';
export const createPostSchema = z.object({
title: z.string()
.min(3, m.validation_min_length({ field: m.field_title(), min: '3' }))
.max(255, m.validation_max_length({ field: m.field_title(), max: '255' })),
body: z.string()
.min(10, m.validation_min_length({ field: m.field_body(), min: '10' })),
});
The API returns localized field errors based on the user's detected locale:
// Response for Spanish user submitting invalid data
{
"message": "Validation failed",
"errors": {
"title": ["Titulo debe tener al menos 3 caracteres"],
"body": ["Contenido debe tener al menos 10 caracteres"]
}
}
authorize()
Check if user is authorized to make this request. Return false to throw FormAuthorizationError (403):
authorize(event: any): boolean {
// Only the user themselves can update their profile
return event.params.id === event.locals.user?.id;
}
passedValidation()
Transform data after successful validation:
passedValidation(data: any) {
// Slugify title if not provided
if (!data.slug) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
}
// Trim whitespace
data.name = data.name?.trim();
// Hash password
data.password = await Hash.make(data.password);
return data;
}
Complete Examples with Contract Schemas
Auth Module
// src/lib/modules/auth/user.schema.ts
import { z } from 'zod';
export const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
created_at: z.string(),
});
export const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
email: z.string().email('Please enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
password_confirmation: z.string(),
}).refine((data) => data.password === data.password_confirmation, {
message: 'Passwords do not match',
path: ['password_confirmation'],
});
export const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required'),
});
export type UserData = z.infer<typeof userSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
// src/lib/modules/auth/RegisterRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { registerSchema } from './user.schema.js';
export class RegisterRequest extends FormRequest {
rules() {
return registerSchema;
}
}
// src/lib/modules/auth/LoginRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { loginSchema } from './user.schema.js';
export class LoginRequest extends FormRequest {
rules() {
return loginSchema;
}
}
// Frontend — register form
import type { RegisterInput } from '$lib/modules/auth/user.schema';
let form: RegisterInput = {
name: '',
email: '',
password: '',
password_confirmation: '',
};
Posts Module
// src/lib/modules/posts/post.schema.ts
import { z } from 'zod';
export const postSchema = z.object({
id: z.number(),
title: z.string(),
slug: z.string(),
body: z.string(),
published: z.boolean(),
author: z.string(),
created_at: z.string(),
});
export const createPostSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(255),
slug: z.string().regex(/^[a-z0-9-]+$/).optional(),
body: z.string().min(10, 'Body must be at least 10 characters'),
published: z.boolean().optional().default(false),
});
export const updatePostSchema = createPostSchema.partial();
export type PostData = z.infer<typeof postSchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
// src/lib/modules/posts/CreatePostRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { createPostSchema } from './post.schema.js';
export class CreatePostRequest extends FormRequest {
rules() {
return createPostSchema;
}
authorize(event: any): boolean {
return !!event.locals.user;
}
passedValidation(data: any) {
if (!data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
return data;
}
}
// src/lib/modules/posts/UpdatePostRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { updatePostSchema } from './post.schema.js';
export class UpdatePostRequest extends FormRequest {
rules() {
return updatePostSchema;
}
async authorize(event: any): Promise<boolean> {
const post = await Post.find(event.params.id);
return post?.user_id === event.locals.user?.id;
}
}
// src/lib/modules/posts/PostResource.ts
import { Resource } from '@beeblock/svelar/routing';
import type { Post } from './Post.js';
import type { PostData } from './post.schema.js';
export class PostResource extends Resource<Post, PostData> {
toJSON(): PostData {
return {
id: this.data.id,
title: this.data.title,
slug: this.data.slug,
body: this.data.body,
published: this.data.published,
author: this.data.author_name,
created_at: this.data.created_at,
};
}
}
// src/lib/modules/posts/PostController.ts — thin controller
import { Controller } from '@beeblock/svelar/routing';
import { CreatePostRequest } from './CreatePostRequest.js';
import { UpdatePostRequest } from './UpdatePostRequest.js';
import { PostResource } from './PostResource.js';
export class PostController extends Controller {
async index(event: any) {
const page = Number(event.url.searchParams.get('page') ?? 1);
const result = await Post.query().paginate(page, 20);
return PostResource.paginate(result).toResponse();
}
async show(event: any) {
const post = await Post.findOrFail(event.params.id);
return PostResource.make(post).toResponse();
}
async store(event: any) {
const data = await CreatePostRequest.validate(event);
const post = await Post.create({ ...data, user_id: event.locals.user.id });
return PostResource.make(post).status(201).toResponse();
}
async update(event: any) {
const data = await UpdatePostRequest.validate(event);
const post = await Post.findOrFail(event.params.id);
await post.update(data);
return PostResource.make(post).toResponse();
}
}
The entire module's type contract lives in post.schema.ts. Change it once, TypeScript catches mismatches everywhere — controller, resource, FormRequests, and frontend.
Zod Validation Schema
Svelar uses Zod for schema validation. Here are common validation rules:
Strings
z.string() // String type
.min(2) // Minimum length
.max(255) // Maximum length
.email() // Valid email
.url() // Valid URL
.regex(/^[a-z]+$/) // Regex pattern
.includes('hello') // Must contain substring
.startsWith('https://') // Must start with
.toLowerCase() // Transform to lowercase
.optional() // Optional (can be undefined)
.default('value') // Default value
.nullable() // Can be null
Numbers
z.number()
.min(0) // Minimum value
.max(100) // Maximum value
.int() // Integer only
.positive() // Must be > 0
.negative() // Must be < 0
.multipleOf(5) // Must be multiple of 5
.optional()
.default(0)
Booleans
z.boolean()
.optional()
.default(false)
Arrays
z.array(z.string()) // Array of strings
.min(1) // At least 1 item
.max(10) // At most 10 items
.default([])
Objects
z.object({
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
})
Unions
z.union([z.string(), z.number()]) // String or number
Custom Validation
z.string()
.refine((val) => val.length > 3, { message: 'Too short' })
// Cross-field validation
z.object({
password: z.string().min(8),
password_confirmation: z.string(),
}).refine((data) => data.password === data.password_confirmation, {
message: 'Passwords do not match',
path: ['password_confirmation'],
})
Inline Validation in Controllers
For simple cases, you can validate directly in the controller using the validate helper:
import { validate, z } from '@beeblock/svelar/validation';
export class PostController extends Controller {
async store(event: any) {
const body = await event.request.json();
const schema = z.object({
title: z.string().min(3),
body: z.string().min(10),
});
const result = validate(schema, body);
if (!result.success) {
return this.json({ errors: result.errors }, 422);
}
return this.created(await Post.create(result.data));
}
}
Note:
validate(schema, data)is synchronous and returns{ success: true, data }or{ success: false, errors }. Theerrorsobject has field names as keys and arrays of error messages as values.
Laravel-like Validation Rules
Svelar provides a rules helper with named validators that map to common Laravel validation rules. These are convenience wrappers around Zod:
import { rules, z } from '@beeblock/svelar/validation';
const schema = z.object({
name: rules.required(), // z.string().min(1)
email: rules.email(), // z.string().email()
age: rules.integer(), // z.number().int()
bio: rules.string(10, 500), // z.string().min(10).max(500)
score: rules.number(0, 100), // z.number().min(0).max(100)
price: rules.between(1, 999), // z.number().min(1).max(999)
active: rules.boolean(), // z.boolean()
birthday: rules.date(), // z.coerce.date()
website: rules.url(), // z.string().url()
token: rules.uuid(), // z.string().uuid()
role: rules.enum(['admin', 'user']), // z.enum(['admin', 'user'])
tags: rules.array(z.string()), // z.array(z.string())
phone: rules.nullable(z.string()), // z.string().nullable()
nickname: rules.optional(z.string()), // z.string().optional()
slug: rules.regex(/^[a-z0-9-]+$/), // z.string().regex(...)
ip: rules.ip(), // IPv4 validation
config: rules.json(), // valid JSON string
min_age: rules.min(18), // z.number().min(18)
max_size: rules.max(1024), // z.number().max(1024)
});
Password Confirmation
// Validates that password and password_confirmation match
const passwordSchema = rules.confirmed('password');
// Equivalent to z.object({ password, password_confirmation }).refine(match)
The rules helper is entirely optional — you can always use Zod directly. It's provided for developers familiar with Laravel's named validation rules.
Validation Error Responses
When validation fails, a FormValidationError is thrown automatically. The error response looks like:
{
"message": "Validation failed",
"errors": {
"email": ["Email is required", "Must be a valid email"],
"password": ["Password must be at least 8 characters"]
}
}
Status code is 422 (Unprocessable Entity).
Authorization Error Responses
When authorize() returns false, a FormAuthorizationError is thrown:
{
"message": "Unauthorized",
"status": 403
}
Status code is 403 (Forbidden).
Full Stack Flow
Here's the complete flow from route to frontend using a contract schema:
post.schema.ts → CreatePostRequest → PostController → PostResource → Frontend
(contract) (validation) (thin) (response) (typed)
// 1. Contract — src/lib/modules/posts/post.schema.ts
export const createPostSchema = z.object({
title: z.string().min(3).max(255),
body: z.string().min(10),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type PostData = z.infer<typeof postSchema>;
// 2. FormRequest — src/lib/modules/posts/CreatePostRequest.ts
export class CreatePostRequest extends FormRequest {
rules() { return createPostSchema; }
authorize(event: any) { return !!event.locals.user; }
}
// 3. Resource — src/lib/modules/posts/PostResource.ts
export class PostResource extends Resource<Post, PostData> {
toJSON(): PostData { return { id: this.data.id, title: this.data.title, ... }; }
}
// 4. Controller — src/lib/modules/posts/PostController.ts
export class PostController extends Controller {
async store(event: any) {
const data = await CreatePostRequest.validate(event); // typed as CreatePostInput
const post = await Post.create({ ...data, user_id: event.locals.user.id });
return PostResource.make(post).status(201).toResponse();
}
}
// 5. Route — src/routes/api/posts/+server.ts
const ctrl = new PostController();
export const POST = ctrl.handle('store');
// 6. Frontend — src/routes/posts/new/+page.svelte
import type { CreatePostInput, PostData } from '$lib/modules/posts/post.schema';
let form: CreatePostInput = { title: '', body: '' };
const { data } = await apiFetchJson<{ data: PostData }>('/api/posts', {
method: 'POST',
body: JSON.stringify(form),
});
Nested JSON with Pivot Data
It's common for forms to submit a base model alongside related pivot data in a single request — for example, creating an order with its product line items. Svelar handles this naturally because Zod supports nested objects and arrays.
1. Define a nested schema
// src/lib/modules/orders/schemas.ts
import { z } from 'zod';
export const createOrderSchema = z.object({
// Base model fields
customer_name: z.string().min(1),
shipping_address: z.string().min(5),
notes: z.string().optional(),
// Pivot data (products with quantities)
products: z.array(z.object({
id: z.number(),
quantity: z.number().min(1),
price: z.number().positive(),
})).min(1, 'At least one product is required'),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
2. Create the FormRequest
// src/lib/modules/orders/CreateOrderRequest.ts
import { FormRequest } from '@beeblock/svelar/routing';
import { createOrderSchema } from './schemas';
export class CreateOrderRequest extends FormRequest {
rules() {
return createOrderSchema;
}
authorize(event: any): boolean {
return !!event.locals.user;
}
}
3. Handle in controller/service — split base fields from pivot data
// src/lib/modules/orders/OrderController.ts
import { Controller } from '@beeblock/svelar/routing';
import { CreateOrderRequest } from './CreateOrderRequest';
import { Order } from './Order';
export class OrderController extends Controller {
async store(event: any) {
const data = await CreateOrderRequest.validate(event);
// Create the base model (fillable ignores the nested 'products' key)
const order = await Order.create({
customer_name: data.customer_name,
shipping_address: data.shipping_address,
notes: data.notes,
user_id: event.locals.user.id,
});
// Attach pivot data with extra columns
const products = order.products(); // belongsToMany relationship
for (const item of data.products) {
await products.attach(item.id, {
quantity: item.quantity,
price: item.price,
});
}
return this.created(order);
}
}
4. The model relationship
// src/lib/modules/orders/Order.ts
import { Model } from '@beeblock/svelar/orm';
import { Product } from '../products/Product';
export class Order extends Model {
static table = 'orders';
static fillable = ['customer_name', 'shipping_address', 'notes', 'user_id'];
products() {
return this.belongsToMany(Product, 'order_product', 'order_id', 'product_id');
}
}
Key points
- Zod validates the full nested structure including arrays of objects, so everything is type-safe before it reaches your service layer.
Model.fillableprotects you — even if you pass the entire validated object toModel.create(), only whitelisted columns are inserted. The nestedproductskey is ignored.attach(id, pivotData)accepts extra pivot columns likequantityandprice.sync(ids)replaces all pivot records — useful for update endpoints where the frontend sends the full list.passedValidation(data)can restructure the payload if needed (e.g., compute totals, normalize data).
Update pattern with sync
For update endpoints where the frontend sends the complete list of related items:
async update(event: any) {
const data = await UpdateOrderRequest.validate(event);
const order = await Order.findOrFail(event.params.id);
order.fill({ customer_name: data.customer_name, shipping_address: data.shipping_address });
await order.save();
// Replace all product associations
const productIds = data.products.map((p) => p.id);
await order.products().sync(productIds);
return this.json(order);
}
Frontend example
const orderData: CreateOrderInput = {
customer_name: 'Alice',
shipping_address: '123 Main St',
products: [
{ id: 1, quantity: 2, price: 29.99 },
{ id: 5, quantity: 1, price: 49.99 },
],
};
const response = await apiFetch('/api/orders', {
method: 'POST',
body: JSON.stringify(orderData),
});
Best Practices
- Use contract schemas — Define Zod schemas + types once in
*.schema.ts, import everywhere - Use FormRequest for API requests — Encapsulates validation and authorization
- Import schemas, don't inline them —
rules() { return createPostSchema; }notrules() { return z.object({...}); } - Provide helpful error messages — Define them in the schema:
z.string().min(2, 'Too short') - Validate authorization in
authorize()— Keeps auth logic in one place - Transform data in
passedValidation()— Hash passwords, slugify fields, etc. - Keep controllers thin — Validate with FormRequest, respond with Resource, logic in Services/Actions
- Share types with the frontend —
import type { PostData } from '$lib/modules/posts/post.schema'
Next Steps
- Learn Controllers & Routing to use validation in handlers
- Explore Services & Actions for business logic
- Check Models & ORM to work with validated data
Svelar Validation & DTOs Guide © 2026