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 --resource

Writing 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.

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

src/providers/AppServiceProvider.ts
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:

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

src/routes/api.ts
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
}
src/server.ts
import { registerRoutes } from './routes/api.js'

// after app.boot():
const router = registerRoutes(app)
await new HttpKernel().useRouter(router).listen(3000)