payloadcms / payloadcms/payload

Operation args missing from collection and global hooks

Open
#16,180 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area: core created-by: Contributor status: needs-triage
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:

  1. Inconsistency in the hook API. beforeOperation/afterOperation get full args, all other hooks get an incomplete hand-picked subset. There's no documented reason for the difference.

  2. The context workaround is unsafe. The suggested workaround is to stash values in context via beforeOperation, then read them in other hooks. But context is a shared mutable object — if a hook triggers a nested operation (e.g., payload.update() inside afterChange), that nested operation's beforeOperation hook overwrites the same context keys, corrupting the values for the outer operation. This is a race condition by design.

  3. Intent vs. state divergence causes incorrect behavior. For example, a beforeChange hook cannot distinguish between:

    • An update that publishes a draft (draft: false, but _status is 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 setting deletedAt
  4. A read hook cannot skip work nobody asked for. A plugin that computes virtual fields in beforeRead has to know whether the caller selected them. With no select in the hook args, the only channel is beforeOperationcontext — 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
  1. Create a collection with versions: { drafts: true } and a beforeChange hook
  2. In the hook, try to access the draft argument: ({ draft }) => { console.log(draft) }
  3. Call payload.create({ collection: 'my-collection', data: { title: 'test' }, draft: true })
  4. Observe that draft is undefined in the hook — the argument is not forwarded
  5. Same issue with autosave in write hooks and trash in 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.