payloadcms / payloadcms/payload
Operation args missing from collection and global hooks
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 44.8k
- Forks
- 4.2k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 53
Description
Describe the Bug
Collection and global hooks (beforeValidate, beforeChange, afterChange, beforeRead, afterRead, beforeDelete, afterDelete) do not receive operation-level arguments like draft, autosave, trash, or select. Most of these represent caller intent — what the operation is trying to do — and are distinct from document state (e.g., _status: 'draft'). select is the same problem in a different shape: a read hook cannot see what the caller projected, so it cannot decide whether the work it exists to do was even asked for.
This is inconsistent: beforeOperation and afterOperation hooks already receive the full args object containing all operation arguments. Field-level afterRead hooks also receive draft. But the most commonly used hooks — beforeChange, afterChange, beforeRead, afterRead — do not.
Why this is a bug, not a feature request:
-
Inconsistency in the hook API.
beforeOperation/afterOperationget full args, all other hooks get an incomplete hand-picked subset. There's no documented reason for the difference. -
The
contextworkaround is unsafe. The suggested workaround is to stash values incontextviabeforeOperation, then read them in other hooks. Butcontextis a shared mutable object — if a hook triggers a nested operation (e.g.,payload.update()insideafterChange), that nested operation'sbeforeOperationhook overwrites the samecontextkeys, corrupting the values for the outer operation. This is a race condition by design. -
Intent vs. state divergence causes incorrect behavior. For example, a
beforeChangehook cannot distinguish between:- An update that publishes a draft (
draft: false, but_statusis still'draft'on the incoming doc) - An update that saves a new draft (
draft: true) - An autosave (
autosave: true) where you'd want to skip side effects like sending notifications - A soft-delete/trash operation (
trash: true) where the update is just settingdeletedAt
- An update that publishes a draft (
-
A read hook cannot skip work nobody asked for. A plugin that computes virtual fields in
beforeReadhas to know whether the caller selected them. With noselectin the hook args, the only channel isbeforeOperation→context— and because a request outlives the operation that wrote it, the mark has to be kept per collection and can never be cleared (clearing it races with a concurrent operation on the same collection, turning wasted work into a missing field). That is a lot of machinery to replace one argument.
Current hook arg availability:
| Hook | draft |
autosave |
trash |
select |
Full args |
|---|---|---|---|---|---|
beforeOperation |
via args |
via args |
via args |
via args |
yes |
afterOperation |
via args |
via args |
via args |
via args |
yes |
beforeValidate |
no | no | no | no | no |
beforeChange |
no | no | no | no | no |
afterChange |
no | no | no | no | no |
beforeRead |
no | n/a | no | no | no |
afterRead |
no | n/a | no | no | no |
beforeDelete |
n/a | n/a | no | n/a | no |
afterDelete |
n/a | n/a | no | n/a | no |
Field afterRead |
yes | no | no | no | no |
Race condition example with context workaround:
const myCollection: CollectionConfig = {
hooks: {
beforeOperation: [
({ args, context }) => {
context.draft = args.draft // stash for later hooks
},
],
afterChange: [
async ({ doc, req, context }) => {
// context.draft is correct here... unless:
await req.payload.update({
collection: 'other-collection',
id: doc.relatedId,
data: { synced: true },
// This triggers OTHER collection's beforeOperation,
// which may overwrite context.draft
})
// context.draft may now reflect the INNER operation's value, not ours
},
],
},
}
Proposed fix: Add args as a property to all hook types — the same full operation args object that beforeOperation/afterOperation already receive. This is backwards compatible (new optional property) and future-proof (new operation args are automatically available without threading them individually).
hooks: {
beforeChange: [({ data, operation, req, args }) => {
if (args.draft) { /* draft save — skip external sync */ }
if (args.autosave) { /* autosave — skip notifications */ }
}],
}
Link to the code that reproduces this issue
https://github.com/payloadcms/payload/blob/main/packages/payload/src/collections/config/types.ts
Reproduction Steps
- Create a collection with
versions: { drafts: true }and abeforeChangehook - In the hook, try to access the
draftargument:({ draft }) => { console.log(draft) } - Call
payload.create({ collection: 'my-collection', data: { title: 'test' }, draft: true }) - Observe that
draftisundefinedin the hook — the argument is not forwarded - Same issue with
autosavein write hooks andtrashin delete/update hooks
Which area(s) are affected?
area: core
Environment Info
Binaries:
Node: 24.3.0
npm: 11.4.2
Yarn: 1.22.22
pnpm: 10.29.3
Relevant Packages:
payload: 3.79.0
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.6.0: Mon Jan 19 22:01:58 PST 2026; root:xnu-11417.140.69.708.3~1/RELEASE_ARM64_T6041
Available memory (MB): 24576
Available CPU cores: 14
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/payload/src/collections/config/types.ts and trace the hook type definitions and the existing beforeOperation/afterOperation args. Verify how collection hooks receive their arguments, then ensure the affected hooks expose the full operation args and confirm the documented draft, autosave, trash, and select cases are covered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100