---
tags: [payloadcms, tech-patterns]
topic: payloadcms
sources:
  - raw/hooks__overview.md
  - https://payloadcms.com/docs/hooks/overview
  - https://payloadcms.com/docs/hooks/collections
  - https://payloadcms.com/docs/hooks/globals
  - https://payloadcms.com/docs/hooks/fields
  - https://payloadcms.com/docs/hooks/context
created: 2026-05-15
---

# PayloadCMS — Hooks

## Overview

- Hooks execute side effects at precise moments in the Document lifecycle
- Four scopes: **Root** (app-level), **Collection**, **Global**, **Field**
- Common uses: mutate data before save, encrypt fields, sync to CRM, send emails, process payments, audit trails
- **Server-only** — hooks are excluded from the client bundle; safe for sensitive logic
- Each hook type accepts an **array** of functions; arrays run in series

## How It Works

- **Awaited (blocking):** hook returns a Promise → Payload awaits it before continuing. Use when hook must modify data or influence response.
- **Non-blocking (fire-and-forget):** hook returns nothing → Payload does not wait. Use for pure side-effects that don't affect the response.
- `async` keyword makes a function return a Promise automatically — Payload will wait for it.
- Long-running tasks that don't affect the response → offload to the [[wiki/payloadcms/jobs-queue|Jobs Queue]] via `req.payload.jobs.queue(...)`.
- Custom error messages: throw `new APIError('message', statusCode)` from any hook.

## Config Examples (TypeScript)

### Root Hook — afterError

```ts
import { buildConfig } from 'payload'

export default buildConfig({
  hooks: {
    afterError: [
      async ({ error, req, result, graphqlResult, collection }) => {
        // Log to Sentry, DataDog, etc.
        // Return modified result/status if needed
      },
    ],
  },
})
```

### Collection Hooks — full lifecycle

```ts
import type {
  CollectionBeforeChangeHook,
  CollectionAfterChangeHook,
  CollectionAfterReadHook,
  CollectionBeforeDeleteHook,
  CollectionBeforeLoginHook,
} from 'payload'
import type { Post } from '@/payload-types'

// beforeChange — data is unvalidated user input; id not in data on create
const beforeChangeHook: CollectionBeforeChangeHook = async ({
  data,        // Partial<T> — changed fields only
  originalDoc, // Full doc before changes (undefined on create)
  operation,   // 'create' | 'update'
}) => {
  const id = operation === 'update' ? originalDoc?.id : undefined
  if (operation === 'update' && 'title' in (data ?? {}) && !data.title) {
    throw new Error(`Document ${id} must have a title`)
  }
  return data
}

// afterChange — doc.id is available; good for CRM sync, stats
const afterChangeHook: CollectionAfterChangeHook<Post> = async ({
  doc,          // Post — full resulting document
  previousDoc,  // Post — document before changes
  operation,
  req,
}) => {
  return doc
}

// afterRead — runs last before returning; locales flattened, hidden fields removed
const afterReadHook: CollectionAfterReadHook<Post> = async ({ doc }) => {
  return doc
}

// beforeDelete — returned values discarded
const beforeDeleteHook: CollectionBeforeDeleteHook = async ({ req, id }) => {
  // validate before delete
}

// beforeLogin — can modify or reject the user (throw to deny login)
const beforeLoginHook: CollectionBeforeLoginHook = async ({ user }) => {
  if (!user.isActive) throw new Error('Account deactivated')
  return user
}
```

### Global Hooks

```ts
import type {
  GlobalBeforeChangeHook,
  GlobalAfterChangeHook,
} from 'payload'
import type { SiteSettings } from '@/payload-types'

const beforeChangeHook: GlobalBeforeChangeHook<SiteSettings> = async ({
  data,
  originalDoc,
  req,
}) => {
  return data
}

const afterChangeHook: GlobalAfterChangeHook<SiteSettings> = async ({
  doc,
  previousDoc,
  req,
}) => {
  // Purge CDN cache, sync to CRM
  return doc
}
```

### Field Hooks

```ts
import type { FieldHook } from 'payload'
import type { Post } from '@/payload-types'

// Generic: FieldHook<DocType, ValueType, SiblingDataType>
type PostTitleHook = FieldHook<Post, string, Post>

// beforeValidate — normalize input before server validation
const normalizeUsername: FieldHook<Post, string> = ({ value }) =>
  value?.trim().toLowerCase()

// afterRead — transform for output
const formatDate: FieldHook<Post, string> = ({ value }) =>
  new Date(value).toLocaleDateString()

// beforeDuplicate — avoid unique constraint on duplicate
const incrementNumber: FieldHook<Post, number> = ({ value }) =>
  (value ?? 0) + 1

// afterChange — detect field-level changes
const trackMembershipChange: FieldHook = ({ value, previousValue, req }) => {
  if (value !== previousValue) {
    void notifyMembershipService(req.user?.id, previousValue, value)
  }
}
```

### Hook Context — share data & prevent infinite loops

```ts
import type { CollectionConfig } from 'payload'

const Customer: CollectionConfig = {
  slug: 'customers',
  hooks: {
    beforeChange: [
      async ({ context, data }) => {
        // Fetch once, reuse in afterChange
        context.customerData = await fetchCustomerData(data.customerID)
        return { ...data, name: context.customerData.name }
      },
    ],
    afterChange: [
      async ({ context, doc, req }) => {
        // Prevent infinite loop
        if (context.triggerAfterChange === false) return

        await req.payload.update({
          collection: 'customers',
          id: doc.id,
          data: { synced: true },
          context: { triggerAfterChange: false },
        })
      },
    ],
  },
}
```

## Available Hook Types

### Collection hooks

| Hook                  | Trigger                                          | Can return      |
| --------------------- | ------------------------------------------------ | --------------- |
| `beforeOperation`     | Before any operation starts                      | modified `args` |
| `beforeValidate`      | `create` / `update`, before server validation    | modified `data` |
| `beforeChange`        | `create` / `update`, immediately before save     | modified `data` |
| `afterChange`         | After `create` / `update` saved                  | modified `doc`  |
| `beforeRead`          | Before `find` / `findByID` output transform      | modified `doc`  |
| `afterRead`           | Last step before returning document              | modified `doc`  |
| `beforeDelete`        | Before `delete`                                  | discarded       |
| `afterDelete`         | After record removed from DB                     | discarded       |
| `afterOperation`      | After any operation completes                    | modified result |
| `afterError`          | On any error                                     | modified result |
| `beforeLogin`         | Before token generated on login (auth only)      | modified `user` |
| `afterLogin`          | After successful login (auth only)               | modified `user` |
| `afterLogout`         | After logout (auth only)                         | discarded       |
| `afterMe`             | After `me` operation (auth only)                 | —               |
| `afterRefresh`        | After token refresh (auth only)                  | —               |
| `afterForgotPassword` | After forgot-password operation (auth only)      | discarded       |
| `refresh`             | Replace default refresh logic (auth only)        | optional        |
| `me`                  | Replace default me logic (auth only)             | optional        |

### Global hooks

`beforeOperation`, `beforeValidate`, `beforeChange`, `afterChange`, `beforeRead`, `afterRead`

### Field hooks

`beforeValidate`, `beforeChange`, `afterChange`, `afterRead`, `beforeDuplicate`

### Context TypeScript augmentation

```ts
declare module 'payload' {
  export interface RequestContext {
    customerData?: { name: string; contacted: boolean }
    triggerAfterChange?: boolean
  }
}
```

## Custom Error Messages

Throw `APIError` from any hook to return structured errors to REST or GraphQL callers:

```ts
import { APIError, type CollectionBeforeChangeHook } from 'payload'

const beforeChangeHook: CollectionBeforeChangeHook = async ({ data }) => {
  if (rateLimitExceeded) {
    throw new APIError('You have sent too many requests', 429)
  }
  return data
}
```

## Performance

### Avoid expensive logic in read hooks

`beforeRead` / `afterRead` run on **every** read request — query-time operations here multiply with list view page sizes.

```ts
// BAD — runs on every find/findByID
hooks: { beforeRead: [async () => { await doSomethingExpensive() }] }

// BETTER — only on create/update
hooks: { beforeChange: [async () => { await doSomethingExpensive() }] }
```

### Use Hook Context to cache across hooks

```ts
hooks: {
  beforeChange: [
    async ({ context, data }) => {
      context.enriched = await fetchExpensiveData(data.userId)
      return { ...data, profile: context.enriched }
    },
  ],
  afterChange: [
    async ({ context, doc }) => {
      // reuse what beforeChange already fetched — no second DB call
      await syncToCRM(doc.id, context.enriched)
    },
  ],
}
```

Full context API → [[wiki/payloadcms/hooks-context|Hooks — Context]]

### Offload long-running tasks

```ts
afterChange: [
  async ({ doc, req }) => {
    await req.payload.jobs.queue({ task: 'processReport', input: { id: doc.id } })
  },
]
```

→ [[wiki/payloadcms/jobs-queue|Jobs Queue]]

## Gotchas

- **`data` on `beforeChange`/`beforeValidate`** — contains only changed fields (delta), not the full document. Use `originalDoc` for unchanged fields. `id` is NOT in `data` on create — use `afterChange` if you need it.
- **`beforeRead` fires before locale flattening** — you receive all locales and all hidden fields; `afterRead` is after locale flattening.
- **Infinite loop risk** — calling `req.payload.update()` on the same document inside `afterChange` loops forever. Always use `context` flag to guard.
- **`beforeRead` / `afterRead` run on every read** — avoid expensive logic here; prefer `beforeChange` / `afterChange`.
- **`async` = blocking** — declaring a hook `async` means Payload awaits it. For true fire-and-forget, return nothing (synchronous function) or use `void`.
- **Field hooks and GraphQL** — changing the return type of a field hook breaks the GraphQL schema; use Collection/Global hooks if you need to reshape data.
- **`beforeDuplicate`** — runs before `beforeValidate` and `beforeChange`; for unique text fields Payload appends "- Copy" by default if no hook is defined.

## Related

- [[wiki/payloadcms/access-control|Access Control]] — hooks and access control share the same `req` object and lifecycle
- [[wiki/payloadcms/configuration|Configuration]] — Collections, Globals, Fields where hooks are registered
