WordPress ships with five default roles and a capability system that can feel opaque until you see how all 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 walks through every pattern you will actually need when building plugins or complex sites.
The Default Role and Capability Hierarchy
WordPress stores roles and their capabilities in the wp_user_roles option. Every role is a label that carries a list of primitive capabilities (boolean flags). A meta capability is a higher-level check — edit_post, delete_post — that WordPress maps to the appropriate primitive at runtime through map_meta_cap(). This distinction matters most when you register custom post types.
| 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 |
For a deep dive into add_role() and basic current_user_can() usage, see the Custom User Roles guide. This article focuses on the advanced patterns: CPT-generated capabilities, object-level access control, REST gating, and WooCommerce.
Registering a Custom Role on Plugin Activation
Always tie add_role() to register_activation_hook(). Calling it on init runs on every request and produces log noise; hooking it to activation means it runs exactly once. add_role() is already idempotent — it silently returns null if the role slug already exists — but limiting the call to activation is 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 — safe to call on every activation.
add_role(
'client_manager',
__( 'Client Manager', 'myplugin' ),
array(
'read' => true,
'edit_posts' => false,
'delete_posts' => false,
)
);
}
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. The has_cap() guard prevents redundant writes to the database on every init.
function myplugin_extend_editor_role() {
$role = get_role( 'editor' );
if ( $role && ! $role->has_cap( 'manage_team_notes' ) ) {
$role->add_cap( 'manage_team_notes' );
}
}
add_action( 'init', 'myplugin_extend_editor_role' );
Capabilities added this way persist in the database. Remove them on plugin deactivation (see the cleanup section below) to leave the database in a clean state.
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.
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 auto-generates a full set of capabilities for your CPT — none of them granted to any role yet. You must grant them explicitly on plugin activation:
| Auto-generated capability | Maps to primitive |
|---|---|
edit_portfolio_item |
edit_post |
read_portfolio_item |
read_post |
delete_portfolio_item |
delete_post |
edit_portfolio_items |
edit_posts |
edit_others_portfolio_items |
edit_others_posts |
publish_portfolio_items |
publish_posts |
read_private_portfolio_items |
read_private_posts |
delete_portfolio_items |
delete_posts |
register_activation_hook( __FILE__, 'myplugin_grant_cpt_caps' );
function myplugin_grant_cpt_caps() {
$cpt_caps = array(
'edit_portfolio_item',
'read_portfolio_item',
'delete_portfolio_item',
'edit_portfolio_items',
'edit_others_portfolio_items',
'publish_portfolio_items',
'read_private_portfolio_items',
'delete_portfolio_items',
'delete_private_portfolio_items',
'delete_published_portfolio_items',
'delete_others_portfolio_items',
'edit_private_portfolio_items',
'edit_published_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 access the CPT in the admin UI — a common source of “I can’t see my CPT posts” bug reports.
map_meta_cap: Object-Level Access Control
The map_meta_cap filter intercepts every call to current_user_can( 'edit_portfolio_item', $post_id ) and lets you override the result based on the actual post object — its author, its status, or any custom field. Returning do_not_allow inside the caps array is an unconditional deny that short-circuits all other checks.
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 {
if ( 'delete_portfolio_item' !== $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 return when the cap or post type does not match. Filtering every map_meta_cap call without guarding the specific cap is a common performance mistake — this filter fires on every admin page load for every capability check in the system.
Protecting REST API Routes with User Capabilities
The permission_callback on a register_rest_route() call runs current_user_can() before the main callback is invoked — which means 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_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,
),
),
) );
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 but does not block direct URL access — 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;
$is_cpt_list = ( 'edit.php' === $pagenow && ( $_GET['post_type'] ?? '' ) === 'portfolio_item' );
$is_cpt_edit = ( 'post.php' === $pagenow && get_post_type( $_GET['post'] ?? 0 ) === 'portfolio_item' );
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 (UI hide + server block) is defence in depth. The UI gate is about UX; the server gate is about security.
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 — but hook into woocommerce_init rather than init so the WooCommerce roles are guaranteed to exist first. 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' );
}
}
// Restrict WooCommerce order export to administrators only.
add_filter( 'woocommerce_prevent_admin_access', 'myplugin_limit_wc_admin' );
function myplugin_limit_wc_admin( bool $prevent ): bool {
if ( is_admin() && current_user_can( 'shop_manager' ) && ! current_user_can( 'manage_options' ) ) {
// Prevent shop managers from reaching wp-admin pages outside WooCommerce.
return true;
}
return $prevent;
}
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 instead of a redirect loop.
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; remove the custom role entirely in uninstall.php. Deactivation should be reversible — that is why remove_role() belongs in the uninstall hook, not deactivation.
// Remove capabilities on deactivation (roles persist; caps on existing roles do not).
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 on full plugin deletion.
// 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() but calling remove_cap() instead. For sanitising and validating the data those roles submit, see the WordPress sanitization vs validation guide.
Quick-Reference Checklist
- Use
register_activation_hook()foradd_role()and initialadd_cap()calls — notinit. - Set
capability_type => 'your_post_type'+map_meta_cap => truefor any CPT that needs per-object access control. - Grant the generated CPT capabilities to the relevant roles during plugin activation.
- Use the
map_meta_capfilter for object-level decisions; guard on both cap name and 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.


