Lock the Publish Button but Allow Save Draft in WordPress

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 edited featured_media attribute and toggles a dpc-publish-locked class on body, 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.preSavePost is an async filter that savePost() runs before it sends the request (WordPress 6.7+). Clicking Publish sets status: 'publish' (or 'future' for a scheduled post) and then saves. The filter sees that status, puts the old status back with editPost() 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 a WP_Error here 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 needs thumbnail support if you keep the featured-image rule.
  • A different rule: change isMissingRequirement() in the JS and the $thumbnail_id check 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_post early in the guard when current_user_can( 'edit_others_posts' ) is true, and return early from dpc_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.preSavePost and 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 a wp_insert_post_data filter that resets post_status to draft.
  • 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

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.