--- name: payload-cms description: > Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Triggers on tasks involving: collection definitions, field configurations, hooks, access control, database queries, custom endpoints, authentication, file uploads, drafts/versions, live preview, or plugin development. Also use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior. author: payloadcms version: 1.0.0 --- # Payload CMS Development Payload is a Next.js native CMS with TypeScript-first architecture. This skill transfers expert knowledge for building collections, hooks, access control, and queries the right way. ## Mental Model Think of Payload as **three interconnected layers**: 1. **Config Layer** → Collections, globals, fields define your schema 2. **Hook Layer** → Lifecycle events transform and validate data 3. **Access Layer** → Functions control who can do what Every operation flows through: `Config → Access Check → Hook Chain → Database → Response Hooks` ## Quick Reference | Task | Solution | Details | |------|----------|---------| | Auto-generate slugs | `slugField()` or beforeChange hook | [references/fields.md#slug-field] | | Restrict by user | Access control with query constraint | [references/access-control.md] | | Local API with auth | `user` + `overrideAccess: false` | [references/queries.md#local-api] | | Draft/publish | `versions: { drafts: true }` | [references/collections.md#drafts] | | Computed fields | `virtual: true` with afterRead hook | [references/fields.md#virtual] | | Conditional fields | `admin.condition` | [references/fields.md#conditional] | | Filter relationships | `filterOptions` on field | [references/fields.md#relationship] | | Prevent hook loops | `req.context` flag | [references/hooks.md#context] | | Transactions | Pass `req` to all operations | [references/hooks.md#transactions] | | Background jobs | Jobs queue with tasks | [references/advanced.md#jobs] | ## Quick Start ```bash npx create-payload-app@latest my-app cd my-app pnpm dev ``` ### Minimal Config ```ts import { buildConfig } from 'payload' import { mongooseAdapter } from '@payloadcms/db-mongodb' import { lexicalEditor } from '@payloadcms/richtext-lexical' export default buildConfig({ admin: { user: 'users' }, collections: [Users, Media, Posts], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET, typescript: { outputFile: 'payload-types.ts' }, db: mongooseAdapter({ url: process.env.DATABASE_URL }), }) ``` ## Core Patterns ### Collection Definition ```ts import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'author', 'status', 'createdAt'], }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', unique: true, index: true }, { name: 'content', type: 'richText' }, { name: 'author', type: 'relationship', relationTo: 'users' }, { name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }, ], timestamps: true, } ``` ### Hook Pattern (Auto-slug) ```ts export const Posts: CollectionConfig = { slug: 'posts', hooks: { beforeChange: [ async ({ data, operation }) => { if (operation === 'create' && data.title) { data.slug = data.title.toLowerCase().replace(/\s+/g, '-') } return data }, ], }, fields: [{ name: 'title', type: 'text', required: true }], } ``` ### Access Control Pattern ```ts import type { Access } from 'payload' // Type-safe: admin-only access export const adminOnly: Access = ({ req }) => { return req.user?.roles?.includes('admin') ?? false } // Row-level: users see only their own posts export const ownPostsOnly: Access = ({ req }) => { if (!req.user) return false if (req.user.roles?.includes('admin')) return true return { author: { equals: req.user.id } } } ``` ### Query Pattern ```ts // Local API with access control const posts = await payload.find({ collection: 'posts', where: { status: { equals: 'published' }, 'author.name': { contains: 'john' }, }, depth: 2, limit: 10, sort: '-createdAt', user: req.user, overrideAccess: false, // CRITICAL: enforce permissions }) ``` ## Critical Security Rules ### 1. Local API Access Control **Default behavior bypasses ALL access control.** This is the #1 security mistake. ```ts // ❌ SECURITY BUG: Access control bypassed even with user await payload.find({ collection: 'posts', user: someUser }) // ✅ SECURE: Explicitly enforce permissions await payload.find({ collection: 'posts', user: someUser, overrideAccess: false, // REQUIRED }) ``` **Rule:** Use `overrideAccess: false` for any operation acting on behalf of a user. ### 2. Transaction Integrity **Operations without `req` run in separate transactions.** ```ts // ❌ DATA CORRUPTION: Separate transaction hooks: { afterChange: [async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, // Missing req - breaks atomicity! }) }] } // ✅ ATOMIC: Same transaction hooks: { afterChange: [async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req, // Maintains transaction }) }] } ``` **Rule:** Always pass `req` to nested operations in hooks. ### 3. Infinite Hook Loops **Hooks triggering themselves create infinite loops.** ```ts // ❌ INFINITE LOOP hooks: { afterChange: [async ({ doc, req }) => { await req.payload.update({ collection: 'posts', id: doc.id, data: { views: doc.views + 1 }, req, }) // Triggers afterChange again! }] } // ✅ SAFE: Context flag breaks the loop hooks: { afterChange: [async ({ doc, req, context }) => { if (context.skipViewUpdate) return await req.payload.update({ collection: 'posts', id: doc.id, data: { views: doc.views + 1 }, req, context: { skipViewUpdate: true }, }) }] } ``` ## Project Structure ``` src/ ├── app/ │ ├── (frontend)/page.tsx │ └── (payload)/admin/[[...segments]]/page.tsx ├── collections/ │ ├── Posts.ts │ ├── Media.ts │ └── Users.ts ├── globals/Header.ts ├── hooks/slugify.ts └── payload.config.ts ``` ## Type Generation Generate types after schema changes: ```ts // payload.config.ts export default buildConfig({ typescript: { outputFile: 'payload-types.ts' }, }) // Usage import type { Post, User } from '@/payload-types' ``` ## Getting Payload Instance ```ts // In API routes import { getPayload } from 'payload' import config from '@payload-config' export async function GET() { const payload = await getPayload({ config }) const posts = await payload.find({ collection: 'posts' }) return Response.json(posts) } // In Server Components export default async function Page() { const payload = await getPayload({ config }) const { docs } = await payload.find({ collection: 'posts' }) return
{docs.map(p =>

{p.title}

)}
} ``` ## Common Field Types ```ts // Text { name: 'title', type: 'text', required: true } // Relationship { name: 'author', type: 'relationship', relationTo: 'users' } // Rich text { name: 'content', type: 'richText' } // Select { name: 'status', type: 'select', options: ['draft', 'published'] } // Upload { name: 'image', type: 'upload', relationTo: 'media' } // Array { name: 'tags', type: 'array', fields: [{ name: 'tag', type: 'text' }], } // Blocks (polymorphic content) { name: 'layout', type: 'blocks', blocks: [HeroBlock, ContentBlock, CTABlock], } ``` ## Decision Framework **When choosing between approaches:** | Scenario | Approach | |----------|----------| | Data transformation before save | `beforeChange` hook | | Data transformation after read | `afterRead` hook | | Enforce business rules | Access control function | | Complex validation | `validate` function on field | | Computed display value | Virtual field with `afterRead` | | Related docs list | `join` field type | | Side effects (email, webhook) | `afterChange` hook with context guard | | Database-level constraint | Field with `unique: true` or `index: true` | ## Quality Checks Good Payload code: - [ ] All Local API calls with user context use `overrideAccess: false` - [ ] All hook operations pass `req` for transaction integrity - [ ] Recursive hooks use `context` flags - [ ] Types generated and imported from `payload-types.ts` - [ ] Access control functions are typed with `Access` type - [ ] Collections have meaningful `admin.useAsTitle` set ## Reference Documentation For detailed patterns, see: - **[references/fields.md](references/fields.md)** - All field types, validation, conditional logic - **[references/collections.md](references/collections.md)** - Auth, uploads, drafts, live preview - **[references/hooks.md](references/hooks.md)** - Hook lifecycle, context, patterns - **[references/access-control.md](references/access-control.md)** - RBAC, row-level, field-level - **[references/queries.md](references/queries.md)** - Operators, Local/REST/GraphQL APIs - **[references/advanced.md](references/advanced.md)** - Jobs, plugins, localization ## Resources - Docs: https://payloadcms.com/docs - LLM Context: https://payloadcms.com/llms-full.txt - GitHub: https://github.com/payloadcms/payload - Templates: https://github.com/payloadcms/payload/tree/main/templates -e --- # Detailed Reference Documentation # Field Types Reference ## Core Field Types ### Text Fields ```ts // Basic text { name: 'title', type: 'text', required: true } // With validation { name: 'email', type: 'text', validate: (value) => { if (!value?.includes('@')) return 'Invalid email' return true }, } // With admin config { name: 'description', type: 'textarea', admin: { placeholder: 'Enter description...', description: 'Brief summary', }, } ``` ### Slug Field Helper Auto-generate URL-safe slugs: ```ts import { slugField } from '@payloadcms/plugin-seo' // Or manual implementation { name: 'slug', type: 'text', unique: true, index: true, hooks: { beforeValidate: [ ({ data, operation, originalDoc }) => { if (operation === 'create' || !originalDoc?.slug) { return data?.title?.toLowerCase().replace(/\s+/g, '-') } return originalDoc.slug }, ], }, } ``` ### Number Fields ```ts { name: 'price', type: 'number', min: 0, required: true } { name: 'quantity', type: 'number', defaultValue: 1 } ``` ### Select Fields ```ts // Simple select { name: 'status', type: 'select', options: ['draft', 'published', 'archived'], defaultValue: 'draft', } // With labels { name: 'priority', type: 'select', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, { label: 'High', value: 'high' }, ], } // Multi-select { name: 'categories', type: 'select', hasMany: true, options: ['tech', 'design', 'marketing'], } ``` ### Checkbox ```ts { name: 'featured', type: 'checkbox', defaultValue: false } ``` ### Date Fields ```ts { name: 'publishedAt', type: 'date' } // With time { name: 'eventDate', type: 'date', admin: { date: { pickerAppearance: 'dayAndTime' } }, } ``` ## Relationship Fields ### Basic Relationship ```ts // Single relationship { name: 'author', type: 'relationship', relationTo: 'users', required: true, } // Multiple relationships (hasMany) { name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true, } // Polymorphic (multiple collections) { name: 'parent', type: 'relationship', relationTo: ['pages', 'posts'], } ``` ### With Filter Options Dynamically filter available options: ```ts { name: 'relatedPosts', type: 'relationship', relationTo: 'posts', hasMany: true, filterOptions: ({ data }) => ({ // Only show published posts, exclude self status: { equals: 'published' }, id: { not_equals: data?.id }, }), } ``` ### Join Fields Reverse relationship lookup (virtual field): ```ts // In Posts collection { name: 'comments', type: 'join', collection: 'comments', on: 'post', // field name in comments that references posts } ``` ## Virtual Fields Computed fields that don't store data: ```ts { name: 'fullName', type: 'text', virtual: true, hooks: { afterRead: [ ({ data }) => `${data?.firstName} ${data?.lastName}`, ], }, } ``` ## Conditional Fields Show/hide fields based on other values: ```ts { name: 'isExternal', type: 'checkbox', }, { name: 'externalUrl', type: 'text', admin: { condition: (data) => data?.isExternal === true, }, } ``` ## Validation ### Custom Validation ```ts { name: 'slug', type: 'text', validate: (value, { data, operation }) => { if (!value) return 'Slug is required' if (!/^[a-z0-9-]+$/.test(value)) { return 'Slug must be lowercase letters, numbers, and hyphens only' } return true }, } ``` ### Async Validation ```ts { name: 'username', type: 'text', validate: async (value, { payload }) => { if (!value) return true const existing = await payload.find({ collection: 'users', where: { username: { equals: value } }, }) if (existing.docs.length > 0) return 'Username already taken' return true }, } ``` ## Group Fields Organize related fields: ```ts { name: 'meta', type: 'group', fields: [ { name: 'title', type: 'text' }, { name: 'description', type: 'textarea' }, ], } ``` ## Array Fields Repeatable sets of fields: ```ts { name: 'socialLinks', type: 'array', fields: [ { name: 'platform', type: 'select', options: ['twitter', 'linkedin', 'github'] }, { name: 'url', type: 'text' }, ], } ``` ## Blocks (Polymorphic Content) Different content types in same array: ```ts { name: 'layout', type: 'blocks', blocks: [ { slug: 'hero', fields: [ { name: 'heading', type: 'text' }, { name: 'image', type: 'upload', relationTo: 'media' }, ], }, { slug: 'content', fields: [ { name: 'richText', type: 'richText' }, ], }, ], } ``` ## Point (Geolocation) ```ts { name: 'location', type: 'point', label: 'Location', } // Query nearby await payload.find({ collection: 'stores', where: { location: { near: [-73.935242, 40.730610, 5000], // lng, lat, maxDistance (meters) }, }, }) ``` ## Upload Fields ```ts { name: 'featuredImage', type: 'upload', relationTo: 'media', required: true, } ``` ## Rich Text ```ts { name: 'content', type: 'richText', // Lexical editor features configured in payload.config.ts } ``` ## UI Fields (Presentational) Fields that don't save data: ```ts // Row layout { type: 'row', fields: [ { name: 'firstName', type: 'text', admin: { width: '50%' } }, { name: 'lastName', type: 'text', admin: { width: '50%' } }, ], } // Tabs { type: 'tabs', tabs: [ { label: 'Content', fields: [...] }, { label: 'Meta', fields: [...] }, ], } // Collapsible { type: 'collapsible', label: 'Advanced Options', fields: [...], } ``` -e --- # Collections Reference ## Basic Collection Config ```ts import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'author', 'status', 'createdAt'], group: 'Content', // Groups in sidebar }, fields: [...], timestamps: true, // Adds createdAt, updatedAt } ``` ## Auth Collection Enable authentication on a collection: ```ts export const Users: CollectionConfig = { slug: 'users', auth: { tokenExpiration: 7200, // 2 hours verify: true, // Email verification maxLoginAttempts: 5, lockTime: 600 * 1000, // 10 min lockout }, fields: [ { name: 'name', type: 'text', required: true }, { name: 'roles', type: 'select', hasMany: true, options: ['admin', 'editor', 'user'], defaultValue: ['user'], }, ], } ``` ## Upload Collection Handle file uploads: ```ts export const Media: CollectionConfig = { slug: 'media', upload: { staticDir: 'media', mimeTypes: ['image/*', 'application/pdf'], imageSizes: [ { name: 'thumbnail', width: 400, height: 300, position: 'centre' }, { name: 'card', width: 768, height: 1024, position: 'centre' }, ], adminThumbnail: 'thumbnail', }, fields: [ { name: 'alt', type: 'text', required: true }, { name: 'caption', type: 'textarea' }, ], } ``` ## Versioning & Drafts Enable draft/publish workflow: ```ts export const Posts: CollectionConfig = { slug: 'posts', versions: { drafts: true, maxPerDoc: 10, // Keep last 10 versions }, fields: [...], } ``` Query drafts: ```ts // Get published only (default) await payload.find({ collection: 'posts' }) // Include drafts await payload.find({ collection: 'posts', draft: true }) ``` ## Live Preview Real-time preview for frontend: ```ts export const Pages: CollectionConfig = { slug: 'pages', admin: { livePreview: { url: ({ data }) => `${process.env.NEXT_PUBLIC_URL}/preview/${data.slug}`, }, }, versions: { drafts: true }, fields: [...], } ``` ## Access Control ```ts export const Posts: CollectionConfig = { slug: 'posts', access: { create: ({ req }) => !!req.user, // Logged in users read: () => true, // Public read update: ({ req }) => req.user?.roles?.includes('admin'), delete: ({ req }) => req.user?.roles?.includes('admin'), }, fields: [...], } ``` ## Hooks Configuration ```ts export const Posts: CollectionConfig = { slug: 'posts', hooks: { beforeValidate: [...], beforeChange: [...], afterChange: [...], beforeRead: [...], afterRead: [...], beforeDelete: [...], afterDelete: [...], // Auth-only hooks afterLogin: [...], afterLogout: [...], afterMe: [...], afterRefresh: [...], afterForgotPassword: [...], }, fields: [...], } ``` ## Custom Endpoints Add API routes to a collection: ```ts export const Posts: CollectionConfig = { slug: 'posts', endpoints: [ { path: '/publish/:id', method: 'post', handler: async (req) => { const { id } = req.routeParams await req.payload.update({ collection: 'posts', id, data: { status: 'published', publishedAt: new Date() }, req, }) return Response.json({ success: true }) }, }, ], fields: [...], } ``` ## Admin Panel Options ```ts export const Posts: CollectionConfig = { slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'status', 'createdAt'], group: 'Content', description: 'Manage blog posts', hidden: false, // Hide from sidebar listSearchableFields: ['title', 'slug'], pagination: { defaultLimit: 20, limits: [10, 20, 50, 100], }, preview: (doc) => `${process.env.NEXT_PUBLIC_URL}/${doc.slug}`, }, fields: [...], } ``` ## Labels & Localization ```ts export const Posts: CollectionConfig = { slug: 'posts', labels: { singular: 'Article', plural: 'Articles', }, fields: [...], } ``` ## Database Indexes ```ts export const Posts: CollectionConfig = { slug: 'posts', fields: [ { name: 'slug', type: 'text', unique: true, index: true }, { name: 'publishedAt', type: 'date', index: true }, ], // Compound indexes via dbName dbName: 'posts', } ``` ## Disable Operations ```ts export const AuditLogs: CollectionConfig = { slug: 'audit-logs', admin: { enableRichTextRelationship: false, }, disableDuplicate: true, // No duplicate button fields: [...], } ``` ## Full Example ```ts import type { CollectionConfig } from 'payload' import { slugField } from './fields/slugField' export const Posts: CollectionConfig = { slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'author', 'status', 'publishedAt'], group: 'Content', livePreview: { url: ({ data }) => `${process.env.NEXT_PUBLIC_URL}/posts/${data.slug}`, }, }, access: { create: ({ req }) => !!req.user, read: ({ req }) => { if (req.user?.roles?.includes('admin')) return true return { status: { equals: 'published' } } }, update: ({ req }) => { if (req.user?.roles?.includes('admin')) return true return { author: { equals: req.user?.id } } }, delete: ({ req }) => req.user?.roles?.includes('admin'), }, versions: { drafts: true, maxPerDoc: 10, }, hooks: { beforeChange: [ async ({ data, operation }) => { if (operation === 'create') { data.slug = data.title?.toLowerCase().replace(/\s+/g, '-') } if (data.status === 'published' && !data.publishedAt) { data.publishedAt = new Date() } return data }, ], }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', unique: true, index: true }, { name: 'content', type: 'richText', required: true }, { name: 'author', type: 'relationship', relationTo: 'users', required: true, defaultValue: ({ user }) => user?.id, }, { name: 'status', type: 'select', options: ['draft', 'published', 'archived'], defaultValue: 'draft', }, { name: 'publishedAt', type: 'date' }, { name: 'featuredImage', type: 'upload', relationTo: 'media' }, { name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true, }, ], timestamps: true, } ``` -e --- # Hooks Reference ## Hook Lifecycle ``` Operation: CREATE beforeOperation → beforeValidate → beforeChange → [DB Write] → afterChange → afterOperation Operation: UPDATE beforeOperation → beforeValidate → beforeChange → [DB Write] → afterChange → afterOperation Operation: READ beforeOperation → beforeRead → [DB Read] → afterRead → afterOperation Operation: DELETE beforeOperation → beforeDelete → [DB Delete] → afterDelete → afterOperation ``` ## Collection Hooks ### beforeValidate Transform data before validation runs: ```ts hooks: { beforeValidate: [ async ({ data, operation, req }) => { if (operation === 'create') { data.createdBy = req.user?.id } return data // Always return data }, ], } ``` ### beforeChange Transform data before database write (after validation): ```ts hooks: { beforeChange: [ async ({ data, operation, originalDoc, req }) => { // Auto-generate slug on create if (operation === 'create' && data.title) { data.slug = data.title.toLowerCase().replace(/\s+/g, '-') } // Track last modified by data.lastModifiedBy = req.user?.id return data }, ], } ``` ### afterChange Side effects after database write: ```ts hooks: { afterChange: [ async ({ doc, operation, req, context }) => { // Prevent infinite loops if (context.skipAuditLog) return doc // Create audit log entry await req.payload.create({ collection: 'audit-logs', data: { action: operation, collection: 'posts', documentId: doc.id, userId: req.user?.id, timestamp: new Date(), }, req, // CRITICAL: maintains transaction context: { skipAuditLog: true }, }) return doc }, ], } ``` ### beforeRead Modify query before database read: ```ts hooks: { beforeRead: [ async ({ doc, req }) => { // doc is the raw database document // Can modify before afterRead transforms return doc }, ], } ``` ### afterRead Transform data before sending to client: ```ts hooks: { afterRead: [ async ({ doc, req }) => { // Add computed field doc.fullName = `${doc.firstName} ${doc.lastName}` // Hide sensitive data for non-admins if (!req.user?.roles?.includes('admin')) { delete doc.internalNotes } return doc }, ], } ``` ### beforeDelete Pre-delete validation or cleanup: ```ts hooks: { beforeDelete: [ async ({ id, req }) => { // Cascading delete: remove related comments await req.payload.delete({ collection: 'comments', where: { post: { equals: id } }, req, }) }, ], } ``` ### afterDelete Post-delete cleanup: ```ts hooks: { afterDelete: [ async ({ doc, req }) => { // Clean up uploaded files if (doc.image) { await deleteFile(doc.image.filename) } }, ], } ``` ## Field Hooks Hooks on individual fields: ```ts { name: 'slug', type: 'text', hooks: { beforeValidate: [ ({ value, data }) => { if (!value && data?.title) { return data.title.toLowerCase().replace(/\s+/g, '-') } return value }, ], afterRead: [ ({ value }) => value?.toLowerCase(), ], }, } ``` ## Context Pattern **Prevent infinite loops and share state between hooks:** ```ts hooks: { afterChange: [ async ({ doc, req, context }) => { // Check context flag to prevent loops if (context.skipNotification) return doc // Trigger related update with context flag await req.payload.update({ collection: 'related', id: doc.relatedId, data: { updated: true }, req, context: { ...context, skipNotification: true, // Prevent loop }, }) return doc }, ], } ``` ## Transactions **CRITICAL: Always pass `req` for transaction integrity:** ```ts hooks: { afterChange: [ async ({ doc, req }) => { // ✅ Same transaction - atomic await req.payload.create({ collection: 'audit-logs', data: { documentId: doc.id }, req, // REQUIRED }) // ❌ Separate transaction - can leave inconsistent state await req.payload.create({ collection: 'audit-logs', data: { documentId: doc.id }, // Missing req! }) return doc }, ], } ``` ## Next.js Revalidation with Context Control ```ts import { revalidatePath, revalidateTag } from 'next/cache' hooks: { afterChange: [ async ({ doc, context }) => { // Skip revalidation for internal updates if (context.skipRevalidation) return doc revalidatePath(`/posts/${doc.slug}`) revalidateTag('posts') return doc }, ], } ``` ## Auth Hooks (Auth Collections Only) ```ts export const Users: CollectionConfig = { slug: 'users', auth: true, hooks: { afterLogin: [ async ({ doc, req }) => { // Log login await req.payload.create({ collection: 'login-logs', data: { userId: doc.id, timestamp: new Date() }, req, }) return doc }, ], afterLogout: [ async ({ req }) => { // Clear session data }, ], afterMe: [ async ({ doc, req }) => { // Add extra user info return doc }, ], afterRefresh: [ async ({ doc, req }) => { // Custom token refresh logic return doc }, ], afterForgotPassword: [ async ({ args }) => { // Custom forgot password notification }, ], }, fields: [...], } ``` ## Hook Arguments Reference All hooks receive these base arguments: | Argument | Description | |----------|-------------| | `req` | Request object with `payload`, `user`, `locale` | | `context` | Shared context object between hooks | | `collection` | Collection config | Operation-specific arguments: | Hook | Additional Arguments | |------|---------------------| | `beforeValidate` | `data`, `operation`, `originalDoc` | | `beforeChange` | `data`, `operation`, `originalDoc` | | `afterChange` | `doc`, `operation`, `previousDoc` | | `beforeRead` | `doc` | | `afterRead` | `doc` | | `beforeDelete` | `id` | | `afterDelete` | `doc`, `id` | ## Best Practices 1. **Always return the data/doc** - Even if unchanged 2. **Use context for loop prevention** - Check before triggering recursive operations 3. **Pass req for transactions** - Maintains atomicity 4. **Keep hooks focused** - One responsibility per hook 5. **Use field hooks for field-specific logic** - Better encapsulation 6. **Avoid heavy operations in beforeRead** - Runs on every query 7. **Use afterChange for side effects** - Email, webhooks, etc. -e --- # Access Control Reference ## Overview Access control functions determine WHO can do WHAT with documents: ```ts type Access = (args: AccessArgs) => boolean | Where | Promise ``` Returns: - `true` - Full access - `false` - No access - `Where` query - Filtered access (row-level security) ## Collection-Level Access ```ts export const Posts: CollectionConfig = { slug: 'posts', access: { create: isLoggedIn, read: isPublishedOrAdmin, update: isAdminOrAuthor, delete: isAdmin, }, fields: [...], } ``` ## Common Patterns ### Public Read, Admin Write ```ts const isAdmin: Access = ({ req }) => { return req.user?.roles?.includes('admin') ?? false } const isLoggedIn: Access = ({ req }) => { return !!req.user } access: { create: isLoggedIn, read: () => true, // Public update: isAdmin, delete: isAdmin, } ``` ### Row-Level Security (User's Own Documents) ```ts const ownDocsOnly: Access = ({ req }) => { if (!req.user) return false // Admins see everything if (req.user.roles?.includes('admin')) return true // Others see only their own return { author: { equals: req.user.id }, } } access: { read: ownDocsOnly, update: ownDocsOnly, delete: ownDocsOnly, } ``` ### Complex Queries ```ts const publishedOrOwn: Access = ({ req }) => { // Not logged in: published only if (!req.user) { return { status: { equals: 'published' } } } // Admin: see all if (req.user.roles?.includes('admin')) return true // Others: published OR own drafts return { or: [ { status: { equals: 'published' } }, { author: { equals: req.user.id } }, ], } } ``` ## Field-Level Access Control access to specific fields: ```ts { name: 'internalNotes', type: 'textarea', access: { read: ({ req }) => req.user?.roles?.includes('admin'), update: ({ req }) => req.user?.roles?.includes('admin'), }, } ``` ### Hide Field Completely ```ts { name: 'secretKey', type: 'text', access: { read: () => false, // Never returned in API update: ({ req }) => req.user?.roles?.includes('admin'), }, } ``` ## Access Control Arguments ```ts type AccessArgs = { req: PayloadRequest id?: string | number // Document ID (for update/delete) data?: Record // Incoming data (for create/update) } ``` ## RBAC (Role-Based Access Control) ```ts // Define roles type Role = 'admin' | 'editor' | 'author' | 'subscriber' // Helper functions const hasRole = (req: PayloadRequest, role: Role): boolean => { return req.user?.roles?.includes(role) ?? false } const hasAnyRole = (req: PayloadRequest, roles: Role[]): boolean => { return roles.some(role => hasRole(req, role)) } // Use in access control const canEdit: Access = ({ req }) => { return hasAnyRole(req, ['admin', 'editor']) } const canPublish: Access = ({ req }) => { return hasAnyRole(req, ['admin', 'editor']) } const canDelete: Access = ({ req }) => { return hasRole(req, 'admin') } ``` ## Multi-Tenant Access ```ts // Users belong to organizations const sameOrgOnly: Access = ({ req }) => { if (!req.user) return false // Super admin sees all if (req.user.roles?.includes('super-admin')) return true // Others see only their org's data return { organization: { equals: req.user.organization }, } } // Apply to collection access: { create: ({ req }) => !!req.user, read: sameOrgOnly, update: sameOrgOnly, delete: sameOrgOnly, } ``` ## Global Access For singleton documents: ```ts export const Settings: GlobalConfig = { slug: 'settings', access: { read: () => true, update: ({ req }) => req.user?.roles?.includes('admin'), }, fields: [...], } ``` ## Important: Local API Access Control **Local API bypasses access control by default!** ```ts // ❌ SECURITY BUG: Access control bypassed await payload.find({ collection: 'posts', user: someUser, }) // ✅ SECURE: Explicitly enforce access control await payload.find({ collection: 'posts', user: someUser, overrideAccess: false, // REQUIRED }) ``` ## Access Control with req.context Share state between access checks and hooks: ```ts const conditionalAccess: Access = ({ req }) => { // Check context set by middleware or previous operation if (req.context?.bypassAuth) return true return req.user?.roles?.includes('admin') } ``` ## Best Practices 1. **Default to restrictive** - Start with `false`, add permissions 2. **Use query constraints for row-level** - More efficient than filtering after 3. **Keep logic in reusable functions** - DRY across collections 4. **Test with different user types** - Admin, regular user, anonymous 5. **Remember Local API default** - Always use `overrideAccess: false` for user-facing operations 6. **Document your access rules** - Complex logic needs comments -e --- # Queries Reference ## Local API ### Find Multiple ```ts const result = await payload.find({ collection: 'posts', where: { status: { equals: 'published' }, }, limit: 10, page: 1, sort: '-createdAt', depth: 2, }) // Result structure { docs: Post[], totalDocs: number, limit: number, totalPages: number, page: number, pagingCounter: number, hasPrevPage: boolean, hasNextPage: boolean, prevPage: number | null, nextPage: number | null, } ``` ### Find By ID ```ts const post = await payload.findByID({ collection: 'posts', id: '123', depth: 2, }) ``` ### Create ```ts const newPost = await payload.create({ collection: 'posts', data: { title: 'New Post', content: '...', author: userId, }, user: req.user, // For access control }) ``` ### Update ```ts const updated = await payload.update({ collection: 'posts', id: '123', data: { title: 'Updated Title', }, }) ``` ### Delete ```ts const deleted = await payload.delete({ collection: 'posts', id: '123', }) ``` ## Query Operators ### Comparison ```ts where: { price: { equals: 100 }, price: { not_equals: 100 }, price: { greater_than: 100 }, price: { greater_than_equal: 100 }, price: { less_than: 100 }, price: { less_than_equal: 100 }, } ``` ### String Operations ```ts where: { title: { like: 'Hello' }, // Case-insensitive contains title: { contains: 'world' }, // Case-sensitive contains email: { exists: true }, // Field has value } ``` ### Array Operations ```ts where: { tags: { in: ['tech', 'design'] }, // Value in array tags: { not_in: ['spam'] }, // Value not in array tags: { all: ['featured', 'popular'] }, // Has all values } ``` ### AND/OR Logic ```ts where: { and: [ { status: { equals: 'published' } }, { author: { equals: userId } }, ], } where: { or: [ { status: { equals: 'published' } }, { author: { equals: userId } }, ], } // Nested where: { and: [ { status: { equals: 'published' } }, { or: [ { featured: { equals: true } }, { 'author.roles': { in: ['admin'] } }, ], }, ], } ``` ### Nested Properties Query through relationships: ```ts where: { 'author.name': { contains: 'John' }, 'category.slug': { equals: 'tech' }, } ``` ### Geospatial Queries ```ts where: { location: { near: [-73.935242, 40.730610, 10000], // [lng, lat, maxDistanceMeters] }, } where: { location: { within: { type: 'Polygon', coordinates: [[[-74, 40], [-73, 40], [-73, 41], [-74, 41], [-74, 40]]], }, }, } ``` ## Field Selection Only fetch specific fields: ```ts const posts = await payload.find({ collection: 'posts', select: { title: true, slug: true, author: true, // Will be populated based on depth }, }) ``` ## Depth (Relationship Population) ```ts // depth: 0 - IDs only { author: '123' } // depth: 1 - First level populated { author: { id: '123', name: 'John' } } // depth: 2 (default) - Nested relationships populated { author: { id: '123', name: 'John', avatar: { url: '...' } } } ``` ## Pagination ```ts // Page-based await payload.find({ collection: 'posts', page: 2, limit: 20, }) // Cursor-based (more efficient for large datasets) await payload.find({ collection: 'posts', where: { createdAt: { greater_than: lastCursor }, }, limit: 20, sort: 'createdAt', }) ``` ## Sorting ```ts // Single field sort: 'createdAt' // Ascending sort: '-createdAt' // Descending // Multiple fields sort: ['-featured', '-createdAt'] ``` ## Access Control in Local API **CRITICAL: Local API bypasses access control by default!** ```ts // ❌ INSECURE: Access control bypassed await payload.find({ collection: 'posts', user: someUser, // User is ignored! }) // ✅ SECURE: Access control enforced await payload.find({ collection: 'posts', user: someUser, overrideAccess: false, // REQUIRED }) ``` ## REST API ### Endpoints ``` GET /api/{collection} # Find GET /api/{collection}/{id} # Find by ID POST /api/{collection} # Create PATCH /api/{collection}/{id} # Update DELETE /api/{collection}/{id} # Delete ``` ### Query String ``` GET /api/posts?where[status][equals]=published&limit=10&sort=-createdAt&depth=2 ``` ### Nested Queries ``` GET /api/posts?where[author.name][contains]=John ``` ### Complex Queries ``` GET /api/posts?where[or][0][status][equals]=published&where[or][1][author][equals]=123 ``` ## GraphQL API ### Query ```graphql query { Posts( where: { status: { equals: published } } limit: 10 sort: "-createdAt" ) { docs { id title author { name } } totalDocs } } ``` ### Mutation ```graphql mutation { createPost(data: { title: "New Post", status: draft }) { id title } } ``` ## Draft Queries ```ts // Published only (default) await payload.find({ collection: 'posts' }) // Include drafts await payload.find({ collection: 'posts', draft: true, }) ``` ## Count Only ```ts const count = await payload.count({ collection: 'posts', where: { status: { equals: 'published' } }, }) // Returns: { totalDocs: number } ``` ## Distinct Values ```ts const categories = await payload.find({ collection: 'posts', select: { category: true }, // Then dedupe in code }) ``` ## Performance Tips 1. **Use indexes** - Add `index: true` to frequently queried fields 2. **Limit depth** - Lower depth = faster queries 3. **Select specific fields** - Don't fetch what you don't need 4. **Use pagination** - Never fetch all documents 5. **Avoid nested OR queries** - Can be slow on large collections 6. **Use count for totals** - Faster than fetching all docs -e --- # Advanced Features Reference ## Jobs Queue Background task processing: ### Define Tasks ```ts // payload.config.ts export default buildConfig({ jobs: { tasks: [ { slug: 'sendEmail', handler: async ({ payload, job }) => { const { to, subject, body } = job.input await sendEmail({ to, subject, body }) }, inputSchema: { to: { type: 'text', required: true }, subject: { type: 'text', required: true }, body: { type: 'text', required: true }, }, }, { slug: 'generateThumbnails', handler: async ({ payload, job }) => { const { mediaId } = job.input // Process images... }, }, ], }, }) ``` ### Queue Jobs ```ts // In a hook or endpoint await payload.jobs.queue({ task: 'sendEmail', input: { to: 'user@example.com', subject: 'Welcome!', body: 'Thanks for signing up.', }, }) ``` ### Run Jobs ```bash # In production, run job worker payload jobs:run ``` ## Custom Endpoints ### Collection Endpoints ```ts export const Posts: CollectionConfig = { slug: 'posts', endpoints: [ { path: '/publish/:id', method: 'post', handler: async (req) => { const { id } = req.routeParams const doc = await req.payload.update({ collection: 'posts', id, data: { status: 'published', publishedAt: new Date(), }, req, overrideAccess: false, // Respect permissions }) return Response.json({ success: true, doc }) }, }, { path: '/stats', method: 'get', handler: async (req) => { const total = await req.payload.count({ collection: 'posts' }) const published = await req.payload.count({ collection: 'posts', where: { status: { equals: 'published' } }, }) return Response.json({ total: total.totalDocs, published: published.totalDocs, }) }, }, ], } ``` ### Global Endpoints ```ts // payload.config.ts export default buildConfig({ endpoints: [ { path: '/health', method: 'get', handler: async () => { return Response.json({ status: 'ok' }) }, }, ], }) ``` ## Plugins ### Using Plugins ```ts import { buildConfig } from 'payload' import { seoPlugin } from '@payloadcms/plugin-seo' import { formBuilderPlugin } from '@payloadcms/plugin-form-builder' export default buildConfig({ plugins: [ seoPlugin({ collections: ['posts', 'pages'], uploadsCollection: 'media', }), formBuilderPlugin({ fields: { text: true, email: true, textarea: true, }, }), ], }) ``` ### Creating Plugins ```ts import type { Config, Plugin } from 'payload' type MyPluginOptions = { enabled?: boolean collections?: string[] } export const myPlugin = (options: MyPluginOptions): Plugin => { return (incomingConfig: Config): Config => { const { enabled = true, collections = [] } = options if (!enabled) return incomingConfig return { ...incomingConfig, collections: (incomingConfig.collections || []).map((collection) => { if (!collections.includes(collection.slug)) return collection return { ...collection, fields: [ ...collection.fields, { name: 'pluginField', type: 'text', admin: { position: 'sidebar' }, }, ], } }), } } } ``` ## Localization ### Enable Localization ```ts export default buildConfig({ localization: { locales: [ { label: 'English', code: 'en' }, { label: 'Spanish', code: 'es' }, { label: 'French', code: 'fr' }, ], defaultLocale: 'en', fallback: true, }, }) ``` ### Localized Fields ```ts { name: 'title', type: 'text', localized: true, // Enable per-locale values } ``` ### Query by Locale ```ts // Local API const posts = await payload.find({ collection: 'posts', locale: 'es', }) // REST API GET /api/posts?locale=es // Get all locales const posts = await payload.find({ collection: 'posts', locale: 'all', }) ``` ## Custom Components ### Field Components ```ts // components/CustomTextField.tsx 'use client' import { useField } from '@payloadcms/ui' export const CustomTextField: React.FC = () => { const { value, setValue } = useField() return ( setValue(e.target.value)} /> ) } // In field config { name: 'customField', type: 'text', admin: { components: { Field: '/components/CustomTextField', }, }, } ``` ### Custom Views ```ts // Add custom admin page admin: { components: { views: { Dashboard: '/components/CustomDashboard', }, }, } ``` ## Authentication ### Custom Auth Strategies ```ts export const Users: CollectionConfig = { slug: 'users', auth: { strategies: [ { name: 'api-key', authenticate: async ({ headers, payload }) => { const apiKey = headers.get('x-api-key') if (!apiKey) return { user: null } const user = await payload.find({ collection: 'users', where: { apiKey: { equals: apiKey } }, }) return { user: user.docs[0] || null } }, }, ], }, } ``` ### Token Customization ```ts auth: { tokenExpiration: 7200, // 2 hours cookies: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: process.env.COOKIE_DOMAIN, }, } ``` ## Database Adapters ### MongoDB ```ts import { mongooseAdapter } from '@payloadcms/db-mongodb' db: mongooseAdapter({ url: process.env.DATABASE_URL, transactionOptions: { maxCommitTimeMS: 30000, }, }) ``` ### PostgreSQL ```ts import { postgresAdapter } from '@payloadcms/db-postgres' db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL, }, }) ``` ## Storage Adapters ### S3 ```ts import { s3Storage } from '@payloadcms/storage-s3' plugins: [ s3Storage({ collections: { media: true }, bucket: process.env.S3_BUCKET, config: { credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY, }, region: process.env.S3_REGION, }, }), ] ``` ### Vercel Blob ```ts import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob' plugins: [ vercelBlobStorage({ collections: { media: true }, token: process.env.BLOB_READ_WRITE_TOKEN, }), ] ``` ## Email Adapters ```ts import { nodemailerAdapter } from '@payloadcms/email-nodemailer' email: nodemailerAdapter({ defaultFromAddress: 'noreply@example.com', defaultFromName: 'My App', transport: { host: process.env.SMTP_HOST, port: 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }, }) ```