current_user_can()
current_user_can() checks capabilities, never roles, and meta capabilities like edit_post need the object ID passed too.
current_user_can( string $capability, mixed ...$args ): boolcurrent_user_can( $capability, ...$args ) returns a bool and is the gate you put in front of every privileged action. It is shorthand for user_can( wp_get_current_user(), $capability, ...$args ) — identical logic, one reads the current user for you.
Capabilities, not roles
Passing a role name works in part, but the Code Reference explicitly discourages it: it produces unreliable results, and it breaks the moment a site owner builds a custom role or grants a capability to one user. Check current_user_can( 'manage_options' ), never current_user_can( 'administrator' ).
Primitive vs meta capabilities
Primitive capabilities are the strings stored on roles: edit_posts, edit_others_posts, manage_options. Meta capabilities are questions about one object: edit_post, delete_post, read_post, edit_user. They are never stored anywhere. When you check one, map_meta_cap( $cap, $user_id, $args ) resolves it into an array of primitive capabilities using the object's author and status — so editing your own draft needs edit_posts, while editing someone else's published post needs edit_others_posts and edit_published_posts.
The trap
Meta capabilities require the object ID as the next argument. current_user_can( 'edit_post', $post_id ) is a real check; current_user_can( 'edit_post' ) is not, because with no post to inspect the map returns do_not_allow and the check fails for everyone, administrators included. Note the singular/plural distinction: edit_post takes an ID, edit_posts does not.
Example
<?php
add_action( 'admin_post_acme_archive_order', static function (): void {
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
// 1. Did this request really come from our form?
check_admin_referer( 'acme_archive_' . $post_id );
// 2. Meta capability — the post ID is required. Without it, edit_post
// maps to do_not_allow and this fails for every user.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die(
esc_html__( 'You are not allowed to edit this order.', 'acme' ),
'',
array( 'response' => 403 )
);
}
// 3. Primitive capability — no object, so no extra argument.
if ( ! current_user_can( 'acme_manage_orders' ) ) {
wp_die( '', '', array( 'response' => 403 ) );
}
wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) );
wp_safe_redirect( wp_get_referer() ?: admin_url() );
exit;
} );When to use it
- Guard an admin-post or AJAX handler before it writes anything.
- Decide whether to render an edit link next to a post in a template.
- Give a custom post type its own permission rules via map_meta_cap.
- Write a REST route permission_callback that checks the right capability.
More examples
Remapping a meta capability with the map_meta_cap filter
The way to give a custom post type its own permission rules. Register the post type with 'map_meta_cap' => true so its meta capabilities route through this filter, then decide here. Remember the 4 in add_filter — the default argument count is 1, and without it $cap and $args never arrive.
/**
* Let anyone with acme_manage_orders edit any acme_order post, whoever
* wrote it. The filter receives the primitive caps the meta cap resolved to
* and returns the list you actually want required.
*
* @param string[] $caps Primitive capabilities required.
* @param string $cap The meta capability being checked.
* @param int $user_id User being tested.
* @param array $args $args[0] is the object ID for post meta caps.
*/
add_filter( 'map_meta_cap', static function ( $caps, $cap, $user_id, $args ) {
if ( ! in_array( $cap, array( 'edit_post', 'delete_post' ), true ) ) {
return $caps;
}
if ( empty( $args[0] ) || 'acme_order' !== get_post_type( $args[0] ) ) {
return $caps;
}
return array( 'acme_manage_orders' ); // replaces the whole requirement
}, 10, 4 );Checking a user who is not the current one
Needed in cron jobs, REST permission callbacks running as another user, and editorial workflow code that asks whether an author could have published something. current_user_can() is only ever a convenience wrapper around user_can().
// Same engine, explicit subject. Accepts a WP_User or an int ID.
if ( user_can( $author_id, 'publish_posts' ) ) {
$status = 'publish';
}
// Meta capability against another user, with the object ID:
if ( user_can( $editor, 'edit_post', $post_id ) ) { /* ... */ }
// current_user_can( $cap ) is exactly this, with the user filled in:
// user_can( wp_get_current_user(), $cap );
// author_can() is the same check aimed at a post's author.
if ( author_can( $post_id, 'unfiltered_html' ) ) { /* ... */ }Gating an admin screen and its output
The capability passed to add_menu_page() only decides menu visibility and the generated page's own check for the top-level slug; any handler you register elsewhere, and every AJAX or admin-post action behind the screen, needs its own check. Duplicating it in the callback costs nothing and closes the common hole.
add_action( 'admin_menu', static function (): void {
// The capability argument controls who sees the menu entry.
add_menu_page(
__( 'Orders', 'acme' ),
__( 'Orders', 'acme' ),
'acme_manage_orders', // capability, not a role
'acme-orders',
'acme_render_orders_page'
);
} );
function acme_render_orders_page(): void {
// Check again inside the callback. Hiding a menu item is not access
// control — the page URL is guessable and callable directly.
if ( ! current_user_can( 'acme_manage_orders' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'acme' ), '', array( 'response' => 403 ) );
}
echo '<div class="wrap"><h1>' . esc_html__( 'Orders', 'acme' ) . '</h1></div>';
}
Discussion