Controllers
As your app grows, putting all logic directly in route callbacks gets messy. A controller groups related handlers into a class, takes its dependencies through the constructor, and keeps your routes file focused on just the URL mapping.
Controllers are plain TypeScript classes — no decorators, no magic. Register one in your service provider and Pearl's IoC container will construct it for you.
Generate a controller
pearl make:controller PostController
# → src/controllers/PostController.ts
# With all resource methods pre-generated:
pearl make:controller Post --resourceWriting a controller
Declare dependencies as constructor arguments. The IoC container resolves and injects them — you never call new PostController(...) yourself. Note that ctx.request.body is a getter — no parentheses.
import type { HttpContext } from '@pearl-framework/pearl'
import { DatabaseManager } from '@pearl-framework/pearl'
import { eq } from '@pearl-framework/pearl'
import { posts } from '../schema/posts.js'
export class PostController {
constructor(private db: DatabaseManager) {}
// GET /posts
async index(ctx: HttpContext) {
const all = await this.db.db.select().from(posts)
ctx.response.json({ data: all })
}
// GET /posts/:id
async show(ctx: HttpContext) {
const id = Number(ctx.request.param('id'))
const [post] = await this.db.db
.select().from(posts).where(eq(posts.id, id))
if (!post) return ctx.response.notFound('Post not found')
ctx.response.json({ data: post })
}
// POST /posts — requires authentication
async store(ctx: HttpContext) {
const { title, content } = ctx.request.body as {
title: string
content: string
}
const user = ctx.user()!
const [post] = await this.db.db
.insert(posts)
.values({ title, content, userId: user.id })
.returning()
ctx.response.created({ data: post })
}
// DELETE /posts/:id — requires authentication
async destroy(ctx: HttpContext) {
const id = Number(ctx.request.param('id'))
await this.db.db.delete(posts).where(eq(posts.id, id))
ctx.response.noContent()
}
}Register in AppServiceProvider
Bind your controller as a singleton inside register(). Pearl uses this to know how to build it when you resolve it from the container.
import { PostController } from '../controllers/PostController.js'
// Inside register():
this.container.singleton(PostController, () =>
new PostController(
this.container.make(DatabaseManager)
)
)Wire to routes
Resolve the controller from the container after app.boot(), then pass its methods to the router:
const postCtrl = app.container.make(PostController)
const guard = [Authenticate(auth)]
router.get('/posts', (ctx) => postCtrl.index(ctx))
router.get('/posts/:id', (ctx) => postCtrl.show(ctx))
router.post('/posts', (ctx) => postCtrl.store(ctx), guard)
router.delete('/posts/:id', (ctx) => postCtrl.destroy(ctx), guard)Tip: move routes to their own file
For larger apps, extract route registration into a dedicated file to keep server.ts clean:
import type { Application } from '@pearl-framework/pearl'
import { Router, Authenticate, AuthManager } from '@pearl-framework/pearl'
import { PostController } from '../controllers/PostController.js'
export function registerRoutes(app: Application): Router {
const router = new Router()
const auth = app.container.make(AuthManager)
const guard = [Authenticate(auth)]
const postCtrl = app.container.make(PostController)
router.get('/posts', (ctx) => postCtrl.index(ctx))
router.get('/posts/:id', (ctx) => postCtrl.show(ctx))
router.post('/posts', (ctx) => postCtrl.store(ctx), guard)
router.delete('/posts/:id', (ctx) => postCtrl.destroy(ctx), guard)
return router
}import { registerRoutes } from './routes/api.js'
// after app.boot():
const router = registerRoutes(app)
await new HttpKernel().useRouter(router).listen(3000)