Browse documentation

Content modelling

Hooks

The nine collection stages, field hooks, and what can and cannot fail a write.

Hooks are arrays of functions on a collection. Every stage receives a context object and runs in declaration order.

const bookings = defineCollection({
  slug: 'bookings',
  hooks: {
    beforeValidate: [({ data }) => ({ ...data, email: String(data.email ?? '').toLowerCase() })],
    beforeChange: [
      ({ data, operation, overrideAccess }) =>
        // Public submissions are always pending; our own server code may set whatever it likes.
        operation === 'create' && !overrideAccess ? { ...data, status: 'pending' } : data
    ],
    afterChange: [
      async ({ doc, operation }) => {
        if (operation === 'create') await notifyFrontDesk(doc);
      }
    ]
  },
  fields: {
    /* … */
  }
});

The stages

Stage Runs Receives Returns
beforeOperation first, on every operation { collection, operation, user, overrideAccess } nothing (side effects)
beforeValidate create / update, before validation + { data, previousData? } the data to continue with
beforeChange create / update, after validation + { data, previousData? } the data to store
afterChange create / update, after the write + { doc, result } nothing
beforeRead once per read, before the query + { query } the query to run
afterRead per document returned + { doc } the document to return
beforeDelete before a delete + { id, doc } nothing
afterDelete after a delete + { id, doc } nothing
afterOperation last, on every operation + { result } nothing

afterChange also gets result as an alias of doc, kept for backwards compatibility.

beforeValidate/beforeChange may change content fields and _status, never Forge's own metadata (id, created_at, updated_at, _storageKey). Returning one with a different value than the stored document fails the operation with a 500. Returning { ...previousData, ...data } is fine, because unchanged values are dropped.

Failing a write

Before-hooks can reject. Throw and the operation stops with 400 and your message:

beforeChange: [
  ({ data }) => {
    if (new Date(data.startsAt as string) < new Date()) {
      throw new Error('Cannot book a slot in the past');
    }
    return data;
  }
];

After-hooks cannot. afterChange, afterDelete and afterOperation run once the write is already committed; a throw there is logged and swallowed, because failing the request would report a write that actually happened as an error. Put anything that must be able to veto in a before-stage.

Narrowing reads

beforeRead gets the query the operation is about to run and returns the query to actually run. It merges with — rather than replaces — access-control constraints:

beforeRead: [({ query, user }) => (user ? query : { ...query, visibility: 'public' })];

Field hooks

Any field can carry its own hooks, which run inside the corresponding collection stage:

price: defineField.number({
  hooks: {
    beforeValidate: [({ value }) => Math.round(Number(value) * 100) / 100],
    afterRead: [({ value }) => Number(value)]
  }
});
Field stage When
beforeValidate create / update, before validation — normalise here
beforeChange create / update, after validation
afterRead every read, before the value reaches the caller

Each returns the value to use in place of args.value, and receives the whole data object plus previousValue on update. Field hooks on nested composite fields are not run yet — only top-level fields.

overrideAccess in hooks

Every hook context carries overrideAccess. Without it a hook cannot distinguish a public request from trusted server code, because both can arrive with user: null:

beforeChange: [
  ({ data, overrideAccess }) => (overrideAccess ? data : { ...data, status: 'pending' })
];

Check it in any hook that hardens public writes, or your seed script's deliberate values get rewritten too.

Ordering, end to end

create
  ├─ access check
  ├─ defaults (defaultValue, slug autoGenerate)
  ├─ beforeOperation
  ├─ beforeValidate (collection) → beforeValidate (fields)
  ├─ validate
  ├─ beforeChange (collection) → beforeChange (fields)
  ├─ write
  ├─ afterChange
  └─ afterOperation

find
  ├─ access check → query constraint
  ├─ beforeOperation
  ├─ beforeRead (may narrow the query)
  ├─ query + count
  ├─ populate relations (depth: 1)
  ├─ field read access filtering
  ├─ afterRead (per doc) → afterRead (fields)
  └─ afterOperation

Both the Local API and the HTTP layer run this same pipeline — hooks fire identically whether the call came from a route or from your own server code.