---
title: "3. Validation"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 3. Validation

Nothing in chapter 2 declared a validator. `required()`, `maxLength(200)` and
`nullable()` on the fields **are** the validator: the server compiles a VineJS
schema out of the same tree it rendered, for the exact mode and visibility
state of the submission.

## What a submission does

Post the create form with an empty title and you are redirected back with an
error under the field:

```sh
curl -i -X POST http://localhost:3333/admin/posts \
  -d 'title=&slug=no-title&body=x&authorId=1&status=draft'
```

```text
HTTP/1.1 302 Found
location: /admin/posts/create
```

The redirect carries a session flash named `inputErrorsBag`:

```jsonc
{ "title": "The title field must be defined" }
```

That name is v7's, not Adonia's. The chain is:

1. `store`/`update` recompiles the validator **server-side** — never from
   flags the client sent.
2. Keys the compiled tree does not contain are pruned before Vine runs.
3. Vine throws `E_VALIDATION_ERROR`; `toInputErrorsBag` folds it into
   `Record<fieldPath, message>`, first error per field wins.
4. The controller flashes it and redirects back.
5. `@adonisjs/inertia` shares the bag as the page's `errors` prop, and
   `useFormEngine` maps each entry onto `controller.error` for that field.

A failed login flashes the identical bag from the panel's auth controller, so
"the form failed" has exactly one shape in the client.

## The rules you get

| Declaration | Effect |
|---|---|
| `required()` | `The <field> field must be defined` |
| `nullable()` | `null` accepted; absent is still absent |
| `maxLength(n)` / `minLength(n)` | Length bounds on textual fields |
| `unique()` | Records the constraint (see below) |
| `rules(v => …)` | Replaces the field type's Vine base entirely |
| `F.belongsTo(…)` | Existence: the key must name an in-scope row |

A relation field validates by **existence**, scoped by the target resource's
own `static query()`, so a key the picker would never offer is a key the form
cannot accept:

```sh
curl -X POST http://localhost:3333/admin/posts -d 'title=Ghost&…&authorId=99999'
```

```jsonc
{ "authorId": "The selected Author does not exist" }
```

::: warning `unique()` is declaration-only in this build
The spec is recorded on the field and the DSL is frozen, but nothing reads it
yet: no `database.unique` rule is emitted and no probe runs. Keep declaring it
— and keep the unique index on the column, which is what actually guarantees
the constraint today. See [validation](/guide/validation#unique).
:::

## Mass assignment is impossible by construction

The **fill set** — the keys written to the model — is derived from the compiled
schema for this submission, not from a list you maintain. A key that is not in
the tree is not in the fill set:

```ts no-check
await client.post('/admin/posts').form({
  title: 'Guard probe',
  slug: 'guard-probe',
  body: 'probe body',
  authorId: String(admin.id),
  status: 'draft',
  id: '424242',            // not a field
  author_id: '99999',      // the COLUMN, not the field key
  createdAt: '1999-01-01', // a timestamp the model owns
  admin_flag: 'true',      // not even a column
})
```

The row is created with none of the last four applied. That test lives in
`examples/blog-admin/tests/functional/adonia_posts.spec.ts` and it is not
decorative: the same mechanism is what makes a field hidden by a `canSee` gate
or a reactive rule unfillable, which chapters 5 and 7 lean on.

Three consequences worth internalising:

- **Omission is total.** A node the request may not see is absent from the
  descriptor, from `state`, from the validator and from the fill set — one
  pass, no `visible: false` anywhere on the wire.
- **`readonlyOn('edit')`** keeps a field rendered and validated but out of the
  fill set for that mode. Chapter 7 uses it to pin a slug.
- **Client rules are cosmetic.** Every declarative rule that affects
  requiredness is mirrored in the compiled validator, so bypassing the UI
  gains nothing.

## Testing it without HTTP

`@adonia/core/testing` compiles a resource in-process, which is the cheap way
to pin a rule:

```ts no-check
import { validateWith } from '@adonia/core/testing'

const { outcome, errors } = await validateWith(resource, 'create', { slug: 'x' })
// outcome === 'invalid'
// errors  === { title: 'The title field must be defined', … }
```

`validateWith` runs the same prune → Vine → `sets` pipeline the controller
runs, and hands back errors already in `inputErrorsBag` shape. The full harness
— `compileFor`, `assertValidatorKeys`, `assertFillOmits`, `assertRequiredWhen`
— is in [testing](/guide/testing).

Next: [the index table](/guide/tutorial/table).

Source: https://adonia.pages.dev/guide/tutorial/validation/index.mdx
