11 KiB
11 KiB
| tags | topic | sources | created | ||
|---|---|---|---|---|---|
|
payloadcms | 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.
asynckeyword 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 } })
},
]
Gotchas
dataonbeforeChange/beforeValidate— contains only changed fields (delta), not the full document. UseoriginalDocfor unchanged fields.idis NOT indataon create — useafterChangeif you need it.beforeReadfires before locale flattening — you receive all locales and all hidden fields;afterReadis after locale flattening.- Infinite loop risk — calling
req.payload.update()on the same document insideafterChangeloops forever. Always usecontextflag to guard. beforeRead/afterReadrun on every read — avoid expensive logic here; preferbeforeChange/afterChange.async= blocking — declaring a hookasyncmeans Payload awaits it. For true fire-and-forget, return nothing (synchronous function) or usevoid.- 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 beforebeforeValidateandbeforeChange; for unique text fields Payload appends "- Copy" by default if no hook is defined.
Related
- wiki/payloadcms/access-control — hooks and access control share the same
reqobject and lifecycle - wiki/payloadcms/configuration — Collections, Globals, Fields where hooks are registered