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.
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.