285 lines
7.9 KiB
Markdown
285 lines
7.9 KiB
Markdown
---
|
||
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<MyOptions>({
|
||
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<MyPluginOptions>({
|
||
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<void> => {
|
||
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]]
|