Fixes https://github.com/payloadcms/payload/discussions/14452
* Adds `collection.lockDocuments: false` to the KV storage collection if
the adapter has one.
* Filters `relationTo` for the locked documents collection with removing
collections that have `lockDocuments: false` in their config.
### What
Adds new UI feature and update to the duplicate endpoint to support to
duplicating only select locales.
### Why
Currently, duplicating a document always copies all locales. To exclude
some, users must duplicate the full document and manually clear fields.
This feature was requested by the community and is part of planned
localization improvements.
### How
The new control opens a drawer where users can pick one or more locales
to duplicate. The duplicate endpoint now accepts `selectLocales` and
automatically clears any unselected locales before creating the new
draft document.
#### Feature tracked here:
https://github.com/payloadcms/payload/discussions/13444
---------
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Adds access control for jobs `queue` and `cancel` operations, similar to
what we have for `run` operations.
**Implementation:**
Both operations now support `overrideAccess` parameter (defaults to
`true`) and respect `jobsConfig.access.queue` and
`jobsConfig.access.cancel` functions. When `overrideAccess: false` is
passed without an authenticated request, operations throw `Forbidden`
error.
**Configuration:**
Access control functions can be defined in the Jobs Config under
`jobs.access.queue`, `jobs.access.run`, and `jobs.access.cancel`. Each
function receives `{ req }` and returns a boolean. If no custom access
control is defined, the default allows any authenticated user to perform
the operation.
**Example:**
```ts
// Configure access control
jobs: {
access: {
queue: ({ req }) => req.user?.roles?.includes('admin'),
cancel: ({ req }) => req.user?.roles?.includes('admin'),
}
}
// Use in Local API
await payload.jobs.cancel({
where: { workflowSlug: { equals: 'sync' } },
overrideAccess: false,
req,
})
```
This PR enables you to add timezone specific config on a per field basis
so each field can support its own list of timezones
```ts
{
name: 'date',
type: 'date',
timezone: {
defaultTimezone: 'America/New_York',
supportedTimezones: [
{ label: 'New York', value: 'America/New_York' },
{ label: 'Los Angeles', value: 'America/Los_Angeles' },
{ label: 'London', value: 'Europe/London' },
],
},
}
```
You can also enforce a specific timezone by specifying just one with a
default value:
```ts
{
name: 'date',
type: 'date',
timezone: {
defaultTimezone: 'Europe/London',
supportedTimezones: [
{ label: 'London', value: 'Europe/London' },
],
},
}
```
It also fixes a bug with date fields in list view not showing the date
in the formatted timezone correctly.
Adds KV storage support to Payload Local API with an adapter pattern,
includes 3 adapters. We'll use it for the Realtime API.
You can access it via `payload.kv`:
```ts
interface KVAdapter {
/**
* Clears all entries in the store.
* @returns A promise that resolves once the store is cleared.
*/
clear(): Promise<void>
/**
* Deletes a value from the store by its key.
* @param key - The key to delete.
* @returns A promise that resolves once the key is deleted.
*/
delete(key: string): Promise<void>
/**
* Retrieves a value from the store by its key.
* @param key - The key to look up.
* @returns A promise that resolves to the value, or `null` if not found.
*/
get(key: string): Promise<KVStoreValue | null>
/**
* Checks if a key exists in the store.
* @param key - The key to check.
* @returns A promise that resolves to `true` if the key exists, otherwise `false`.
*/
has(key: string): Promise<boolean>
/**
* Retrieves all the keys in the store.
* @returns A promise that resolves to an array of keys.
*/
keys(): Promise<string[]>
/**
* Sets a value in the store with the given key.
* @param key - The key to associate with the value.
* @param value - The value to store.
* @returns A promise that resolves once the value is stored.
*/
set(key: string, value: KVStoreValue): Promise<void>
}
```
To configure the adapter you can use the `kv` property of the root
config.
```ts
buildConfig({
kv: adapter()
})
```
#### Database KV adapter (default)
No need to configure, as Payload uses it by default. It generates new
collection `payload-kv` and uses the current database adapter to access
it. The collection is hidden in the admin panel and access locked.
If you want to override the generated collection:
```ts
import { databaseKVAdapter } from 'payload'
buildConfig({
kv: databaseKVAdapter({
kvCollectionOverrides: {
slug: 'custom-kv',
...(process.env.DEBUG === 'true' && {
admin: { hidden: false },
access: {},
}),
},
}),
})
```
#### In Memory KV adapter
Simple and very fast storage using memory. Don't use it on Vercel /
multiple instances or if you need data persistence.
```ts
import { inMemoryKVAdapter } from 'payload'
buildConfig({
kv: inMemoryKVAdapter(),
})
```
#### Redis KV Adapter
Uses Redis. Probably the best option as it's faster than database,
persistent and works with Vercel / multiple instances, but requires
additional setup
```sh
pnpm add @payloadcms/kv-redis
```
```ts
import { redisKVAdapter } from '@payloadcms/kv-redis'
buildConfig({
kv: redisKVAdapter({
keyPrefix: "custom-prefix:", // defaults to 'payload-kv:'
redisURL: "redis://127.0.0.1:6379" // defaults to process.env.REDIS_URL (Vercel generates this variable for you if you connect a project to Redis)
}),
})
```
### What?
Fixes an issue where globals with drafts/versions enabled would return
the `_status` field with its default value (`'draft'`) when access
control denied read access, instead of returning an empty object.
### Why?
When a global has:
1. Versions/drafts enabled (which adds the `_status` field with
`defaultValue: 'draft'`)
2. Read access control with a where clause (e.g., `return {_status:
{equals: 'published'}}`)
And a user without permissions requests the global (e.g., an unpublished
draft exists), the API was returning `{"_status": "draft"}` instead of
an empty object `{}`. This exposes the document's status even when
access is denied.
The issue occurred because when the DB query returned `null` (filtered
by access control), the `findOne` operation would set `doc = {}`, and
then the `afterRead` hook would populate default values including
`_status: 'draft'`.
### How?
- Modified `afterRead/promise.ts` to skip setting default values for
globals when:
- The document has no `id` (indicating it doesn't exist or was filtered)
- AND access control is active (`!overrideAccess`)
Fixes#14096
Similar to how we deprecated the `runHooks` property in
https://github.com/payloadcms/payload/pull/13617, this PR deprecates the
`depth` property.
Just like `runHooks`, setting depth to 1 or higher will massively slow
down the job system and make it less reliable. It's also adds a lot of
unnecessary code we will need to maintain.
To further discourage using this property, this PR marks it as
deprecated and for removal in 4.0.
Fixes https://github.com/payloadcms/payload/issues/14379
Additionally, removes duplication of `findMigrationDir` between DB
adapters, deprecates the export from `@payloadcms/drizzle` and adds unit
testing to this function.
Fixes https://github.com/payloadcms/payload/issues/14299
Using `filterAvailableLocales` means they could change when the req
changes. When we get the client config from the cache, we need to
re-apply `filterAvailableLocales` so the config has the correct values.
This was manually being done in the Root layout, but every other place
that called getClientConfig was not i.e. the Root view. So this PR moves
that logic from the Root layout into the getClientConfig function.
Fixes#14241 and #11253.
Preview URL is now part of the form state as well so we can
conditionally show the preview button based on its value, previously
this was not possible because we were fetching the URL on click. Similar
to #14012.
Adds check in `lint-staged` to see if the top-level README.md has been
modified, if so, it will copy it to `packages/payload/README.md`. This
is to ensure the README shown on the [npm package
page](https://www.npmjs.com/package/payload) is proper.
✅ This PR also performs the sync
### What?
The `updatedAt` field on user documents was being updated whenever
session operations occurred (login, logout, or token refresh), even
though only session metadata was changing and not the actual user data
itself.
### Why?
This behavior was introduced in PR #13335, which added explicit
`updatedAt = new Date().toISOString()` to all session operations to
ensure that `updatedAt` would always be bumped when using
`payload.db.updateOne()`.
However, this was incorrect for session operations specifically. The
`updatedAt` timestamp should only change when the user's actual data
(email, password, profile fields, etc.) is modified - not when internal
session tracking metadata changes. Session management is essentially
bookkeeping and shouldn't trigger timestamp updates that suggest the
user entity itself was modified.
### How?
Modified session operations to set `updatedAt = null` instead of
updating it:
- `addSessionToUser()` in `packages/payload/src/auth/sessions.ts`
- `logoutOperation()` in
`packages/payload/src/auth/operations/logout.ts`
- `refreshOperation()` in
`packages/payload/src/auth/operations/refresh.ts`
Setting `updatedAt = null` skips updating this field entirely, leaving
the existing timestamp unchanged.
Fixes#13626
### What?
Fixes TypeScript types for `payload.create()` to allow omitting required
fields when `draft: true` is specified, aligning type checking with
runtime validation behavior.
### Why?
According to the
[documentation](https://payloadcms.com/docs/versions/drafts#updating-or-creating-drafts),
when creating drafts, required fields don't need to be provided because
validation is skipped by default (unless `versions.drafts.validate` is
explicitly set to `true`). However, TypeScript was still enforcing
required fields at the type level, causing type errors even when `draft:
true` was set.
### How?
- Added new types `DraftDataFromCollectionSlug` and
`DraftDataFromCollection` that make all fields optional
- Changed `payload.create` options to use a discriminated union type:
- When `draft: true` → uses `DraftDataFromCollectionSlug` (all fields
optional)
- When `draft: false` or omitted → uses `RequiredDataFromCollectionSlug`
(required fields enforced)
The fix uses TypeScript's discriminated unions to provide accurate type
inference based on the `draft` parameter value.
Fixes#12578
Just some small optimizations, extracted from
https://github.com/payloadcms/payload/pull/14244 to make the diff easier
to read:
- short circuit .split() calls => faster
- remove unused code and clean up code
Fixes https://github.com/payloadcms/payload/issues/14221
Fixes https://github.com/payloadcms/payload/issues/13569
Fixes a problem where blocks would have their fields disappear entirely
if update permissions were false on the collection or on the blocks
field itself.
This fixes that and makes sure that fields are always visible and that
the blocks fields are correctly being set to readOnly mode if you have
permissions to read but not update
Previously you had to use the overrides to enable localization for the
`slugField` however it's a very common request that slugs are localized
so we've added it to the args.
Now you can do:
```ts
slugField({ localized: true })
```
There was also a bug relating to using custom field names, even though
we accept it the hook would still look for `slug`, this has been fixed.
Todo:
- [x] Add e2e tests for general field and localized field
This PR deprecates the `i18n` property returned from the editor adapter.
Instead, we now merge `i18n` directly into the `config`, since the
config is already available in the richtext adapter provider. This
simplifies the logic (only one line of code) and eliminates the need to
manually read and merge i18n from the adapter.
Benefits:
- **Simpler implementation**: Merging i18n into the config is
straightforward
- **Reduced memory usage**: Each editor no longer maintains its own i18n
object - only a single, merged `config.i18n` is kept.
- **Improved developer experience**: The sanitized field config is now
smaller and easier to inspect. Console logging the config no longer
floods the output with hundreds of translation lines.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211668727538425
### What?
This change allows `fallbackLocale` to accept an **array of locales** in
find queries and locale configuration.
```ts
/** Local API **/
await payload.findByID({
id,
collection,
locale: 'en',
fallbackLocale: ['fr', 'es'],
})
/** REST API **/
await fetch(`${baseURL}/api/${collectionSlug}?locale=en&fallbackLocale[]=fr&fallbackLocale[]=es`)
/** GraphQL **/
await restClient.GRAPHQL_POST({
body,
query: { locale: 'en', fallbackLocale: ['fr', 'es']},
})
/** Locale Configs **/
locales: [
{
code: 'en',
label: 'English',
fallbackLocale: ['fr', 'es'],
},
]
```
### Why?
This update is part of the planned [localization
enhancements](https://github.com/payloadcms/payload/discussions/10705).
Currently, only one fallback locale can be specified. If that locale
doesn’t contain data, the query returns nothing. To work around this,
users have to inspect the response themselves and then make additional
queries for other locales.
With this change, Payload handles that work automatically. Users only
need to provide a list of fallback locales in their preferred order, and
Payload will check each one until it finds a value.
### How?
Updates query handling across the local API, REST API, and GraphQL so
that `fallbackLocale` can accept either a **string** or an **array** of
locales. When an array is passed, Payload will iterate through them in
order and return the first found localized value. This behavior applies
to both collections and globals.
#### Feature request:
https://github.com/payloadcms/payload/discussions/13443
---------
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
### What?
Adds a new `admin.components.settingsMenu` configuration option that
allows users to inject custom menu items into the navigation. When
configured, a gear icon appears above the logout button in the
navigation sidebar. Clicking the gear icon opens a popup menu displaying
the custom components.
<img width="130" height="177" alt="Screenshot 2025-10-14 at 11 43 18 AM"
src="https://github.com/user-attachments/assets/5583e32d-cde3-45f0-9d58-a6724c12509f"
/>
<img width="174" height="239" alt="Screenshot 2025-10-14 at 11 43 37 AM"
src="https://github.com/user-attachments/assets/5dca8ea7-ed99-4bc1-89c2-bc6da7d8f323"
/>
### Why?
Users need a way to add custom actions and menu items to the admin
navigation without replacing the entire navigation component. This
provides a standardized location for admin-level utilities and actions
that don't fit into collection or global navigation.
### How?
- Added `settingsMenu` property to `admin.components` config type
definition
- Created `GearIcon` component following the existing icon patterns
- Exported `GearIcon` from the UI package exports
- Implemented `SettingsMenuButton` client component that renders a popup
menu with the gear icon trigger
- Integrated `SettingsMenuButton` into `DefaultNav` component,
positioned above the logout button
- Updated import map generation to include `settingsMenu` components
### What?
This PR fixes the validation logic for restoring documents from trash.
Previously, restoring a trashed draft with empty required fields would
fail with "The following fields are invalid" errors, even when restoring
as a draft.
### Why?
When a draft document with empty required fields is trashed and then
restored:
- Trashing works correctly (skips validation since it's just setting
`deletedAt`)
- Restoring as draft should also work (it's just metadata, clearing
`deletedAt`)
- Restoring as published should require validation (content is being
published)
The previous fix only handled trashing (when `deletedAt` is set), but
didn't handle the restore case (when `deletedAt` is set to `null`).
### How?
Extended the `skipValidation` logic in `updateDocument()` to detect
restore operations.
Fixes#14180
Bumps nodemailer to `7.0.9` and types to `7.0.2` - there have been some
security fixes since our previously pinned version
Manually tested SMTP connection with this new version via our package
This PR:
- adds support for client-side markdown shortcuts for blocks with the
`admin.jsx` property. This was previously only supported server-side.
The code mimics the server-side implementation, as this could be used
for full markdown imports on the client-side, rather than just simple
markdown shortcuts
- exports a pre-made `CodeBlock` that can be used within the
`BlocksFeature`. Video Example:
https://github.com/user-attachments/assets/31f2d66b-64e9-4e9d-a2b6-89ddefda3a84
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211217132290839
Discussion #8859. Requires #14012.
Exports a new `slugField`. This is a wrapper around the text field that
you can drop into any field schema.
A slug is a unique, indexed, URL-friendly string that identifies a
particular document, often used to construct the URL of a webpage. Slugs
are a fundamental concept for seemingly every project.
Traditionally, you'd build this field from scratch, but there are many
edge cases and nice-to-haves to makes this difficult to maintain by
hand. For example, it needs to automatically generate based on the value
of another field, provide UI to lock and re-generate the slug on-demand,
etc.
Fixes#13938.
When autosave is enabled, the slug is only ever generated once after the
initial create, leading to single character, or incomplete slugs.
For example, it is expected that "My Title" → "my-title, however ends up
as "m".
This PR overhauls the field to feel a lot more natural. Now, we only
generate the slug through:
1. The `create` operation, unless the user has modified the slug
manually
2. The `update` operation, if:
a. Autosave is _not_ enabled and there is no slug
b. Autosave _is_ enabled, the doc is unpublished, and the user has not
modified the slug manually
The slug should stabilize after all above criteria have been met,
because the URL is typically derived from the slug. This is to protect
modifying potentially live URLs, breaking links, etc. without explicit
intent.
This fix, along with all the other features, is now standardized behind
the new `slugField`:
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
export const MyCollection: CollectionConfig = {
// ...
fields: [
// ...
slugField()
]
}
```
In the future we could also make this field smart enough to auto
increment itself when its generated slug is not unique.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211513433305005
- Adds documentdb and cosmosdb to CI test matrix
- Adds missing generateDatabaseAdapter configs
- Adjust generateDatabaseAdapter firestore config to make it pure to the
compatibilityOptions we provide as an export
Creating as draft because I expect a few tests to fail based on previous
comments.
---------
Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
Follow up from https://github.com/payloadcms/payload/pull/14053
Add detection for `--experimental-https` flag based on `process.argv`
which may not be reliable so we're keeping the env var toggle in as well
Closes https://github.com/payloadcms/payload/issues/12087
When using `--experimental-https` from Nextjs we have no way of
detecting that right now so I've added documentation on how to handle
this flag and added to support for `USE_HTTPS` to set the websocket
protocol for HMR to `wss` instead of `ws`
### What?
This PR adds a new `admin.disableRowTypes` config to `'join'` fields
which hides the `"Type"` column from the relationship table.
### Why?
While the collection type column _can be_ helpful for providing
information, it's not always necessary and can sometimes be redundant
when the field only has a singular relationTo. Hiding it can be helpful
by removing visual noise and providing editors the data directly.
### How?
By threading `admin.disableRowTypes` directly to the `getTableState`
function of the `RelationshipTable` component.
**With row types** (via `admin.disableRowTypes: false | undefined` OR
default for polymorphic):

**Without row types** (default for monomorphic):

Fixes https://github.com/payloadcms/payload/issues/13308
Adds support for a custom live preview component back, we previously
supported this and it was allowed via the config types but it wasn't
being rendered.
Now we export the `useLivePreviewContext` hook and the original
`LivePreviewWindow` component too so that end users can wrap the live
preview functionality with anything custom that they may need
Supports live preview conditions. This is essentially access control for
live preview, where you may want to restrict who can use it based on
certain criteria, such as the current user or document data.
To do this, simply return null or undefined from your live preview url
functions:
```ts
url: ({ req }) => (req.user?.role === 'admin' ? '/hello-world' : null)
```
This is also useful for pages which derive their URL from document data,
e.g. a slug field, do not attempt to render the live preview window
until the URL is fully formatted.
For example, if you have a page in your front-end with the URL structure
of `/posts/[slug]`, the slug field is required before the page can
properly load. However, if the slug is not a required field, or when
drafts and/or autosave is enabled, the slug field might not yet have
data, leading to `/posts/undefined` or similar.
```ts
url: ({ data }) => data?.slug ? `/${data.slug}` : null
```
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211513433305000
### What?
Adds a new `disableGroupBy` admin config property for fields to control
their visibility in the list view GroupBy dropdown.
### Why
Previously, the GroupByBuilder was incorrectly using `disableListFilter`
to determine which fields to show in the group-by dropdown.
### How
- Added new `disableGroupBy` property to the field admin config types.
- Updated `GroupByBuilder` to filter fields based on `disableGroupBy`
instead of `disableListFilter`
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211511898438807
This PR updates the build process to generate a single
`dist/index.bundled.d.ts` file that bundles all `payload` package types.
Having one bundled declaration file makes it easy to load types into the
Monaco editor (e.g. for the new Code block), enabling full type
completion for the `payload` package.
## Example
```ts
BlocksFeature({
blocks: [
CodeBlock({
slug: 'PayloadCode',
languages: {
ts: 'TypeScript',
},
typescript: {
fetchTypes: [
{
filePath: 'file:///node_modules/payload/index.d.ts',
url: 'https://unpkg.com/payload@3.59.0-internal.e247081/dist/index.bundled.d.ts', // <= download bundled .d.ts
},
],
paths: {
payload: ['file:///node_modules/payload/index.d.ts'],
},
typeRoots: ['node_modules/@types', 'node_modules/payload'],
},
}),
],
}),
```
<img width="1506" height="866" alt="Screenshot 2025-10-01 at 12 38
54@2x"
src="https://github.com/user-attachments/assets/135b9b69-058a-42b9-afa0-daa328f64f38"
/>
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211524241290884