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