obsidian/wiki/payloadcms/plugins-api.md
2026-05-15 16:15:30 +01:00

285 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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
1050 → 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]]