In the WordPress block editor, a common request is to lock the Publish button but still allow Save Draft. For example, a post shouldn’t go live without a featured image, but writers still need to save their drafts. The obvious tool, lockPostSaving() from the core/editor data store, can’t do this. It locks every save, so Save Draft stops working too and people lose their work.
The snippet below locks only publishing. While the rule isn’t met, the Publish button is dimmed and a warning notice explains why. A client-side editor.preSavePost filter stops a publish attempt before the request is sent. A REST API guard rejects the same request on the server, so the rule still holds when someone uses a keyboard shortcut, a different client or an old editor version. Save Draft, Save as Pending and updates to posts that are already published work as before.
The snippet
Put it in a small plugin, an mu-plugin or your theme’s functions.php. It is one self-contained PHP file with no build step: the editor script is printed inline and uses wp.data, wp.hooks and wp.notices from core.
<?php
/**
* Plugin Name: DPC Publish Lock (Save Draft still allowed)
* Description: Blocks publishing until the post has a featured image. Drafts can always be saved.
*/
/** Post types the lock applies to. */
function dpc_publish_lock_post_types(): array {
return (array) apply_filters( 'dpc_publish_lock_post_types', array( 'post' ) );
}
/** Message shown in the editor and returned by the REST API. */
function dpc_publish_lock_message(): string {
return __( 'Set a featured image before publishing. You can still save this post as a draft.', 'dpc' );
}
/**
* Server-side guard: reject REST requests that would publish or schedule
* a post that has no featured image yet.
*/
add_action( 'rest_api_init', 'dpc_publish_lock_register_guards' );
function dpc_publish_lock_register_guards(): void {
foreach ( dpc_publish_lock_post_types() as $post_type ) {
add_filter( "rest_pre_insert_{$post_type}", 'dpc_publish_lock_rest_guard', 10, 2 );
}
}
function dpc_publish_lock_rest_guard( $prepared_post, WP_REST_Request $request ) {
if ( is_wp_error( $prepared_post ) ) {
return $prepared_post;
}
$live = array( 'publish', 'future' );
$new_status = $request->has_param( 'status' ) ? (string) $request['status'] : '';
// Drafts, pending and requests that don't change the status are always allowed.
if ( ! in_array( $new_status, $live, true ) ) {
return $prepared_post;
}
// Updating a post that is already live is not "publishing".
$post_id = isset( $prepared_post->ID ) ? (int) $prepared_post->ID : 0;
if ( $post_id && in_array( get_post_status( $post_id ), $live, true ) ) {
return $prepared_post;
}
// The rule: use the featured image sent with this request, else the stored one.
$thumbnail_id = $request->has_param( 'featured_media' )
? (int) $request['featured_media']
: ( $post_id ? (int) get_post_thumbnail_id( $post_id ) : 0 );
if ( $thumbnail_id > 0 ) {
return $prepared_post;
}
return new WP_Error( 'dpc_publish_locked', dpc_publish_lock_message(), array( 'status' => 400 ) );
}
/** Editor script: dims Publish, shows a notice, stops the publish request. */
function dpc_publish_lock_js(): string {
return <<<'JS'
( function ( wp, config ) {
if ( ! wp || ! wp.data || ! wp.hooks || ! config ) {
return;
}
const { select, dispatch, subscribe } = wp.data;
const LIVE = [ 'publish', 'future' ];
const NOTICE_ID = 'dpc-publish-lock';
const editor = () => select( 'core/editor' );
// The rule. Keep it in sync with dpc_publish_lock_rest_guard().
const isMissingRequirement = () =>
! editor().getEditedPostAttribute( 'featured_media' );
const appliesHere = () =>
config.postTypes.includes( editor().getCurrentPostType() ) &&
! LIVE.includes( editor().getCurrentPost().status );
// Toggle the visual lock and the notice only when the state changes.
let wasLocked = null;
subscribe( () => {
if ( ! editor().getCurrentPostId() ) {
return;
}
const locked = appliesHere() && isMissingRequirement();
if ( locked === wasLocked ) {
return;
}
wasLocked = locked;
document.body.classList.toggle( 'dpc-publish-locked', locked );
if ( locked ) {
dispatch( 'core/notices' ).createWarningNotice( config.message, {
id: NOTICE_ID,
isDismissible: false,
} );
} else {
dispatch( 'core/notices' ).removeNotice( NOTICE_ID );
}
} );
// Stop a publish/schedule save before the request is sent.
wp.hooks.addFilter( 'editor.preSavePost', 'dpc/publish-lock', ( edits, options ) => {
if ( options?.isAutosave || ! LIVE.includes( edits.status ) ) {
return edits;
}
if ( ! appliesHere() || ! isMissingRequirement() ) {
return edits;
}
// Put the status back so the next Save Draft really saves a draft.
const saved = editor().getCurrentPost().status;
dispatch( 'core/editor' ).editPost( {
status: 'auto-draft' === saved ? 'draft' : saved,
} );
throw new Error( config.message );
} );
} )( window.wp, window.dpcPublishLock );
JS;
}
add_action( 'enqueue_block_editor_assets', 'dpc_publish_lock_editor_assets' );
function dpc_publish_lock_editor_assets(): void {
// Post editor only: the Site Editor and widget screens have no global post.
$post_type = get_post_type();
if ( ! $post_type || ! in_array( $post_type, dpc_publish_lock_post_types(), true ) ) {
return;
}
$config = array(
'postTypes' => array_values( dpc_publish_lock_post_types() ),
'message' => dpc_publish_lock_message(),
);
wp_register_script(
'dpc-publish-lock',
false,
array( 'wp-data', 'wp-hooks', 'wp-notices', 'wp-editor' ),
null,
true
);
wp_enqueue_script( 'dpc-publish-lock' );
wp_add_inline_script(
'dpc-publish-lock',
'window.dpcPublishLock = ' . wp_json_encode( $config ) . ";\n" . dpc_publish_lock_js()
);
$css = 'body.dpc-publish-locked .editor-post-publish-panel__toggle,'
. 'body.dpc-publish-locked .editor-post-publish-button{opacity:.5;pointer-events:none;}';
wp_register_style( 'dpc-publish-lock', false );
wp_enqueue_style( 'dpc-publish-lock' );
wp_add_inline_style( 'dpc-publish-lock', $css );
}
How it works
wp.data.subscribe()runs whenever an editor store changes. It checks the editedfeatured_mediaattribute and toggles adpc-publish-lockedclass onbody, plus a warning notice that can’t be dismissed. It only touches the DOM when the locked state changes, so it stays cheap even though it runs on every store update.editor.preSavePostis an async filter thatsavePost()runs before it sends the request (WordPress 6.7+). Clicking Publish setsstatus: 'publish'(or'future'for a scheduled post) and then saves. The filter sees that status, puts the old status back witheditPost()and throws. The editor aborts the save and shows “Publishing failed.” followed by your message.rest_pre_insert_{post_type}is the real lock. The block editor saves through the REST API, so returning aWP_Errorhere rejects any publish or schedule request for a post without a featured image, whatever client sent it. Requests that don’t set a live status pass straight through, and so does any post that is already published or scheduled.- The config goes to the script through
wp_json_encode(), which escapes slashes, so a translated message can’t break out of the inline<script>.
This differs from blocking publish until a category and subcategory are set: that approach locks saving entirely, while this one keeps drafts saveable. Use whichever fits your writers’ workflow.
Customise it
- More post types:
add_filter( 'dpc_publish_lock_post_types', fn() => array( 'post', 'page', 'product' ) );Every type you add needsthumbnailsupport if you keep the featured-image rule. - A different rule: change
isMissingRequirement()in the JS and the$thumbnail_idcheck in PHP together. For a required excerpt, use! editor().getEditedPostAttribute( 'excerpt' ).trim()in JS and$request['excerpt'] ?? get_post_field( 'post_excerpt', $post_id )in PHP. - Let editors bypass it: return
$prepared_postearly in the guard whencurrent_user_can( 'edit_others_posts' )is true, and return early fromdpc_publish_lock_editor_assets()with the same check.
Gotchas
- The dimmed button relies on core CSS class names, which aren’t a public API. If a future release renames them, the button looks clickable again, but
editor.preSavePostand the REST guard still block publishing. - Before WordPress 6.7 there is no
editor.preSavePost. The server guard still rejects the request and the editor shows its usual “Publishing failed” notice. - Quick Edit, bulk edit, XML-RPC and
wp_insert_post()don’t go through the REST API. If those paths matter to you, add awp_insert_post_datafilter that resetspost_statustodraft. - If you load the snippet with Scripts Organizer instead of functions.php, paste it into one PHP block set to run everywhere. The REST guard has to load on REST requests, not only in the admin.
Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.
0 of 10 votes