Roles and Capabilities API
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
WordPress decides what a user may do with capabilities, named permissions such as edit_posts, grouped into roles.
Plugins add capabilities for their content, create roles, and refine both at run time with two filters. This page, for
plugin developers, builds the Catalogue Manager role of lumen-catalog, restricts Authors' book rights, and checks
every rule from PHP, WP-CLI and REST. The site owner’s view is in
Users, Roles and Profiles.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28.
WordPress 7.2 is in beta and due in December 2026; nothing on this page depends on it.
Every output below comes from a 7.1.3 site (official wordpress:7.1.3-php8.4-apache image, MariaDB 11.8, table prefix
lp_) running lumen-catalog, first as a single site and then converted to a Multisite network. A disposable site is
described in Local Development Environments.
The page builds on Custom Post Types and Taxonomies: lp_book
has its own capabilities ('capability_type' => array( 'lp_book', 'lp_books' ), 'map_meta_cap' => true), its
taxonomies use manage_lp_terms, activation grants all of them to Administrators and Editors, and that page explains
meta versus primitive capabilities.
How roles and capabilities are stored
Roles live in the autoloaded {prefix}user_roles option (lp_user_roles here): each role’s slug, display name and
map of capability to true/false, loaded into the global WP_Roles object (wp_roles()). A user’s roles, and any
capability granted to that user alone, are in the {prefix}capabilities user meta (lp_capabilities), for example
array( 'lp_catalogue_manager' => true ); at run time WP_User merges both into $user->allcaps. On Multisite each
site has its own option (lp_2_user_roles, …) and each user one meta key per site, while super admins are listed in
the site_admins network option and are not a role (see Multisite and super admins). The {prefix}user_level
meta is a leftover of WordPress 1.5 user levels; never check it. The API:
| API | Since | Purpose |
|---|---|---|
|
2.0 |
Stores a new role. Returns |
|
2.0 |
Deletes the role from the option. Users keep the slug in their meta and end up with no capabilities; if it was the |
|
2.0 |
The |
|
2.0 |
Change one capability of a stored role (writes the option) |
|
2.0 |
Per-user grants and role assignment (writes user meta) |
|
2.0 |
Checks, mapping meta capabilities first |
|
6.7 |
Switches site and checks; replaces |
|
2.0 / 2.8 |
Translates a meta capability into the primitive capabilities required |
|
2.0 |
Edits the user’s |
|
4.7 |
Fires after roles are loaded; for adjusting role objects in memory only |
The Catalogue Manager role
Users, Roles and Profiles
describes the role: a sales assistant who manages every book, its cover and its genres and series, with no access to
posts, pages, comments, plugins, themes, settings or users. In capabilities, that is
read, upload_files, the ten primitive lp_book capabilities and manage_lp_terms, and nothing else; in particular
not edit_posts, so the Posts, Pages and Comments menus disappear.
Lumen Press also lets its staff writers (Authors) propose entries for their own titles: Authors get edit_lp_books and
delete_lp_books, but not publish_lp_books, edit_others_lp_books or edit_published_lp_books, so core’s mapping
already restricts them to their own unpublished books.
Once, on activation, never on init
add_role() writes to the database and ignores a slug that exists. Calling it on every init, as one of the older books
does, achieves nothing after the first request and misleads: changing the capability array later changes nothing where
the role exists, and deleting the code does not delete the role. (The Plugin Handbook’s short example also uses init,
with a caveat.) On 7.1.3:
$ wp eval 'var_dump( add_role( "lp_catalogue_manager", "Catalogue Manager", array( "read" => true, "lp_new_cap" => true ) ) ); var_dump( get_role( "lp_catalogue_manager" )->has_cap( "lp_new_cap" ) );'
NULL
bool(false)
Source: Code Reference: add_role()
Stored permissions follow the rule for tables and rewrite rules: create on activation, change in a versioned upgrade
routine, remove on uninstall. Deactivation keeps them, so users do not lose their role when the plugin is briefly off;
meanwhile the lp_book capabilities are inert, since nothing checks them.
src/Roles.php
<?php
/**
* The Catalogue Manager role and the lp_book capabilities of built-in roles.
*
* @package Lumen\Catalog
*/
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
/**
* Creates and removes stored roles and capabilities. Never hooked to init.
*/
final class Roles {
const ROLE = 'lp_catalogue_manager';
/**
* The ten primitive lp_book capabilities plus manage_lp_terms (literal: uninstall has no post type).
*
* @return string[]
*/
public static function book_caps(): array {
return array(
'edit_lp_books',
'edit_others_lp_books',
'edit_private_lp_books',
'edit_published_lp_books',
'publish_lp_books',
'read_private_lp_books',
'delete_lp_books',
'delete_others_lp_books',
'delete_private_lp_books',
'delete_published_lp_books',
'manage_lp_terms',
);
}
/**
* Activation (and upgrade): create the role and grant Authors draft rights. Idempotent.
*/
public static function install(): void {
$caps = array_fill_keys( array_merge( array( 'read', 'upload_files', 'manage_lumen_catalog' ), self::book_caps() ), true );
$role = get_role( self::ROLE );
if ( null === $role ) {
add_role( self::ROLE, 'Catalogue Manager', $caps );
} else {
// add_role() ignores an existing role, so a later version adds new capabilities here.
foreach ( array_keys( $caps ) as $cap ) {
$role->add_cap( $cap );
}
}
// Administrators also manage the catalogue settings screen.
$admin = get_role( 'administrator' );
if ( $admin ) {
$admin->add_cap( 'manage_lumen_catalog' );
}
// Authors may propose books (drafts) but not publish them or touch other people's books.
$author = get_role( 'author' );
if ( $author ) {
$author->add_cap( 'edit_lp_books' );
$author->add_cap( 'delete_lp_books' );
}
}
/**
* Uninstall: move Catalogue Managers to Subscriber, drop the role, every lp_book capability and manage_lumen_catalog.
*/
public static function uninstall(): void {
foreach ( get_users( array( 'role' => self::ROLE ) ) as $user ) {
$user->set_role( 'subscriber' );
}
remove_role( self::ROLE );
foreach ( wp_roles()->role_objects as $role ) {
foreach ( array_merge( self::book_caps(), array( 'manage_lumen_catalog' ) ) as $cap ) {
if ( $role->has_cap( $cap ) ) {
$role->remove_cap( $cap );
}
}
}
}
/**
* Multisite: a site created while the plugin is network-active gets the role too.
* Hooked to wp_initialize_site after core (priority 10) has created the site's tables.
*
* @param \WP_Site $site The new site.
*/
public static function install_on_new_site( \WP_Site $site ): void {
if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
if ( ! is_plugin_active_for_network( plugin_basename( LUMEN_CATALOG_FILE ) ) ) {
return;
}
switch_to_blog( $site->id );
Installer::grant_capabilities();
self::install();
restore_current_blog();
}
}
The slug carries the plugin prefix. The display name is stored untranslated: translate_user_role() translates role
names at display time (with a text domain argument since 5.2), whereas a translated string at creation would freeze one
language in the database. The capability list is literal because uninstall.php runs without the post type registered.
Upgrading an existing role uses add_cap(), since add_role() ignores it. Uninstall moves Catalogue Managers to
Subscriber first, because remove_role() would leave them with a dangling slug and no capabilities, not even read.
Built-in roles are only extended, never redefined, and themes create no roles at all: roles outlive a theme switch.
Wiring it into the life cycle
Two calls connect the class to the life cycle of Plugin Fundamentals:
Installer::activate() calls Roles::install() right after self::grant_capabilities() (the post type and
taxonomies are registered by then), and Installer::uninstall_site() calls Roles::uninstall() before it deletes
the options. Plugin::boot() hooks the rest of this page: Capabilities::register(),
add_action( 'wp_initialize_site', array( Roles::class, 'install_on_new_site' ), 900 ) and
add_action( 'rest_api_init', array( StockController::class, 'register_routes' ) ).
Activation hooks do not run when a plugin is updated. A release that introduces or changes the role therefore also
bumps DB_VERSION and calls Roles::install() in the matching step of Installer::upgrade() (for example
if ( $installed < 3 ) { Roles::install(); }), which runs on the first request after the update. install() is
idempotent, so running it from both places is harmless.
Checking the result with WP-CLI
$ wp role list
name role
Administrator administrator
Editor editor
Author author
Contributor contributor
Subscriber subscriber
Catalogue Manager lp_catalogue_manager
$ wp cap list lp_catalogue_manager
read
upload_files
manage_lumen_catalog
edit_lp_books
edit_others_lp_books
edit_private_lp_books
edit_published_lp_books
publish_lp_books
read_private_lp_books
delete_lp_books
delete_others_lp_books
delete_private_lp_books
delete_published_lp_books
manage_lp_terms
Source: WP-CLI Commands: wp cap list
The test users are admin (Administrator, ID 1), maria.editor (Editor, 2), tom.author (Author, 3),
lena.catalogue (given the role with wp user set-role, 4) and sam.reader (Subscriber, 5). Logged in as lena.catalogue, the admin menu
has the Catalogue menu (with Genres and Series) and no Posts or Pages menu; edit.php, edit.php?post_type=page and
options-general.php answer 403 with "Sorry, you are not allowed to access this page", while the book list, the block
editor for books and the Genres screen load.
wp role create, wp role delete, wp cap add and wp cap remove change the same option, and wp role reset
restores built-in roles to their defaults. They suit staging fixes; a role the plugin’s code relies on belongs in the
plugin, so that every environment gets it the same way.
Refining permissions with map_meta_cap
Roles hold primitive capabilities only (granting edit_lp_book to a role has no effect, since it is always mapped).
Code calls current_user_can( 'edit_lp_book', $book_id ), and map_meta_cap() decides which primitive capabilities
that book requires. For tom.author and three books,
The Glass Orchard (5, published, by lena.catalogue), Salt Roads (6, draft, by tom.author) and Night Ferry
(7, pending review, by tom.author):
$ wp eval 'foreach ( array( 5, 6, 7 ) as $id ) { echo $id, ": ", implode( ", ", map_meta_cap( "edit_lp_book", 3, $id ) ), "\n"; }'
5: edit_others_lp_books, edit_published_lp_books
6: edit_lp_books
7: edit_lp_books, edit_others_lp_books
Source: Code Reference: map_meta_cap()
Core’s mapping already gives "edit only own books": someone else’s book requires edit_others_lp_books, and a published
own book requires edit_published_lp_books, neither of which Authors have. Book 7 shows a rule core cannot express and
the plugin adds: once an Author submits a book for review, it is locked for them until an Editor or Catalogue Manager
publishes or returns it. Without the filter, core would ask only for edit_lp_books for an own pending post.
The filter receives the primitive capabilities core computed and returns a new list: adding do_not_allow denies
everyone, super admins included; adding a capability denies those who lack it.
<?php
/**
* Runtime capability rules for books.
*
* @package Lumen\Catalog
*/
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
/**
* Filters that refine what the stored capabilities allow.
*/
final class Capabilities {
public static function register(): void {
add_filter( 'map_meta_cap', array( self::class, 'lock_submitted_books' ), 10, 4 );
add_filter( 'user_has_cap', array( self::class, 'freeze_catalogue' ), 10, 4 );
}
/**
* Authors edit only their own books, and only until they submit them for review.
*
* @param string[] $caps Primitive capabilities core requires.
* @param string $cap Capability checked: edit_post, not edit_lp_book (see below).
* @param int $user_id User being checked.
* @param array $args Context; $args[0] is the post ID.
*/
public static function lock_submitted_books( $caps, $cap, $user_id, $args ) {
if ( ! in_array( $cap, array( 'edit_post', 'delete_post' ), true ) || empty( $args[0] ) ) {
return $caps;
}
$post = get_post( (int) $args[0] );
if ( ! $post || 'lp_book' !== $post->post_type || 'pending' !== $post->post_status ) {
return $caps;
}
// Core asked for edit_lp_books (own pending book); require the reviewer's capability instead.
if ( (int) $post->post_author === (int) $user_id ) {
$caps[] = 'edit_others_lp_books';
}
return $caps;
}
/**
* While the catalogue is frozen for stock-taking, only administrators change books.
*
* @param bool[] $allcaps Capabilities the user has (role and user grants combined).
* @param string[] $caps Primitive capabilities being checked.
* @param array $args Requested capability, user ID, then context.
* @param \WP_User $user The user.
*/
public static function freeze_catalogue( $allcaps, $caps, $args, $user ) {
$settings = (array) get_option( 'lp_catalog_settings', array() );
if ( empty( $settings['catalogue_frozen'] ) || ! empty( $allcaps['manage_options'] ) ) {
return $allcaps;
}
foreach ( Roles::book_caps() as $book_cap ) {
if ( 'manage_lp_terms' !== $book_cap ) {
$allcaps[ $book_cap ] = false;
}
}
return $allcaps;
}
}
The filter tests edit_post, not edit_lp_book: for a custom post type, map_meta_cap() looks the name up in the
$post_type_meta_caps global and calls itself with the generic name (edit_post, delete_post, read_post), and only
that inner call reaches the filter. It must also check the post type, since it sees every post, and stay cheap: it runs
on every capability check, often hundreds of times per admin screen.
The metadata field lp_isbn in Metadata and Custom Fields restricts edits in
Meta::can_edit_isbn(), which checks the book capability edit_others_lp_books rather than edit_others_posts;
that is why a Catalogue Manager, who has no edit_others_posts, can still change ISBNs.
Granting or denying at run time with user_has_cap
user_has_cap runs after the mapping and returns a modified allcaps array for one check; nothing is stored. It suits
rules that depend on state (a time window, a setting, a subscription). Capabilities::freeze_catalogue() above removes every
book capability (except term management) from everyone without manage_options while the catalogue_frozen setting of
the lp_catalog_settings option is on, for a stock-taking weekend.
Logic about one object fits map_meta_cap better. A grant made with user_has_cap is invisible to wp cap list, the
Users screen and role editors, so document it.
Checking capabilities in PHP
Always check a capability, and for one object pass its ID so the meta capability is mapped:
| Write | Not |
|---|---|
|
|
|
|
|
|
|
|
|
|
a nonce and a capability check |
a nonce alone: nonces prove intent, not permission |
Checking edit_post (or edit_lp_book) without an object ID is reported with _doing_it_wrong since 6.1 and denied.
The nonce, sanitising and escaping side of the same checks is on
Security for Developers.
The resulting matrix, printed with user_can() for every test user:
<?php
// wp eval-file matrix.php: one row per check, one column per user.
$logins = array( 'admin', 'maria.editor', 'tom.author', 'lena.catalogue', 'sam.reader' );
$checks = array( array( 'edit_lp_books' ), array( 'publish_lp_books' ), array( 'edit_lp_book', 5 ), array( 'edit_lp_book', 6 ),
array( 'edit_lp_book', 7 ), array( 'delete_lp_book', 6 ), array( 'manage_lp_terms' ), array( 'edit_posts' ) );
vprintf( '%-20s' . str_repeat( '%-16s', 5 ) . "\n", array_merge( array( 'capability' ), $logins ) );
foreach ( $checks as $check ) {
printf( '%-20s', implode( ' ', $check ) );
foreach ( $logins as $login ) {
printf( '%-16s', user_can( get_user_by( 'login', $login ), ...$check ) ? 'yes' : 'no' );
}
echo "\n";
}
Source: Code Reference: user_can()
capability admin maria.editor tom.author lena.catalogue sam.reader
edit_lp_books yes yes yes yes no
publish_lp_books yes yes no yes no
edit_lp_book 5 yes yes no yes no
edit_lp_book 6 yes yes yes yes no
edit_lp_book 7 yes yes no yes no
delete_lp_book 6 yes yes yes yes no
manage_lp_terms yes yes no yes no
edit_posts yes yes yes no no
With wp option update lp_catalog_settings '{"catalogue_frozen":true}' --format=json the same script prints no for
every book row except under admin, while manage_lp_terms and edit_posts are unchanged.
Capabilities in the REST API
The core endpoints of a post type check its capabilities themselves: /wp/v2/lp_book uses create_posts (here
edit_lp_books) to create, publish_lp_books for a published status, and the edit_lp_book meta capability to update,
so both filters apply to REST and to the block editor without extra code. Custom routes must say who may call them in
permission_callback (mandatory since 5.5; omitting it triggers a _doing_it_wrong notice and leaves the route
public). The lumen/v1 stock route lets anyone who may edit a book correct its stock count:
<?php
/**
* REST route lumen/v1/books/<id>/stock.
*
* @package Lumen\Catalog
*/
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
/**
* Lets people who may edit a book correct its stock count.
*/
final class StockController {
public static function register_routes(): void {
register_rest_route(
'lumen/v1',
'/books/(?P<id>\d+)/stock',
array(
'methods' => 'PUT',
'callback' => array( self::class, 'update' ),
'permission_callback' => array( self::class, 'can_update' ),
'args' => array(
'id' => array( 'type' => 'integer', 'required' => true ),
'quantity' => array( 'type' => 'integer', 'minimum' => 0, 'required' => true ),
),
)
);
}
/**
* The meta capability, checked against this book: ownership, status and filters all apply.
*
* @param \WP_REST_Request $request Request.
* @return true|\WP_Error
*/
public static function can_update( \WP_REST_Request $request ) {
$book = get_post( (int) $request['id'] );
if ( ! $book || 'lp_book' !== $book->post_type ) {
return new \WP_Error( 'lp_book_not_found', __( 'No such book.', 'lumen-catalog' ), array( 'status' => 404 ) );
}
if ( ! current_user_can( 'edit_lp_book', $book->ID ) ) {
return new \WP_Error(
'lp_cannot_update_stock',
__( 'Sorry, you are not allowed to change the stock of this book.', 'lumen-catalog' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Stores the new quantity in the lp_stock post meta and records the change in the stock history.
*
* @param \WP_REST_Request $request Request.
*/
public static function update( \WP_REST_Request $request ) {
global $wpdb;
$book_id = (int) $request['id'];
$quantity = (int) $request['quantity'];
$previous = (int) get_post_meta( $book_id, 'lp_stock', true );
update_post_meta( $book_id, 'lp_stock', $quantity );
$row = array( 'book_id' => $book_id, 'delta' => $quantity - $previous, 'quantity' => $quantity, 'changed_at' => current_time( 'mysql', true ) );
$wpdb->insert( $wpdb->prefix . 'lp_stock', $row, array( '%d', '%d', '%d', '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return rest_ensure_response( array( 'book' => $book_id, 'quantity' => $quantity ) );
}
}
rest_authorization_required_code() gives 401 to anonymous callers and 403 to logged-in users. Each test user has an
application password ($LENA_APP_PASSWORD, …; the site’s WP_ENVIRONMENT_TYPE is local, which allows them over
HTTP), and each request has the form
curl -s -u "lena.catalogue:$LENA_APP_PASSWORD" -X PUT -H 'Content-Type: application/json' "$SITE/wp-json/lumen/v1/books/5/stock" -d '{"quantity":40}'
(see REST API Handbook: Authentication):
user method route status code or result
maria.editor POST /wp/v2/lp_book (draft) 201 id=8 status=draft
tom.author POST /wp/v2/lp_book (draft) 201 id=9 status=draft
lena.catalogue POST /wp/v2/lp_book (draft) 201 id=10 status=draft
sam.reader POST /wp/v2/lp_book (draft) 403 rest_cannot_create
tom.author POST /wp/v2/lp_book (publish) 403 rest_cannot_publish
tom.author POST /wp/v2/lp_book/5 403 rest_cannot_edit
lena.catalogue POST /wp/v2/lp_book/5 200 id=5 status=publish
tom.author POST /wp/v2/lp_book/6 200 id=6 status=draft
tom.author POST /wp/v2/lp_book/7 403 rest_cannot_edit
lena.catalogue POST /wp/v2/posts (draft) 403 rest_cannot_create
tom.author PUT /lumen/v1/books/5/stock 403 lp_cannot_update_stock
lena.catalogue PUT /lumen/v1/books/5/stock 200 {"book":5,"quantity":40}
The 403 on book 7 is the map_meta_cap filter at work; the 403 on /wp/v2/posts shows the Catalogue Manager confined to
books. With the catalogue frozen, the three draft creations by maria.editor, tom.author and lena.catalogue return
403 rest_cannot_create as well.
Multisite and super admins
Roles are per site on a network, so a user can be an Editor on one site and a Catalogue Manager on another. For a plugin this means:
-
Network activation runs the activation callback once, on the main site, with
$network_widetrue, so the role exists only there. Loop overget_sites()withswitch_to_blog()(slow on large networks), or create the role in the per-site upgrade routine, which runs on each site’s first request. -
Sites created later need the role too:
Roles::install_on_new_site()hookswp_initialize_siteat priority 900, after core (priority 10) has created the new site’s tables and default roles. -
Uninstall runs once for the network;
lumen-catalog'suninstall.phpalready loops over the sites and callsInstaller::uninstall_site(), and thereforeRoles::uninstall(), on each.
On the test network, with the plugin first active on the main site only, Lumen Kids created at that point, then the plugin network-activated and Lumen Noir created afterwards:
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test
Success: Role with ID 'lp_catalogue_manager' exists.
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test/kids
Error: Role with ID 'lp_catalogue_manager' does not exist.
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test/noir
Success: Role with ID 'lp_catalogue_manager' exists.
Super admins are the logins in the site_admins network option (grant_super_admin(), revoke_super_admin(),
wp super-admin add). WP_User::has_cap() returns true for them on every site for any capability, even an unknown
one, unless the mapped capabilities contain do_not_allow; the user_has_cap filter is not called for them. Only
super admins pass the network capabilities (manage_network, manage_network_plugins, …), and on Multisite core
maps unfiltered_html, delete_users, update_core and plugin installation to super admins only, so a site
Administrator lacks them. With maria.editor made a super admin and the catalogue frozen:
$ wp super-admin add maria.editor
Success: Granted super-admin capabilities to 1 user.
$ wp eval '$maria = get_user_by( "login", "maria.editor" ); foreach ( array( array( "edit_lp_book", 7 ), array( "edit_lp_book", 999 ), array( "manage_network_plugins" ), array( "lumen_anything" ) ) as $c ) { printf( "%-26s %s\n", implode( " ", $c ), user_can( $maria, ...$c ) ? "yes" : "no" ); }'
edit_lp_book 7 yes
edit_lp_book 999 no
manage_network_plugins yes
lumen_anything yes
The freeze did not stop her, and an undefined capability returns yes; only book 999, which does not exist, was mapped
to do_not_allow. A rule that must bind super admins belongs in map_meta_cap with do_not_allow. Do not use
is_super_admin() as a permission check: on a single site it is true for anyone with delete_users.
Uninstalling from the network
After wp plugin deactivate lumen-catalog --network and wp plugin uninstall lumen-catalog, the sites had lost the
role and the book capabilities, and the Catalogue Manager had become a Subscriber:
$ wp role exists lp_catalogue_manager
Error: Role with ID 'lp_catalogue_manager' does not exist.
$ wp user get lena.catalogue --field=roles
subscriber
$ wp cap list editor | grep -c lp_
0
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test/noir
Error: Role with ID 'lp_catalogue_manager' does not exist.
Where older books are outdated
| Older claim | Current behaviour (WordPress 7.1) |
|---|---|
Register a custom role with |
The role is stored on the first call and every later call returns |
Restrict an admin feature with |
|
|
Deprecated in 6.7 in favour of |