How to create a granular permissions system via metacapabilities in PHP in WordPress

Contents

Overview

This article is a complete, detailed tutorial on building a granular permissions system in WordPress using metacapabilities. It explains the concepts behind capabilities and metacapabilities, shows how to register custom capabilities, implements a robust map_meta_cap callback for per-object access control, integrates with the REST API and admin UI, discusses performance and security considerations, and provides reusable code examples you can drop into a plugin. Every example code block is provided for direct use.

Concepts and terminology

  • Primitive capabilities: simple caps stored on roles/users (examples: edit_posts, publish_posts). These are what WP actually checks against a users capability array.
  • Meta capabilities: higher-level capabilities that depend on context (examples: edit_post, read_post). They require mapping to primitive capabilities based on context (object ID, post type, post author, custom ACL stored in postmeta, etc.).
  • map_meta_cap: a WordPress filter that translates meta capabilities to primitive capabilities. For granular per-object control you implement logic here to return either primitive caps to require, or the special cap do_not_allow to deny, or exist to grant.
  • Capability strings naming conventions: use singular and plural forms consistently (capability_type => document, plural: documents) and define the full capabilities array when registering a post type or custom entity.

Why use metacapabilities for granular control?

  • Built-in WP primitive capabilities are role-level. To allow per-object permissions (e.g., user A can edit post #10, user B cannot) you must map meta caps to contextual checks.
  • map_meta_cap centralizes per-object checks so everywhere WP checks current_user_can(edit_document, post_id) the same logic runs (admin UI, REST endpoints, theme templates, etc.).
  • Using meta capabilities integrates smoothly with WPs APIs (post type registration, REST, capability checks, Ajax and nonce flows).

Design decisions

  • Store per-object ACL in postmeta (e.g., a meta key that lists allowed user IDs) — easy and compatible. For very large ACLs, use a custom table to avoid postmeta bloat and performance problems.
  • Grant global roles the expected primitive caps (edit_others_documents, manage_documents) while mapping per-object caps to either exist for allow or do_not_allow for deny when appropriate.
  • Keep map_meta_cap logic extremely fast (avoid heavy queries or external calls). Use caching or a custom table lookup optimized for frequent checks.

Plugin skeleton and capability definitions

Below is a complete example plugin skeleton that:

  1. Registers a custom post type document with custom capabilities.
  2. Creates a map_meta_cap implementation that enforces per-document ACLs.
  3. Adds helper functions for managing per-document ACL entries.
  4. Includes activation/deactivation steps to add and remove role capabilities properly.
 true,
        read_document           => true,
        delete_document         => true,
        edit_documents          => true,
        edit_others_documents   => true,
        publish_documents       => true,
        read_private_documents  => true,
        delete_documents        => true,
        delete_others_documents => true,
        edit_private_documents  => true,
        edit_published_documents=> true,
        delete_private_documents=> true,
        delete_published_documents=> true,
    )

    // Add caps to administrator role
    admin = get_role(administrator)
    if (admin) {
        foreach (caps as cap => grant) {
            admin->add_cap(cap)
        }
    }

    // Add caps optionally to an editor-like role
    editor = get_role(editor)
    if (editor) {
        // Editors can edit and read documents but not necessarily edit others
        editor->add_cap(edit_documents)
        editor->add_cap(edit_published_documents)
        editor->add_cap(read_document)
    }
}

// Deactivation: remove added capabilities if desired
register_deactivation_hook(__FILE__, gdp_deactivate)
function gdp_deactivate() {
    caps = array(
        edit_document,read_document,delete_document,edit_documents,edit_others_documents,
        publish_documents,read_private_documents,delete_documents,delete_others_documents,
        edit_private_documents,edit_published_documents,delete_private_documents,delete_published_documents
    )

    admin = get_role(administrator)
    if (admin) {
        foreach (caps as cap) { admin->remove_cap(cap) }
    }

    editor = get_role(editor)
    if (editor) {
        foreach (caps as caps_to_remove = array(edit_documents,edit_published_documents,read_document) as c) {
            editor->remove_cap(c)
        }
    }
}

// Register post type with custom capabilities
add_action(init, gdp_register_document_type)
function gdp_register_document_type() {
    labels = array(
        name => Documents,
        singular_name => Document,
    )

    capabilities = array(
        edit_post          => edit_document,
        read_post          => read_document,
        delete_post        => delete_document,
        edit_posts         => edit_documents,
        edit_others_posts  => edit_others_documents,
        publish_posts      => publish_documents,
        read_private_posts => read_private_documents,
        delete_posts       => delete_documents,
        delete_private_posts => delete_private_documents,
        delete_published_posts => delete_published_documents,
        delete_others_posts    => delete_others_documents,
        edit_private_posts     => edit_private_documents,
        edit_published_posts   => edit_published_documents,
    )

    register_post_type(document, array(
        labels => labels,
        public => true,
        has_archive => true,
        capability_type => array(document,documents),
        map_meta_cap => true, // tell WP to use map_meta_cap for the basic post meta caps
        capabilities => capabilities,
        supports => array(title,editor,author),
    ))
}

/
  Core map_meta_cap implementation.
 
  This maps meta caps (edit_document, read_document, delete_document) to
  primitive capabilities or special allowances based on post ownership or per-object ACL.
 /
add_filter(map_meta_cap, gdp_map_meta_cap, 10, 4)
function gdp_map_meta_cap(caps, cap, user_id, args) {
    // Target only our meta caps
    object_meta_caps = array(edit_document, read_document, delete_document)

    if (! in_array(cap, object_meta_caps) ) {
        return caps
    }

    // args[0] should be the post ID for these meta caps
    post_id = isset(args[0]) ? (int) args[0] : 0
    post = get_post(post_id)

    if (! post  post->post_type !== document) {
        // Deny if post missing or wrong type
        return array(do_not_allow)
    }

    // Super admins (multisite) or users with manage_options should pass
    if (is_multisite()  is_super_admin(user_id)) {
        return array(exist) // allow
    }
    if (user_can(user_id, manage_options)) {
        return array(exist) // allow admins, site owners, etc.
    }

    switch (cap) {
        case read_document:
            // published posts may be readable by anyone who can read posts
            if (post->post_status === publish) {
                return array(exist) // allow
            }
            // private posts require read_private_documents
            if (post->post_status === private) {
                return array(read_private_documents)
            }
            // fallback: only author or explicit ACL
            if ((int)post->post_author === (int)user_id) {
                return array(exist)
            }
            if (gdp_post_allows_user(post_id, user_id, read)) {
                return array(exist)
            }
            return array(do_not_allow)

        case edit_document:
            // authors can edit their own drafts/published depending on capability
            if ((int)post->post_author === (int)user_id) {
                return array(edit_documents) // require the primitive capability the role may hold
            }
            // check a special capability for editing others
            if (user_can(user_id, edit_others_documents)) {
                return array(edit_others_documents)
            }
            // Check per-object ACL for explicit edit grant
            if (gdp_post_allows_user(post_id, user_id, edit)) {
                return array(exist) // explicitly granted
            }
            return array(do_not_allow)

        case delete_document:
            if ((int)post->post_author === (int)user_id) {
                return array(delete_documents)
            }
            if (user_can(user_id, delete_others_documents)) {
                return array(delete_others_documents)
            }
            if (gdp_post_allows_user(post_id, user_id, delete)) {
                return array(exist) // explicitly granted
            }
            return array(do_not_allow)
    }

    return caps
}

/
  Per-post ACL helpers.
  Store an associative array like:
  array(
    edit => array( 2, 34 ), // user IDs allowed to edit
    read => array( 2, 5 ),
    delete => array( 2 )
  )
 /
function gdp_get_acl(post_id) {
    acl = get_post_meta(post_id, _gdp_acl, true)
    if (! is_array(acl)) {
        acl = array()
    }
    return acl
}

function gdp_post_allows_user(post_id, user_id, action = edit) {
    acl = gdp_get_acl(post_id)
    if (empty(acl)  ! isset(acl[action]) ) {
        return false
    }
    allowed = acl[action]
    if (! is_array(allowed)) {
        return false
    }
    return in_array((int)user_id, array_map(intval, allowed), true)
}

function gdp_allow_user_for_post(post_id, user_id, action = edit) {
    acl = gdp_get_acl(post_id)
    if (! isset(acl[action])) { acl[action] = array() }
    uid = (int)user_id
    if (! in_array(uid, acl[action], true)) {
        acl[action][] = uid
        update_post_meta(post_id, _gdp_acl, acl)
    }
}

function gdp_remove_user_from_post(post_id, user_id, action = edit) {
    acl = gdp_get_acl(post_id)
    if (! isset(acl[action])) { return }
    uid = (int)user_id
    acl[action] = array_values(array_diff(acl[action], array(uid)))
    update_post_meta(post_id, _gdp_acl, acl)
}
?>

Explanation of the code

  1. Capability registration: We define a full capability map for the document post type and add the appropriate primitive caps to roles (administrator/editor) on activation. This ensures roles have the basic rights (edit_documents, edit_others_documents, etc.).
  2. map_meta_cap: This is the core. The function intercepts meta caps (edit_document, read_document, delete_document) and decides whether to allow, deny, or require other primitive caps. Important behaviors:
    • Administrators or manage_options users are allowed via returning array(exist).
    • If the requesting user is the post author, we generally map to edit_documents or delete_documents (primitive caps), letting WordPress check the roles primitive capabilities. That allows global role policies (if role doesnt have edit_documents, author cannot edit even if they authored the post).
    • We allow explicit per-object grants stored in postmeta by returning array(exist). Returning exist effectively grants access because every user implicitly has exist.
    • If no condition matches we return array(do_not_allow) to deny.
  3. Per-object ACL storage: Using a single postmeta key (_gdp_acl) storing an associative array reduces the number of postmeta rows and keeps ACLs grouped. For very large ACLs or many users, prefer a custom table for better querying and minimal performance impact.

Adding admin UI: metabox to manage per-object ACL

You will usually want an admin metabox where document authors or managers can grant/revoke permissions. The following code adds a simple metabox with a user ID text input (replace with a user picker in real UI), saves the ACL using nonces and capability checks, and demonstrates secure handling.

ID)) {
        echo 

You do not have permission to manage this list.

return } wp_nonce_field(gdp_save_acl, gdp_acl_nonce) acl = gdp_get_acl(post->ID) edit_list = isset(acl[edit]) ? acl[edit] : array() read_list = isset(acl[read]) ? acl[read] : array() echo

echo echo

echo } add_action(save_post_document, gdp_save_acl_metabox, 10, 3) function gdp_save_acl_metabox(post_id, post, update) { // nonce check if (! isset(_POST[gdp_acl_nonce]) ! wp_verify_nonce(_POST[gdp_acl_nonce], gdp_save_acl)) { return } // capability check if (! current_user_can(edit_document, post_id)) { return } edit_raw = isset(_POST[gdp_edit_list]) ? sanitize_text_field(_POST[gdp_edit_list]) : read_raw = isset(_POST[gdp_read_list]) ? sanitize_text_field(_POST[gdp_read_list]) : edit_ids = array_filter(array_map(intval, array_map(trim, explode(,, edit_raw)))) read_ids = array_filter(array_map(intval, array_map(trim, explode(,, read_raw)))) acl = gdp_get_acl(post_id) acl[edit] = array_values(edit_ids) acl[read] = array_values(read_ids) update_post_meta(post_id, _gdp_acl, acl) // Clear user caches for changed users (if you changed role/cap caches) foreach (array_merge(edit_ids, read_ids) as uid) { clean_user_cache((int)uid) } } ?>

Using current_user_can with meta caps (everywhere)

After implementing map_meta_cap, you can use the normal WP API throughout your code and templates:

  • Check capability: current_user_can(edit_document, post_id)
  • Show/hide UI based on that check
  • In REST endpoints or permission callbacks use current_user_can(edit_document, post_id) to gate actions

REST API example

Register a REST route that uses a permission_callback leveraging the meta cap. This ensures REST requests are protected by the same per-object ACL.

d )/publish, array(
        methods => POST,
        callback => gdp_publish_document_rest,
        permission_callback => function(request) {
            id = (int) request[id]
            return current_user_can(edit_document, id)
        },
    ))
})

function gdp_publish_document_rest(request) {
    id = (int) request[id]
    if (! current_user_can(edit_document, id)) {
        return new WP_Error(rest_forbidden, You cannot publish this document, array(status => 403))
    }

    updated = wp_update_post(array(
        ID => id,
        post_status => publish
    ), true)

    if (is_wp_error(updated)) {
        return updated
    }

    return rest_ensure_response(array(success => true, post_id => id))
}
?>

Security considerations

  • Always use nonce checks and capability checks when saving ACL data in admin POST handlers or Ajax endpoints.
  • Be careful with code that returns array(exist) indiscriminately — make sure only explicitly allowed cases return it.
  • When modifying role capabilities use the provided functions (get_role->add_cap/remove_cap). After changing caps, call clean_user_cache() for affected users to ensure immediate effect.
  • Escape and sanitize any user input in admin UI, and avoid exposing raw user IDs publicly.

Performance tips

  • map_meta_cap runs frequently. Keep its logic minimal. Avoid expensive queries, remote requests, or heavy computations inside it.
  • Use get_post_field(post_author, post_id) instead of get_post() if you only need the author to avoid building the entire WP_Post object in hot paths.
  • Cache ACL lookups using the object cache (wp_cache_get/wp_cache_set) if you expect many checks for the same post within a single request or across requests.
  • If a document must support thousands of user ACL entries, store ACLs in a custom table indexed by post_id and user_id and query it directly update your map_meta_cap to perform a single indexed lookup instead of fetching and parsing large postmeta blobs.

Testing and edge cases

  1. Test with multiple roles (administrator, editor, custom manager role) and a non-privileged subscriber account.
  2. Test drafts, private, and published posts for read behavior.
  3. Test REST endpoints and WP Ajax actions to ensure permission_callback and server-side checks are aligned with map_meta_cap results.
  4. Test role modifications (adding/removing caps) and call clean_user_cache() after changing caps to ensure a users cached capabilities refresh.
  5. Consider capabilities inheritance: some plugins call current_user_can(edit_post, post_id) for post type. If you register your post type with proper capability_type and map_meta_cap => true, WP core will map edit_post to edit_document automatically for that post type — but if you add custom meta caps beyond WPs default set, ensure map_meta_cap handles them.

Multisite behavior

On multisite installations, super admins bypass typical capability checks. If you need to treat super admins differently, include is_super_admin() checks in your map_meta_cap implementation. Be mindful of network-wide roles and that get_role() operates per site to add capabilities network-wide you may need to run activation code on each site or use network activation logic.

Advanced patterns

  • Role-based but scoped permissions: Combine role primitive caps and per-object overrides by checking both role capabilities and ACL entries in map_meta_cap. This allows global managers to have blanket rights and finer-grained exceptions per object.
  • Composition with taxonomies: You can implement map_meta_cap checks that consult taxonomy terms (e.g., department) to grant access if a user is in the same department. Be careful to cache taxonomy lookups.
  • Capability groups: Instead of storing three separate arrays edit/read/delete, store a single array per user with a bitmask or list of actions to reduce lookups and make updates atomic.

Migrating from postmeta ACL to a custom table

  1. Create a custom table with columns (post_id, user_id, action) and appropriate indexes.
  2. Change gdp_post_allows_user() to query that table with an indexed SELECT and return a boolean. Keep the interface the same so other parts of the code do not change.
  3. Batch migrate existing postmeta entries into the custom table with a migration tool activated once (e.g., on plugin upgrade hook).
  4. Benefits: queries become O(1) with proper indexes, fewer postmeta rows, easier to query by user for dashboards.

Common pitfalls and how to avoid them

  • Returning wrong primitive caps from map_meta_cap: returning a primitive cap that the user doesnt have will deny while you might expect grant — if you want to explicitly allow, return array(exist).
  • Doing heavy work inside map_meta_cap: this will noticeably slow down admin pages, front-end checks, and REST calls. Move heavy operations to async tasks or precompute grants into a cache.
  • Not cleaning caches after changing role caps or ACLs: use clean_user_cache(user_id) and wp_cache_delete() for custom caches.
  • Inconsistent checks across UI layers: always use current_user_can(…) with the same meta caps in client and server code so the same logic applies everywhere.

Useful references

Final notes and recommended development workflow

Start with a clear capability naming convention. Implement an initial version using postmeta-based ACLs so you can iterate quickly and prove the model. Profile map_meta_cap to ensure its fast enough — if it becomes a bottleneck, move ACL storage into an indexed custom table. Ensure all user input is sanitized and nonces are used everywhere ACLs are changed. Use unit tests and integration tests to verify that map_meta_cap returns expected results for various user/role combinations and post states. Finally, document the capabilities you introduce so site administrators understand which roles get which primitive caps on activation and how to adjust role permissions as needed.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *