Add Custom User Roles and Capabilities to WordPress Programmatically

WordPress user roles are collections of capabilities stored in the wp_user_roles option — each role is a named array of capabilities mapped to boolean values, and a user can have multiple roles assigned simultaneously. Adding a custom role is done with add_role(), which accepts a slug, a display name, and an array of capabilities — the function must run only once (typically on plugin activation via register_activation_hook()) because it writes to the database; running it on every request via init is a performance anti-pattern. Removing a role on plugin deactivation uses remove_role(). Custom capabilities are added to existing roles with $role->add_cap('custom_capability') retrieved via get_role('editor'), and checked in code with current_user_can('custom_capability'). The map_meta_cap filter maps primitive meta-capabilities (like edit_post for a specific post) to base capabilities (like edit_others_posts) — custom post types registered with capability_type set to a custom string require this filter to map their generated capabilities to the base capabilities of the role that should manage them. Role and capability checks should be the primary authorization mechanism for custom REST endpoints, admin pages, and AJAX handlers — never rely solely on nonce verification for authorization, as nonces only prevent CSRF and do not verify what the user is allowed to do. The user_has_cap filter allows dynamically granting or revoking capabilities at runtime based on post ownership, user meta, or subscription status — useful for membership plugins that need capability-based access without permanently modifying stored roles. The REST API endpoints post shows how current_user_can() is used inside permission_callback to restrict endpoint access to specific capabilities.

Problem: A WordPress site needs an "Editor Plus" role that can publish posts and manage WooCommerce orders but cannot install plugins, change themes, or access user management — the built-in Editor role does not include order management and the Shop Manager role has too many capabilities.

Solution: Create a custom role on plugin activation with add_role(), copy the base capabilities from the built-in Editor role, add the WooCommerce order management capabilities needed, and verify the role is removed cleanly on plugin deactivation.

// plugin.php — register activation/deactivation hooks
register_activation_hook(__FILE__,   'myplugin_add_editor_plus_role');
register_deactivation_hook(__FILE__, 'myplugin_remove_editor_plus_role');

function myplugin_add_editor_plus_role(): void {
    // Avoid duplicate on re-activation
    if (get_role('editor_plus')) return;

    // Start with the Editor capabilities as a base
    $editor_caps = get_role('editor')?->capabilities ?? [];

    // WooCommerce order management capabilities
    $woo_caps = [
        'edit_shop_orders'         => true,
        'read_shop_orders'         => true,
        'edit_others_shop_orders'  => true,
        'publish_shop_orders'      => true,
        'delete_shop_orders'       => false, // no delete
    ];

    add_role(
        'editor_plus',
        __('Editor Plus', 'myplugin'),
        array_merge($editor_caps, $woo_caps)
    );
}

function myplugin_remove_editor_plus_role(): void {
    remove_role('editor_plus');
}

// Dynamically grant a capability based on user meta (e.g. subscription)
add_filter('user_has_cap', function(array $allcaps, array $caps, array $args, WP_User $user): array {
    if (in_array('access_premium_content', $caps, true)) {
        if (get_user_meta($user->ID, 'is_premium', true)) {
            $allcaps['access_premium_content'] = true;
        }
    }
    return $allcaps;
}, 10, 4);

// Guard a custom admin page
add_action('admin_menu', function() {
    add_submenu_page(
        'woocommerce',
        __('Order Reports', 'myplugin'),
        __('Order Reports', 'myplugin'),
        'read_shop_orders',   // capability required
        'myplugin-order-reports',
        'myplugin_order_reports_page'
    );
});

NOTE: Never store role definitions as hardcoded arrays in theme functions — use a plugin for capability management so the roles survive theme switches. If the plugin is deactivated without calling remove_role(), orphaned roles remain in the database but are harmless; the next activation will skip creation because get_role() returns non-null. Always test capability checks with a non-admin test account before deployment.