obsidian/wiki/payloadcms/hooks.md
2026-05-15 15:53:42 +01:00

11 KiB

tags topic sources created
payloadcms
tech-patterns
payloadcms
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
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 via req.payload.jobs.queue(...).
  • Custom error messages: throw new APIError('message', statusCode) from any hook.

Config Examples (TypeScript)

Root Hook — afterError

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

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

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

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

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

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:

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.

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

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

Offload long-running tasks

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

wiki/payloadcms/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.