Controllers
Controllers are where your application logic lives. They receive HTTP requests, interact with models and services, and return responses. Think of them as the glue between what the user asks for and what your app delivers.
Controllers
Controllers are where your application logic lives. They receive HTTP requests, interact with models and services, and return responses. Think of them as the glue between what the user asks for and what your app delivers.
Your First Controller
Generate a controller with the CLI, then add a couple of methods:
bunx guren make:controller PostsController
// app/Http/Controllers/PostsController.ts
import { Controller, paginate, type PaginatedPageProps } from '@guren/core'
import { Post } from '@/app/Models/Post'
import { PostResource, type PostResourceData } from '@/app/Http/Resources/PostResource'
import { ListPostsQuerySchema, PostIdParamSchema } from '@/app/Http/Validators/PostValidator'
import { pages } from '@/.guren/pages.gen'
type PostsIndexProps = PaginatedPageProps<PostResourceData>
export default class PostsController extends Controller {
async index() {
const { page } = this.validateQuery(ListPostsQuerySchema)
const result = await Post.paginate({ page, perPage: 10, orderBy: ['id', 'desc'] })
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(),
},
} satisfies PostsIndexProps)
}
async show() {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findOrFail(id)
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
}
Wire it up in routes/web.ts:
import { Router } from '@guren/core'
import PostsController from '@/app/Http/Controllers/PostsController'
export function registerWebRoutes(router: Router): void {
router.get('/posts', [PostsController, 'index'])
router.get('/posts/:id', [PostsController, 'show'])
}
That is all it takes. The [Controller, 'method'] tuple tells Guren which class to instantiate and which method to call for each request.
Responding to Requests
Controllers provide helpers for every common response type:
export default class PostsController extends Controller {
async index() {
const posts = await Post.all()
return this.json(posts) // 200 JSON
}
async show() {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findOrFail(id)
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() }) // Inertia page
}
async store() {
const data = await this.validateBody(StorePostSchema)
const post = await Post.create(data)
return this.created({ post: new PostResource(post).toJSON() }) // 201 JSON
}
async update() {
await Post.update({ id: 1 }, { title: 'Updated' })
return this.redirect('/posts') // 302 redirect
}
async destroy() {
await Post.delete({ id: 1 })
return this.noContent() // 204 empty
}
}
| Helper | Status | Description |
|---|---|---|
this.json(data) |
200 | Return JSON |
this.inertia(component, props) |
200 | Render an Inertia page |
this.created(data) |
201 | JSON with 201 status |
this.accepted(data) |
202 | JSON with 202 status |
this.redirect(url) |
302 | HTTP redirect |
this.noContent() |
204 | Empty response |
Reading Input
Controllers parse both JSON and form-encoded bodies automatically:
async store() {
// Read a single field
const title = await this.input('title')
// Query parameters (synchronous)
const page = this.query('page', '1')
// Canonical schema-first body parsing
const data = await this.validateBody(StorePostSchema)
// Check if a field exists
if (await this.has('email')) {
// ...
}
}
Tip
Body-reading methods (input, has, validateBody) are async because they parse the request body. The query method reads URL parameters and is synchronous.
Validation
Zod Schema Helpers (Recommended)
The simplest approach is to use validateBody, validateQuery, and validateParams with Zod schemas directly in your controller. They accept any object with a safeParse() method (Zod, Valibot, etc.) and throw a ValidationException (422) on failure:
import { Controller } from '@guren/core'
import { z } from 'zod'
import { Post } from '@/app/Models/Post'
const PostIdParamSchema = z.object({ id: z.coerce.number().int().positive() })
const StorePostSchema = z.object({ title: z.string().min(1), 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) // throws 422
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 show() {
const { id } = this.validateParams(PostIdParamSchema) // throws 422
const post = await Post.findOrFail(id) // throws 404
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
async store() {
const data = await this.validateBody(StorePostSchema) // throws 422
const user = await this.auth.userOrFail() // throws 401
const post = await Post.create({ ...data, authorId: user.id })
return this.redirect('/posts')
}
}
| Helper | Input Source | Async |
|---|---|---|
this.validateBody(schema) |
Request body (JSON / form) | Yes |
this.validateQuery(schema) |
URL query parameters | No |
this.validateParams(schema) |
Route parameters (:id, etc.) |
No |
All three throw ValidationException (HTTP 422) on failure, which the ExceptionHandler renders automatically.
Dependency Injection
When your controller needs services (caching, events, mail), declare them with static inject and Guren resolves them from the container:
import { Controller } from '@guren/core'
import type { CacheManager, EventManager } from '@guren/core'
import { Post } from '@/app/Models/Post'
export default class PostsController extends Controller {
static inject = ['cache', 'events'] as const
constructor(
private cache: CacheManager,
private events: EventManager,
) {
super()
}
async index() {
const cached = await this.cache.get('posts:all')
if (cached) return this.json(cached)
const posts = await Post.all()
await this.cache.put('posts:all', posts, 300)
return this.json(posts)
}
}
The as const assertion on inject ensures type safety. Each string maps to a key registered in the service container.
Resource Controllers
Instead of defining seven routes by hand, use router.resource():
router.resource('/posts', PostsController)
This generates:
| Method | Path | Controller Method |
|---|---|---|
| GET | /posts |
index |
| GET | /posts/create |
create |
| POST | /posts |
store |
| GET | /posts/:id |
show |
| GET | /posts/:id/edit |
edit |
| PUT | /posts/:id |
update |
| DELETE | /posts/:id |
destroy |
Only methods that exist on your controller are registered. Limit the routes with only or except:
router.resource('/posts', PostsController, { only: ['index', 'show'] })
Testing Controllers
TestApp boots a lightweight instance of your app for expressive HTTP testing:
import { TestApp } from '@guren/testing'
const app = await TestApp.create()
// Basic request assertions
await app.get('/posts').assertOk()
await app.post('/posts', { title: 'New' }).assertStatus(201)
// Authenticated requests
await app.actingAs(user).get('/dashboard').assertOk()
// JSON structure assertions
await app.get('/api/posts')
.assertOk()
.assertJsonCount(5, 'data')
.assertJsonPath('data.0.title', 'Hello')
Tip
Controllers stay thin when they delegate business logic to models or services. Treat them as an orchestration layer that glues your app together.