WordPress ships with five default roles and a capability system that can feel opaque until you see how the pieces fit together. Once you do, you can gate any feature (an admin menu, a REST endpoint, a single post on the front end) behind a custom capability without touching the default role structure. This guide covers the patterns I use when building plugins and complex sites: how current_user_can() makes its decision, creating roles with add_role(), custom post type capabilities, object-level rules with map_meta_cap, REST gating, WooCommerce roles and clean uninstalls.
WordPress Roles vs Capabilities: The Two-Layer Model
A capability is a single permission, such as edit_posts or manage_options. A role is a named bundle of capabilities, such as Editor, that you assign to a user. WordPress checks capabilities when it decides what a user may do, so the role is simply how those permissions reach the user.
| Layer | What it is | Examples |
|---|---|---|
| Capability | One permission to do one specific thing | edit_posts, manage_options, upload_files, publish_posts |
| Role | Named bundle of capabilities | Administrator, Editor, Author, Contributor, Subscriber |
| Meta capability | Capability checked against a specific object | edit_post (for post #123), delete_post (for post #456) |
WordPress stores roles and their capabilities in the wp_user_roles option (the name follows your table prefix). Every role carries a list of primitive capabilities, which are boolean flags. A meta capability such as edit_post or delete_post is a higher-level check that WordPress maps to the right primitives at runtime through map_meta_cap(). This distinction matters most when you register custom post types. The Roles and Capabilities chapter of the Plugin Handbook is the official reference for the model.
| Role | Key default capabilities |
|---|---|
| Subscriber | read |
| Contributor | edit_posts, delete_posts |
| Author | + upload_files, publish_posts |
| Editor | + edit_others_posts, manage_categories |
| Administrator | manage_options, install_plugins, everything |
How Does current_user_can() Work?
current_user_can() returns true or false for whether the logged-in user holds a given capability. It loads the current user, merges the capabilities of every role they hold with any granted to them directly, and passes meta capabilities such as edit_post through map_meta_cap() using the object ID you supply.
The signature is current_user_can( string $capability, mixed ...$args ), documented in the current_user_can() developer reference. Two details from that page matter in practice. On multisite, a super admin gets true for every check unless a capability is explicitly denied. And the function only answers for the current user: for anyone else, call user_can() with a user ID or WP_User object.
// Primitive capability: can this user change site settings?
if ( current_user_can( 'manage_options' ) ) {
myplugin_render_settings_page();
}
// Meta capability plus an object ID: can this user edit post 123?
$post_id = 123;
if ( current_user_can( 'edit_post', $post_id ) ) {
myplugin_render_edit_button( $post_id );
}
// A different user: user_can() accepts a user ID or a WP_User object.
if ( user_can( 42, 'edit_others_posts' ) ) {
// User 42 can edit other people's posts.
}
manage_options is the capability WordPress uses to guard its own Settings screens, which is why it is the usual gate for a plugin settings page. For edit_post, WordPress does the object work for you: it loads the post, compares the author with the current user, and requires edit_others_posts or edit_published_posts where the post calls for it.
current_user_can() with a role name vs a capability
Passing a role slug, as in current_user_can( 'subscriber' ) or current_user_can( 'client_manager' ), partly works. WordPress keeps a user’s role slugs in the same array as their individual capabilities (see WP_User::get_role_caps()), so the check returns true when the user holds exactly that role. The developer reference calls this practice discouraged because it may produce unreliable results. These are the cases I see break:
- Role slugs carry no hierarchy.
current_user_can( 'subscriber' )returns false for an editor or an administrator, even though both can do everything a subscriber can. - On multisite, a super admin passes every role check, including roles they do not hold.
- A check tied to a custom role slug fails as soon as someone renames the role, splits it in two, or grants the same access to another role through a plugin.
So check the capability that matches the action you are protecting. current_user_can( 'edit_posts' ) keeps working whichever role, plugin or direct grant gave the user that permission. If a custom role needs its own gate, give it a custom capability such as myplugin_view_reports and check that.
When you need to know which role a user has, for example to show a badge in a dashboard, read the roles array from wp_get_current_user(). Keep this for display logic; anything that grants access should still go through a capability check.
$user = wp_get_current_user();
if ( in_array( 'client_manager', (array) $user->roles, true ) ) {
// The user holds the custom client_manager role.
}
Registering a Custom Role on Plugin Activation
Tie add_role() to register_activation_hook(). Calling it on init runs it on every request; hooking it to activation runs it once. add_role() is already idempotent (it returns null if the role slug already exists), and limiting the call to activation is still the cleaner pattern.
register_activation_hook( __FILE__, 'myplugin_add_roles' );
function myplugin_add_roles() {
// add_role() is a no-op if the role already exists, so it is safe on every activation.
add_role(
'client_manager',
__( 'Client Manager', 'myplugin' ),
array(
'read' => true,
'edit_posts' => false,
'delete_posts' => false,
'myplugin_view_reports' => true, // custom capability
)
);
}
Custom capabilities like myplugin_view_reports need no registration. They are plain strings, and their meaning comes from wherever you check them with current_user_can(). Prefix them with your plugin slug so they cannot collide with another plugin’s capabilities.
Extending an Existing Role
When you want to give editors (or any built-in role) access to a new feature without creating a whole new role, add the capability directly to the existing role object. add_cap() saves to the roles option, so the grant persists and only needs to run on activation. The has_cap() guard skips the write when the role already has the capability.
register_activation_hook( __FILE__, 'myplugin_extend_editor_role' );
function myplugin_extend_editor_role() {
$role = get_role( 'editor' );
if ( $role && ! $role->has_cap( 'manage_team_notes' ) ) {
$role->add_cap( 'manage_team_notes' );
}
}
Capabilities added this way stay in the database after your code stops running. Remove them on plugin deactivation (see the cleanup section below) to leave the database clean.
Custom Post Types: capability_type and map_meta_cap
This is where most developers hit a wall. When you register a CPT with capability_type => 'post' (the default), WordPress checks the standard post capabilities for it, so any editor can create, edit and delete your CPT just like a standard post. To enforce CPT-specific access, set capability_type to your CPT slug and map_meta_cap => true. The full registration setup (labels, rewrite rules, supports) is in the custom post types guide.
register_post_type( 'portfolio_item', array(
'label' => __( 'Portfolio Items', 'myplugin' ),
'capability_type' => 'portfolio_item',
'map_meta_cap' => true,
'supports' => array( 'title', 'editor', 'thumbnail' ),
'show_in_rest' => true,
) );
With map_meta_cap => true, WordPress generates a full set of capability names for your CPT (see get_post_type_capabilities()), and no role holds any of them yet.
| Generated capability | Type | Core equivalent |
|---|---|---|
edit_portfolio_item |
Meta | edit_post |
read_portfolio_item |
Meta | read_post |
delete_portfolio_item |
Meta | delete_post |
edit_portfolio_items |
Primitive | edit_posts (also used for create_posts) |
edit_others_portfolio_items |
Primitive | edit_others_posts |
publish_portfolio_items |
Primitive | publish_posts |
read_private_portfolio_items |
Primitive | read_private_posts |
delete_portfolio_items |
Primitive | delete_posts |
delete_private_, delete_published_, delete_others_, edit_private_, edit_published_portfolio_items |
Primitive | The matching core _posts capabilities |
The three singular names are meta capabilities. WordPress resolves them at check time through map_meta_cap(), so you never grant them to a role. Grant the plural primitives on plugin activation:
register_activation_hook( __FILE__, 'myplugin_grant_cpt_caps' );
function myplugin_grant_cpt_caps() {
// Primitive capabilities only. The singular meta capabilities
// (edit_portfolio_item, read_portfolio_item, delete_portfolio_item)
// are resolved by map_meta_cap() and are never stored on a role.
$cpt_caps = array(
'edit_portfolio_items',
'edit_others_portfolio_items',
'edit_private_portfolio_items',
'edit_published_portfolio_items',
'publish_portfolio_items',
'read_private_portfolio_items',
'delete_portfolio_items',
'delete_private_portfolio_items',
'delete_published_portfolio_items',
'delete_others_portfolio_items',
);
foreach ( array( 'administrator', 'editor' ) as $role_slug ) {
$role = get_role( $role_slug );
if ( ! $role ) {
continue;
}
foreach ( $cpt_caps as $cap ) {
$role->add_cap( $cap );
}
}
}
Without running myplugin_grant_cpt_caps() on activation, no one except super admins can reach the CPT in the admin UI. That is a common source of “I can’t see my CPT posts” bug reports. Grant subsets to other roles to follow least privilege: a client_manager might get edit_portfolio_items and publish_portfolio_items with no delete capabilities at all.
map_meta_cap: Object-Level Access Control
The map_meta_cap filter runs on every meta capability check, such as current_user_can( 'delete_portfolio_item', $post_id ), and lets you change the result based on the actual post object: its author, its status, or any custom field. Adding do_not_allow to the caps array is an unconditional deny.
One detail trips people up. When a CPT uses map_meta_cap => true, WordPress translates delete_portfolio_item into the core delete_post meta capability before the filter runs (you can see this in the map_meta_cap() source). So the filter receives $cap = 'delete_post', and you identify your CPT by checking the post type.
add_filter( 'map_meta_cap', 'myplugin_map_portfolio_caps', 10, 4 );
function myplugin_map_portfolio_caps( array $caps, string $cap, int $user_id, array $args ): array {
// CPT meta caps arrive here as the core name, so delete_portfolio_item is 'delete_post'.
if ( 'delete_post' !== $cap ) {
return $caps;
}
$post_id = $args[0] ?? 0;
$post = get_post( $post_id );
if ( ! $post || 'portfolio_item' !== $post->post_type ) {
return $caps;
}
// Allow only the author or an administrator to delete.
$is_author = ( (int) $post->post_author === $user_id );
$is_admin = user_can( $user_id, 'manage_options' );
if ( ! $is_author && ! $is_admin ) {
$caps[] = 'do_not_allow';
}
return $caps;
}
Notice the early returns when the capability or post type does not match. Filtering every map_meta_cap call without that guard is a common performance mistake: this filter fires for every capability check on every admin page load.
The filter also lets you invent your own meta capabilities. A name that is not part of any post type’s capability map reaches the filter unchanged, so you decide which primitives it requires:
// A custom meta capability that is not tied to a post type's capability map.
add_filter( 'map_meta_cap', 'myplugin_map_report_cap', 10, 4 );
function myplugin_map_report_cap( array $caps, string $cap, int $user_id, array $args ): array {
if ( 'myplugin_view_report' !== $cap ) {
return $caps;
}
$post = get_post( (int) ( $args[0] ?? 0 ) );
if ( ! $post ) {
return array( 'do_not_allow' );
}
// Authors can always view the report on their own item.
if ( (int) $post->post_author === $user_id ) {
return array( 'read' );
}
// Everyone else needs the primitive capability.
return array( 'myplugin_view_reports' );
}
// Usage:
if ( current_user_can( 'myplugin_view_report', $post_id ) ) {
myplugin_render_report( $post_id );
}
Return the array of primitive capabilities the user must hold, or array( 'do_not_allow' ) to block access. This keeps the permission logic in one place, and every current_user_can() call in the plugin stays a one-liner.
Protecting REST API Routes with User Capabilities
The permission_callback on a register_rest_route() call runs before the main callback, and because it calls current_user_can(), your map_meta_cap filter applies here too. The same object-level rule that blocks a UI action blocks the equivalent API call without any extra code.
// Register both routes inside a rest_api_init callback.
// Listing items needs only the primitive capability.
register_rest_route( 'myplugin/v1', '/portfolio-items', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'myplugin_list_portfolio_items',
'permission_callback' => function () {
return current_user_can( 'edit_portfolio_items' );
},
) );
// Deleting one item checks the meta capability against that item.
register_rest_route( 'myplugin/v1', '/portfolio-items/(?P<id>\d+)', array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => 'myplugin_delete_portfolio_item',
'permission_callback' => function( WP_REST_Request $request ) {
// map_meta_cap runs here: the same object-level check as in the admin UI.
return current_user_can( 'delete_portfolio_item', (int) $request['id'] );
},
'args' => array(
'id' => array(
'type' => 'integer',
'minimum' => 1,
'required' => true,
),
),
) );
When the permission callback returns false, WordPress responds with a 401 for a logged-out request or a 403 for a logged-in user who lacks the capability (see rest_authorization_required_code()), and your main callback never runs.
Never set permission_callback => '__return_true' on a route that modifies data, even during development. See the custom REST API endpoints guide for the full permission model, authentication options, and per-argument validate_callback patterns.
Hiding Admin Menu Items (and Why That Alone Is Not Enough)
remove_menu_page() removes the sidebar link, and a user who knows the URL can still reach the page. Always pair it with an admin_init check that calls wp_die().
// 1. Remove the menu item from the sidebar (cosmetic only).
add_action( 'admin_menu', 'myplugin_restrict_admin_menu' );
function myplugin_restrict_admin_menu(): void {
if ( ! current_user_can( 'edit_portfolio_items' ) ) {
remove_menu_page( 'edit.php?post_type=portfolio_item' );
}
}
// 2. Block direct URL access (defence in depth).
add_action( 'admin_init', 'myplugin_block_direct_portfolio_access' );
function myplugin_block_direct_portfolio_access(): void {
global $pagenow;
$post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';
$post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
$is_cpt_list = ( 'edit.php' === $pagenow && 'portfolio_item' === $post_type );
$is_cpt_edit = ( 'post.php' === $pagenow && $post_id && 'portfolio_item' === get_post_type( $post_id ) );
if ( ( $is_cpt_list || $is_cpt_edit ) && ! current_user_can( 'edit_portfolio_items' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'myplugin' ), 403 );
}
}
The double gate is defence in depth. Hiding the menu keeps the screen tidy for that user; the admin_init check is what actually stops them.
WooCommerce: Extending Built-In Shop Roles
WooCommerce registers shop_manager and customer roles on plugin activation. You can extend them the same way you extend any built-in role. Hook into woocommerce_init so the WooCommerce roles are guaranteed to exist first, and keep the has_cap() guard so the write only happens once. For WooCommerce performance tuning and store-specific configuration, see the WooCommerce optimization guide.
// Extend the shop_manager role after WooCommerce initialises.
add_action( 'woocommerce_init', 'myplugin_extend_shop_manager' );
function myplugin_extend_shop_manager(): void {
$role = get_role( 'shop_manager' );
if ( $role && ! $role->has_cap( 'manage_team_notes' ) ) {
$role->add_cap( 'manage_team_notes' );
}
}
Front-End Content Gating for Membership Sites
For membership or client portal scenarios, the same capability checks work on template_redirect to gate individual posts or post types. This pattern pairs well with the WordPress membership site guide for the subscription and payment layer.
add_action( 'template_redirect', 'myplugin_gate_member_content' );
function myplugin_gate_member_content(): void {
if ( ! is_singular( 'portfolio_item' ) ) {
return;
}
if ( ! is_user_logged_in() ) {
wp_safe_redirect( wp_login_url( get_permalink() ) );
exit;
}
if ( ! current_user_can( 'read_portfolio_item', get_the_ID() ) ) {
wp_die( esc_html__( 'You do not have access to this content.', 'myplugin' ), 403 );
}
}
The redirect sends unauthenticated visitors to the login screen with a redirect_to parameter so they land back on the content after logging in. Logged-in users without the right capability get a hard 403, which also avoids a redirect loop.
One caveat: for a published item, read_portfolio_item resolves to the basic read capability, which every logged-in user has. To limit published items to members, add a map_meta_cap rule for read_post on portfolio_item, using the same pattern as the delete rule above.
Cleaning Up on Deactivation and Uninstall
Capabilities added to existing roles are stored in wp_options and persist after your plugin is deactivated. Remove them in the deactivation hook, and remove the custom role entirely in uninstall.php. Deactivation should be reversible, which is why remove_role() belongs in the uninstall step: users assigned that role would otherwise lose access every time someone deactivates the plugin for testing.
// Remove capabilities on deactivation (the custom role stays until uninstall).
register_deactivation_hook( __FILE__, 'myplugin_remove_caps' );
function myplugin_remove_caps(): void {
$caps_to_remove = array( 'manage_team_notes' );
foreach ( array( 'editor', 'shop_manager' ) as $role_slug ) {
$role = get_role( $role_slug );
if ( ! $role ) {
continue;
}
foreach ( $caps_to_remove as $cap ) {
$role->remove_cap( $cap );
}
}
}
// uninstall.php: runs only when the plugin is deleted.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
// delete_option( 'myplugin_settings' ); // remove plugin options
remove_role( 'client_manager' );
Also remove the CPT caps you granted to editors and administrators during activation, following the same loop pattern used in myplugin_grant_cpt_caps() with remove_cap() in place of add_cap().
Quick-Reference Checklist
- Check permissions with
current_user_can( 'capability' ). Keep role-name checks for display logic only. - Use
register_activation_hook()foradd_role()and initialadd_cap()calls, so they run once. - Prefix custom capability slugs with your plugin slug (for example
myplugin_view_reports). - Set
capability_type => 'your_post_type'+map_meta_cap => truefor any CPT that needs per-object access control. - Grant the generated plural CPT capabilities to the relevant roles during plugin activation.
- Use the
map_meta_capfilter for object-level decisions; guard on both the capability name and the post type. - Always pair
remove_menu_page()with anadmin_initserver-side block. - Use
permission_callbackwithcurrent_user_can()on every REST route that modifies data. - Strip added caps in the deactivation hook; call
remove_role()only inuninstall.php.
Roles and capabilities decide who can do what. The data those users submit still needs checking, so sanitize and validate user input on every form and REST endpoint your custom roles touch, and see proactive WordPress security measures for the hardening that sits alongside capability checks. If you need a role and capability setup built into a plugin for your team, that is part of my WordPress plugin development work.


