Agora
Guides

Controllers

Wiring a filter into AdonisJS controllers — the shape of a list endpoint, per-request policies, forced scopes a client cannot relax, strict mode and error handling, and response shapes.

A filtered list endpoint is three lines: build the query, apply the filter, paginate. Everything in this guide is a variation on those three.

The shape of a list endpoint

With a filter class on the model, the endpoint is one line:

app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'

export default class UsersController {
  async index(ctx: HttpContext) {
    return User.filterPaginate(ctx)
  }
}
start/routes.ts
import router from '@adonisjs/core/services/router'
const UsersController = () => import('#controllers/users_controller')

router.get('/users', [UsersController, 'index'])

Most endpoints need the builder between filtering and paging — to preload, to force a scope, to project something other than rows. Take it:

app/controllers/users_controller.ts
async index(ctx: HttpContext) {
  const { query } = await User.filter(ctx)

  query.preload('team').withCount('posts')

  return query.filterPaginate()
}

And when the filter is a defineFilter spec rather than a class, the free function is the same three lines it always was:

app/controllers/users_controller.ts
async index(ctx: HttpContext) {
  const query = User.query()

  const { page, size } = applyFilterFromRequest(query, userFilter, ctx)

  return query.paginate(page, size)
}

All three do the same work — the sections below use whichever reads clearest.

Constraints the client cannot relax

A scope you enforce goes on the builder, and its column stays out of filterable. With a class that is what setup() is for; on a query you built yourself it is an ordinary where. Client filters are AND-combined with whatever is already there, so the two together give you a condition no query string can widen:

app/controllers/users_controller.ts
async index(ctx: HttpContext) {
  const query = User.query()
    .where('tenantId', ctx.auth.user!.tenantId) // forced — `tenantId` is not filterable
    .whereNull('deletedAt')

  const { page, size } = applyFilterFromRequest(query, userFilter, ctx)

  return query.paginate(page, size)
}

Leaving the column out of the allow-list is half the recipe

Adding .where('tenantId', …) while tenantId is also filterable does not scope anything: the client's ?filter[tenantId]=999 is AND-combined with yours, so the request simply returns nothing — and the day someone widens it to an OR group, it returns everything. Force the scope, omit the column.

When the same scope belongs to every endpoint that uses the spec, declare it once as the spec's tenant scope instead of repeating it in each controller.

Per-request policies

Which filter runs is the controller's decision, so an endpoint can pick one per request — an admin reading more columns than everyone else is a filter class of its own, not a branch inside one:

app/controllers/users_controller.ts
async index(ctx: HttpContext) {
  const filter = ctx.auth.user!.isAdmin ? AdminUserFilter : UserFilter

  return User.filterPaginate(ctx, filter)
}

The same choice with a spec, or with an inline FilterConfig for the one endpoint whose policy depends on who is asking:

app/controllers/users_controller.ts
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'

async index({ request, auth }: HttpContext) {
  const input = parseFilterRequest(request.qs())
  const query = User.query()

  const isAdmin = auth.user?.role === 'admin'

  const { page, size } = applyFilter(query, input, {
    allowed: isAdmin
      ? ['name', 'email', 'status', 'internalNotes']
      : ['name', 'status'],
    searchable: ['name', 'email'],
  })

  return query.paginate(page, size)
}

Keep the narrow list as the default and widen it deliberately, never the other way around: a policy that starts open and subtracts is one refactor away from leaking a column.

Where the input comes from

applyFilterFromRequest reads the query string. When the filter arrives some other way — a search endpoint with a body too big for a URL, a payload nested under one key — parse it yourself and pass the result in:

// GET /users?filter[status]=active&sort=-createdAt
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)

A GET endpoint pairs with the client builder's .toQueryString(); a POST search endpoint pairs with .build(). parseFilterRequest reads both shapes, so one controller body serves either — see the structured shape and the Client Builder. include is the one key nothing acts on: eager-loading stays your preload call.

Strict mode and error handling

By default a disallowed field is dropped and the request still returns rows. Set throwOnInvalid on the spec to reject those requests instead, and map the error to a 400:

app/controllers/users_controller.ts
import { InvalidColumnFilterError } from '@adonis-agora/filter'

async index(ctx: HttpContext) {
  const query = User.query()

  try {
    const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
    return query.paginate(page, size)
  } catch (error) {
    if (error instanceof InvalidColumnFilterError) {
      return ctx.response.badRequest({ message: error.message })
    }
    throw error
  }
}

One try/catch per controller gets old fast. Handle it once in app/exceptions/handler.ts instead — Validation shows that version, along with layering VineJS over parsed input.

Response shapes

query.paginate(page, size) returns Lucid's paginator, which serializes to { meta, data }. Return it as-is, or reshape it:

const result = await query.paginate(page, size)

return {
  data: result.all(),
  meta: result.getMeta(),
}

For a lookup endpoint that should not paginate, ignore the returned pagination and execute directly — the filters, search and sort are already on the builder:

app/controllers/users_controller.ts
async options(ctx: HttpContext) {
  const query = User.query().select('id', 'name')
  applyFilterFromRequest(query, userFilter, ctx)

  return query.limit(50)
}

One helper for many resources

List endpoints differ by model and spec, and by almost nothing else. When you have a dozen of them, one helper is worth more than a dozen identical methods:

app/filters/list.ts
import type { HttpContext } from '@adonisjs/core/http'
import { applyFilterFromRequest, type FilterSpec } from '@adonis-agora/filter'
import type { QueryBuilderLike } from '@adonis-agora/filter'

type Paginatable = QueryBuilderLike & {
  paginate: (page: number, size: number) => Promise<unknown>
}

export function list(ctx: HttpContext, query: Paginatable, spec: FilterSpec) {
  const { page, size } = applyFilterFromRequest(query, spec, ctx)
  return query.paginate(page, size)
}
app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { list } from '#filters/list'
import { userFilter } from '#filters/user_filter'
import User from '#models/user'

export default class UsersController {
  index(ctx: HttpContext) {
    return list(ctx, User.query(), userFilter)
  }
}

Stop there. The moment the helper grows a second parameter for "except this endpoint also needs a join", it has become a worse version of the three lines it replaced.

On this page