14+ years building on WordPress / Replies in under 5 hours
Development 16 min read · Updated October 2026

WordPress Custom User Roles and Capabilities: current_user_can(), add_role() and map_meta_cap

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Extending WordPress User Roles and Capabilities: A Developer’s Guide
TL;DR

WordPress gates every action through capabilities, and roles are named bundles of them, so check permissions with <code>current_user_can( 'capability' )</code> and keep role-name checks for display logic. Create roles with <code>add_role()</code> and grant capabilities with <code>add_cap()</code> on plugin activation, give custom post types <code>capability_type</code> plus <code>map_meta_cap => true</code>, and use the <code>map_meta_cap</code> filter for per-post rules, which also apply inside REST <code>permission_callback</code> checks. Strip added capabilities on deactivation and call <code>remove_role()</code> only in <code>uninstall.php</code>.

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() for add_role() and initial add_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 => true for 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_cap filter for object-level decisions; guard on both the capability name and the post type.
  • Always pair remove_menu_page() with an admin_init server-side block.
  • Use permission_callback with current_user_can() on every REST route that modifies data.
  • Strip added caps in the deactivation hook; call remove_role() only in uninstall.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.

Frequently asked questions

Check a capability. Passing a role slug such as current_user_can( 'subscriber' ) only returns true for users who hold exactly that role, so it fails for editors and administrators, and the WordPress developer reference calls the practice discouraged because it may produce unreliable results. If you need to know a user's role for display purposes, read wp_get_current_user()->roles and keep access decisions on capabilities.

A capability is a single permission, a string like edit_posts or manage_options that allows one specific action. A role is a named bundle of capabilities, such as Editor or a custom Client Manager role, and a user gets every capability in the roles assigned to them. Your code should check capabilities with current_user_can(), because the same permission can reach a user through different roles or direct grants.

A primitive capability is a simple boolean flag stored on a role, such as edit_posts or manage_options. A meta capability is a named permission check, such as edit_post or delete_post, that WordPress resolves at runtime by calling map_meta_cap(). Meta capabilities accept a post ID as context, which is how WordPress decides whether a user can edit one specific post as well as posts in general.

When you set capability_type to a custom slug and map_meta_cap => true, WordPress generates a new set of capabilities that no role holds yet. Editors keep their edit_posts primitive, but the CPT now requires edit_portfolio_items (or your equivalent). Grant the generated plural capabilities, for example get_role( 'editor' )->add_cap( 'edit_portfolio_items' ), in your plugin activation hook to restore their access.

Pass a permission_callback to register_rest_route() that returns current_user_can() for the capability the route needs, using a meta capability plus the object ID for routes that act on one item. Your map_meta_cap filter runs inside that check too, so object-level rules from the admin apply to the API automatically. Never use __return_true on a route that changes data.

Use the uninstall hook (uninstall.php). Deactivation should be reversible, and removing a role there means users assigned that role lose all access the moment someone deactivates the plugin for testing. Use register_deactivation_hook() only to remove capabilities you added to existing roles, and reserve remove_role() for uninstall.php, which runs only when the plugin is deleted.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance, the kind of work that shows up as faster load times and fewer support tickets. You work directly with me, and replies typically arrive within 5 hours.

Work with me →