--- tags: [payloadcms, tech-patterns] topic: payloadcms sources: [plugins__plugin-api.md, plugins__build-your-own.md] created: 2026-05-15 --- # PayloadCMS — Plugin API ## Overview - A plugin is a function: `(incomingConfig: Config) => Config` - Core contract is permanent — simple functions always work - Advanced API adds execution ordering (`order`) and typed cross-plugin communication (`RegisteredPlugins`) - Advanced API is **experimental** — safe to use but surface may change - See [[wiki/payloadcms/plugins|Official Plugins]] for ready-made plugins ## Plugin Function Signature **Minimal (always works):** ```ts import type { Config, Plugin } from 'payload' export const myPlugin = (opts: MyOptions): Plugin => (incomingConfig: Config): Config => ({ ...incomingConfig, collections: [...(incomingConfig.collections || []), myCollection], }) ``` **With `definePlugin` (recommended for published packages):** ```ts import { definePlugin } from 'payload' export const myPlugin = definePlugin({ slug: 'my-plugin', // unique identifier for cross-plugin communication order: 10, // lower = runs first; default 0 plugin: ({ config, plugins, ...opts }) => ({ ...config, collections: [...(config.collections || []), myCollection], }), }) // Usage in payload.config.ts: plugins: [myPlugin({ /* options */ })] ``` ## What Plugins Can Do By spreading the incoming config and returning a modified version, plugins can: - Add or modify **collections** and **globals** - Inject **fields** into existing collections - Register **[[wiki/payloadcms/hooks|Hooks]]** (beforeChange, afterRead, etc.) - Add custom **endpoints** - Add custom **admin views** and components - Extend `onInit` (must call existing `onInit` first) - Add **GraphQL** queries/mutations - Add `custom` metadata to the config ## Spread Syntax — Critical Rule Always spread existing arrays/objects; never replace them: ```ts // Collections config.collections = [ ...(config.collections || []), newCollection, ] // Globals config.globals = [ ...(config.globals || []), newGlobal, ] // Hooks (object — spread differently) config.hooks = { ...(config.hooks || {}), afterError: [...(config.hooks?.afterError || []), myHook], } // onInit — must chain, cannot spread config.onInit = async (payload) => { if (incomingConfig.onInit) await incomingConfig.onInit(payload) // your logic here } ``` ## Example Plugin Full lifecycle example — adds a `lastModifiedBy` relationship field to all auth-enabled collections: ```ts import type { Config, Plugin } from 'payload' export const addLastModified: Plugin = (incomingConfig: Config): Config => { const authCollections = incomingConfig.collections.filter( (c) => Boolean(c.auth), ) return { ...incomingConfig, collections: incomingConfig.collections.map((collection) => ({ ...collection, fields: [ ...collection.fields, { name: 'lastModifiedBy', type: 'relationship', relationTo: authCollections.map(({ slug }) => slug), hooks: { beforeChange: [ ({ req }) => ({ value: req?.user?.id, relationTo: req?.user?.collection, }), ], }, admin: { position: 'sidebar', readOnly: true }, }, ], })), } } ``` ## Execution Ordering with `order` ``` Negative → must run before everything (polyfills, normalization) 0 → default — no dependencies 10–50 → depends on collections/fields added by other plugins 100+ → must run last (audit, introspection, final-config plugins) ``` ```ts plugins: [ analyticsPlugin(), // order: 10 — runs second basePlugin(), // order: 1 — runs first (regardless of array position) ] ``` ## Cross-Plugin Communication Pattern: a plugin exposes its options; another plugin mutates those options before the first runs. ```ts // writer plugin (order: 1) — runs first, injects into reader's options export const writerPlugin = definePlugin({ slug: 'my-writer', order: 1, plugin: ({ config, plugins }) => { plugins['my-reader']?.options?.items.push({ name: 'injected' }) return config }, }) // reader plugin (order: 10) — runs second, sees injected item export const readerPlugin = definePlugin<{ items: Array<{ name: string }> }>({ slug: 'my-reader', order: 10, plugin: ({ config, items }) => ({ ...config, custom: { ...config.custom, items: items.map((i) => i.name) }, }), }) // payload.config.ts plugins: [readerPlugin({ items: [{ name: 'user-provided' }] }), writerPlugin()] // Result: reader sees ['user-provided', 'injected'] ``` ## TypeScript: `RegisteredPlugins` Augmentation Plugin packages can register their slug and options type so the `plugins` map is fully typed — no codegen, activated at import time: ```ts // packages/my-plugin/src/index.ts export type MyPluginOptions = { collections: string[] } export const myPlugin = definePlugin({ slug: 'my-plugin', order: 10, plugin: ({ config, collections }) => ({ ...config }), }) declare module 'payload' { interface RegisteredPlugins { 'my-plugin': MyPluginOptions } } ``` Any project that imports `my-plugin` gets typed access in the `plugins` map — `plugins['my-plugin']?.options?.collections` is typed as `string[]`. ## Plugin Template Scaffold a new plugin with the official template: ```bash npx create-payload-app@latest --template plugin ``` Template structure: - `/` — root config (package.json, README, tsconfig) - `/src` — plugin source code - `/dev` — local Payload dev environment for testing Start dev: ```bash cd dev && pnpm dev # http://localhost:3000 ``` Run tests (Jest): ```bash cd dev && pnpm test ``` ## Seeding Data in Dev Environment For development and testing, seed the database via `PAYLOAD_SEED=true`: ```ts // dev/src/server.ts if (process.env.PAYLOAD_SEED === 'true') { await seed(payload) } // dev/src/seed.ts export const seed = async (payload: Payload): Promise => { payload.logger.info('Seeding data...') await payload.create({ collection: 'new-collection', data: { title: 'Seeded title' }, }) } ``` ## Best Practices - **Enable/disable option** — provide `enabled?: boolean` so users can disable without uninstalling - **TypeScript types** — export `PluginTypes` interface with JSDoc comments for IDE support - **Test suite** — Jest in `dev/` folder; integrate into GitHub CI - **SemVer** — major version should indicate Payload compatibility - **npm publish** — publish with `payload-plugin` GitHub topic for discoverability - **Spread everything** — never mutate incoming config directly; always spread - **Chain functions** — for `onInit`, `onInit`, etc. call the existing function first ## Checking If a Plugin Is Installed From outside a plugin: ```ts const hasMySeoPlugin = config.plugins?.some((p) => p.slug === 'plugin-seo') ?? false ``` From inside a `definePlugin` function: ```ts plugin: ({ config, plugins }) => { if (plugins['plugin-seo']) { // plugin-seo is installed — safe to mutate its options } return config } ``` ## Gotchas - Plugins execute **after** config validation but **before** defaults are merged — avoid relying on default values - If you override `name` of `parent` or `breadcrumbs` fields in nested-docs, set `parentFieldSlug`/`breadcrumbsFieldSlug` accordingly - `onInit` must always chain the existing function or other plugins' `onInit` logic is lost - Cross-plugin mutation works because options are resolved before any plugin executes — no re-execution needed - `definePlugin` result is a factory; call it with options to get a `Plugin`: `myPlugin({ option: true })` - Initialization order: validate → plugins execute → defaults merge → sanitize → final config ## Related - [[wiki/payloadcms/plugins|Official Plugins]] - [[wiki/payloadcms/configuration|Configuration]] - [[wiki/payloadcms/hooks|Hooks]] - [[wiki/payloadcms/collections|Collections]]