Skip to content
System nominalUTC --:--:--Articles 085
MODULE 04 Lesson 11 / 18 40 MIN

Building a plugin by prompt

Custom Post Types, Screens and Capabilities

A custom post type with its own capabilities, a meta box with four guards on save, and the checks that prove an administrator is not locked out of their own content.

Ask an agent for a custom post type and you will get one in a minute. Ask it what happens when a user with the editor role tries to edit one, and you get a paragraph that sounds correct and a site where the menu item is missing for everyone except the administrator. The registration call is the easy half.

The half that bites is capabilities. A post type registered with capability_type => 'post' inherits the post capabilities, so anybody who can edit posts can edit your new type, and your custom role work does nothing. Switch to a custom capability type and the opposite failure appears: WordPress derives the capabilities but grants them to nobody, including the administrator who installed the plugin. Both are one-line registrations. Only one of them produces a support ticket immediately, which is why the quiet one is the one agents produce.

A capability type of its own

The plugin in this lesson is a book library. The registration that matters is four lines of it:

register_post_type(
	'acme_book',
	array(
		'public'          => true,
		'show_in_rest'    => true,
		'supports'        => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
		'capability_type' => array( 'acme_book', 'acme_books' ),
		'map_meta_cap'    => true,
		'taxonomies'      => array( 'acme_shelf' ),
	)
);

capability_type takes an array of singular and plural names. WordPress uses them to build a capability list, and map_meta_cap is what puts the plugin's own checks in front of WordPress's generic ones. Activation was the point at which that list stopped being theoretical:

$ wp plugin activate acme-library
Plugin 'acme-library' activated.
Success: Activated 1 of 1 plugins.

$ wp cap list administrator 2>&1 | grep -v Deprecated | tr ',' '\n' | grep acme | tr '\n' ' '
edit_acme_book read_acme_book delete_acme_book edit_acme_books edit_others_acme_books
publish_acme_books read_private_acme_books delete_acme_books delete_private_acme_books
delete_published_acme_books delete_others_acme_books edit_private_acme_books
edit_published_acme_books create_acme_books

Fourteen capabilities, all granted. That output is the result of the plugin's activation hook, not of the registration call, and the next section is why.

WordPress derives the capabilities and grants none of them

This is the fact that makes the whole lesson worth reading. register_post_type creates the mapping from meta capabilities to primitive ones. It does not touch any role. Comment out the activation hook and the registration still works, the post type still appears, and the administrator still cannot create a book, because create_acme_books belongs to no role.

So the plugin has to grant the set itself, and it has to revoke it on deactivation. The plugin's activation and deactivation handlers are the same loop with a different method call:

function book_capabilities() {
	return array(
		'edit_acme_book', 'read_acme_book', 'delete_acme_book',
		'edit_acme_books', 'edit_others_acme_books', 'publish_acme_books',
		'read_private_acme_books', 'delete_acme_books', 'delete_private_acme_books',
		'delete_published_acme_books', 'delete_others_acme_books',
		'edit_private_acme_books', 'edit_published_acme_books', 'create_acme_books',
	);
}

function activate() {
	$role = get_role( 'administrator' );
	if ( ! $role instanceof WP_Role ) {
		return;
	}
	foreach ( book_capabilities() as $capability ) {
		$role->add_cap( $capability );
	}
}

register_activation_hook( __FILE__, __NAMESPACE__ . '\\activate' );
register_deactivation_hook( __FILE__, __NAMESPACE__ . '\\deactivate' );

Two details are easy to get wrong and both are worth checking rather than assuming. The first is that add_cap() writes to the database, so it persists after the plugin is deactivated unless you remove it. I confirmed the revoke path by deactivating and reading the role back:

$ wp plugin deactivate acme-library
$ wp eval 'echo get_role("administrator")->has_cap("edit_acme_books") ? "yes" : "no", "\n";'
no
$ wp plugin activate acme-library

The second is that a capability check on an administrator is not a test of your registration, because administrators bypass most capability checks through map_meta_cap. It is a test of your activation hook. If you want to know whether the registration is right, you check the mapping, and that is the next section.

edit_post becomes edit_others_acme_books, and that is the point

map_meta_cap is the function that turns a generic check into a specific one. It takes the capability being asked about, the user, and the object, and returns the primitive capabilities that would satisfy it. Run it directly and you can see the translation, including the branch that depends on who owns the post:

$ wp eval '
wp_set_current_user( 1 );
$id = wp_insert_post( array( "post_type" => "acme_book", "post_title" => "The Cuckoo Clock", "post_status" => "publish", "post_author" => 1 ) );
echo "admin, own book:\n";
print_r( map_meta_cap( "edit_post", 1, $id ) );
wp_set_current_user( get_user_by( "login", "subbie" )->ID );
echo "subscriber, someone else's book: can edit? ";
var_dump( current_user_can( "edit_post", $id ) );
' 2>&1 | grep -v Deprecated
admin, own book:
Array
(
    [0] => edit_published_acme_books
)
subscriber, someone else's book: can edit? bool(false)

Read the array. The administrator asked about edit_post and WordPress turned it into edit_published_acme_books, because the book is published and the administrator authored it. A different author's book would have produced edit_others_acme_books and edit_published_acme_books together. That is the whole value of map_meta_cap: the plugin writes current_user_can( 'edit_post', $post_id ) and WordPress resolves ownership, status and role for it.

The practical consequence for reviewing agent code is that a bare current_user_can( 'edit_posts' ) in a book plugin is a bug even though it returns true for an administrator. It is asking about posts. The check should name the post type or, better, use the meta capability with the object ID so WordPress can apply the ownership rules. Agents get this wrong in both directions: they write edit_posts where they mean edit_acme_books, or they write manage_options as a shortcut for "administrator only", which locks the screen away from any custom role that legitimately needs it.

The save handler needs four guards, and each one is load-bearing

The meta box itself is a label and an input, with a nonce field, and it refuses to render for a user who cannot edit the post:

function render_isbn_meta_box( $post ) {
	if ( ! current_user_can( 'edit_post', $post->ID ) ) {
		return;
	}

	wp_nonce_field( 'acme_library_save_isbn', 'acme_library_isbn_nonce' );

	printf(
		'<label for="acme-library-isbn-field" class="screen-reader-text">%1$s</label>
		<input type="text" id="acme-library-isbn-field" name="acme_library_isbn" value="%2$s" class="widefat" />',
		esc_html__( 'ISBN', 'acme-library' ),
		esc_attr( (string) get_post_meta( $post->ID, META_ISBN, true ) )
	);
}

Rendering behind a capability check is presentation, not security. The write happens on save_post_acme_book, and that callback is where the guards matter. I tested each guard by calling the hook with different inputs and reading the meta value back after each attempt:

$ wp eval '...' 2>&1 | grep -v Deprecated
no nonce        -> ''
bad nonce       -> ''
good nonce      -> '978316'
subscriber      -> '978316'

The starting value was an empty string. The first two attempts supplied a real ISBN and were rejected because there was no nonce and then because the nonce was wrong. The third attempt, with a valid nonce and an administrator, stored 978316 from the hostile input <script>978 3 16</script>. The fourth attempt used a valid nonce generated for a subscriber, and the stored value did not change, because current_user_can( 'edit_post', $post_id ) failed.

Lines three and four are why I run these tests instead of reading the code. A nonce is not an authorisation check: it proves the request came from a form the site rendered, and a subscriber can load a page the site rendered. Drop the capability check and any logged-in user who can reach the editor can write to any book. Drop the nonce and the plugin accepts a cross-site POST. Drop the autosave check and a draft autosave can overwrite the field with whatever the previous request left in $_POST. The four guards are four different threats.

The sanitizer is where the remaining bugs live

All four guards passed and the stored value is still wrong for real ISBNs. The plugin's sanitizer is preg_replace( '/[^0-9Xx-]/', '', $value ), and it turns 9783161484100 ext. 5 into 9783161484100X5 by keeping the x in ext. and uppercasing it. It also keeps an X anywhere in the string rather than only in the check-digit position. Escaping and capability checks passed; the data is still corrupt. Authentication and validation are different problems and an agent will usually solve the first and call it the second.

Registration, taxonomy and REST are one command each

The rest of the post type is verifiable from the command line, which is how I would review any generated registration. Three commands cover the three things a reviewer actually cares about:

$ wp post-type list --fields=name,label,public 2>&1 | grep -E "name|acme"
name       label   public
acme_book  Books   1

$ wp taxonomy list --fields=name,label,object_type 2>&1 | grep -E "name|acme"
name        label    object_type
acme_shelf  Shelves  acme_book

$ curl -s "http://127.0.0.1:8080/wp-json/wp/v2/acme_book/6" | python3 -m json.tool | head -20
{
    "id": 6,
    "date": "2026-10-16T18:15:08",
    ...
    "type": "acme_book",
    "meta": { "_acme_isbn": "978316" }
}

That last one is worth more than it looks. show_in_rest => true on the post type and show_in_rest => true with an auth_callback on the meta field are two separate decisions, and a registration that gets the first right and the second wrong is invisible in wp-admin while being broken for every block-editor user. The auth_callback in this plugin is current_user_can( 'edit_post', $post_id ), which is the same meta capability the save handler uses. One check, three call sites, one place to get it right.

Two things I did not test, stated plainly. I did not open wp-admin in a browser, so I have not seen the meta box render beside the editor; I verified it is hooked with has_action( 'add_meta_boxes_acme_book' ) and that the callback returns the nonce field and the input. And I did not test a custom role, only the administrator and a subscriber, so the edit_others_acme_books branch is confirmed by map_meta_cap output rather than by watching a second author use the screen.

Lab 40 minutes

Register a post type with its own capabilities and prove the whole path before you touch the admin UI.

Pick a content type and a singular/plural capability pair. Register it with capability_type, map_meta_cap, show_in_rest and one custom meta field, and add the activation and deactivation handlers that grant and revoke the capability set.

Then run four checks and keep the output of each:

wp cap list administrator 2>&1 | grep -v Deprecated | tr ',' '\n' | grep YOURPREFIX
wp eval '$r = get_post_type_object("YOUR_TYPE"); print_r( $r->cap );' 2>&1 | grep -v Deprecated
wp eval 'wp_set_current_user(1); print_r( map_meta_cap("edit_post", 1, YOUR_ID) );' 2>&1 | grep -v Deprecated
wp plugin deactivate YOUR_PLUGIN; wp eval 'var_dump( get_role("administrator")->has_cap("YOUR_CAP") );'

Now break the meta box on purpose, one guard at a time. Remove the nonce check, then the capability check, then the autosave check, and after each removal attempt a save with a valid value and see what gets stored. Write down which removal actually changed the stored value; at least one of them will not, and explaining why is the point of the exercise.

Show the solution and the reasoning

The capability object is the fastest way to see whether the registration did what you think, because it lists the derived names without any role involvement:

$ wp eval '$r = get_post_type_object("acme_book"); print_r( $r->cap );' \
  2>&1 | grep -v Deprecated
Array
(
    [edit_post] => edit_acme_book
    [read_post] => read_acme_book
    [delete_post] => delete_acme_book
    [edit_posts] => edit_acme_books
    [edit_others_posts] => edit_others_acme_books
    [publish_posts] => publish_acme_books
    ...
)

If those values still say edit_post and edit_posts, capability_type did not take effect, and the usual cause is that it was passed as a string where an array was expected, or that a second register_post_type call for the same slug ran later and overwrote it. Registration is last-one-wins, which is worth knowing before you spend an hour debugging a conflict that is really two plugins fighting.

One entry in that array is not what most people expect. [create_posts] => edit_acme_books, not create_acme_books. The "Add New Book" screen asks for edit_acme_books, so the create_acme_books capability the plugin grants is not the one doing the work. I tested it rather than reading the source: removing create_acme_books from the administrator role left the user able to create books and able to pass current_user_can( $post_type_object->cap->create_posts ), because that value is edit_acme_books. Keep granting it for the sake of any plugin that checks the documented name, but do not believe that a role without it is locked out.

For the deliberate breakages, the interesting results are these. Removing the capability check lets a subscriber write to the field, which is the security hole, and it is invisible to anyone testing as an administrator. Removing the nonce check changes nothing when you call the save hook directly from wp eval, because a direct call has no request to validate and the guard you deleted was the only thing reading $_POST. That is why the test has to set $_POST and call the hook the way WordPress would, and why "I tested the save function" from an agent usually means the agent wrote the input it then accepted.

Removing the autosave check is the one that looks harmless and is not. Autosave fires on a schedule while an editor types, and it posts the whole form. Without the guard the meta value is rewritten on every autosave, so a value another process set seconds ago disappears while the author is still writing the post. Nothing about the final save looks wrong, which is what makes it hard to report and easy to dismiss as a caching problem.

The transferable rule is that the four guards protect against four unrelated failures and only one of them is an authorisation problem. When you review generated code, check them one at a time by name rather than as a block of boilerplate, because the block is exactly what an agent produces: a copy of the pattern with one line quietly missing.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.