I had a client running a busy membership site. We had built custom roles and capabilities the standard WordPress way, and it had held up for years. Then, after a WordPress 6.9 Release Candidate 1 rollout, discussed in the dev chat agenda, some users suddenly could not reach parts of the admin dashboard they needed. Real headache.
WordPress is not a static target. The core team keeps shipping new features and reworking old APIs. Sometimes these “incremental improvements,” as the dev chat put it, introduce subtle shifts. This time it was the new Abilities API in WordPress 6.9 that caused the trouble. Our custom roles filtered user_has_cap, and they started behaving erratically. A user with the custom “bbioon_manager” role, for example, got locked out of the editing screen for their custom post type. Not good at all.
My first thought, and probably yours if you have been at this a while, was to drop a remove_cap and add_cap pair into an init hook and force the permissions back. It is the quick and dirty fix that feels right in the moment, but it is a hack. It only masks the real problem, and it would have been miserable to maintain across future core updates. You would be chasing your tail with every patch release.
Understanding the new Abilities API in WordPress 6.9
The real fix is to understand what actually changed. WordPress 6.9 updated the Abilities API. user_has_cap still works, but the way capabilities are evaluated under the hood shifted, so custom logic that is not aligned with the new flow can hit unexpected conflicts. The updated API is faster and more flexible, but it needs a different approach for granular control.
Instead of fighting core with brute-force add_cap calls, use the extension points the API gives you. That usually means hooking into the filters that expose capability checks at a deeper level, or registering custom capability types explicitly. Here is a small example that keeps a custom bbioon_manager role holding a specific capability while working with the new Abilities API instead of against it:
<?php
/**
* Register custom capabilities for the bbioon_manager role.
*
* This function adds specific capabilities to the 'bbioon_manager' role,
* ensuring they are correctly registered and recognized by the Abilities API.
*
* @since 1.0.0
*/
function bbioon_register_custom_capabilities() {
$role = get_role( 'bbioon_manager' );
if ( $role ) {
// Ensure the role can edit its own custom post type.
$role->add_cap( 'edit_bbioon_custom_posts' );
$role->add_cap( 'read_bbioon_custom_posts' );
$role->add_cap( 'delete_bbioon_custom_posts' );
// Add more specific capabilities as needed.
}
}
add_action( 'admin_init', 'bbioon_register_custom_capabilities' );
/**
* Filter user capabilities to ensure specific custom caps are granted.
*
* This filter acts as a fallback or a way to enforce capabilities
* that might be affected by subtle changes in core, specifically
* with the new Abilities API.
*
* @param array $allcaps The user's capabilities.
* @param array $caps Required capabilities.
* @param array $args Arguments that accompany the capability check.
* @param WP_User $user The user object.
* @return array Modified capabilities.
*/
function bbioon_filter_user_capabilities( $allcaps, $caps, $args, $user ) {
if ( in_array( 'bbioon_manager', (array) $user->roles ) ) {
// Always grant this specific cap to bbioon_manager.
$allcaps['edit_bbioon_custom_posts'] = true;
// Add other necessary capabilities for this role here.
}
return $allcaps;
}
add_filter( 'user_has_cap', 'bbioon_filter_user_capabilities', 10, 4 );
/**
* Register a custom post type with appropriate capability_type.
*
* This is crucial for the Abilities API to correctly map
* capabilities to our custom post type.
*
* @since 1.0.0
*/
function bbioon_register_custom_post_type() {
$labels = array(
// ... (standard labels) ...
);
$args = array(
'labels' => $labels,
'public' => true,
'capability_type' => array( 'bbioon_custom_post', 'bbioon_custom_posts' ), // Plural form matters!
'map_meta_cap' => true, // Important for custom capabilities.
'hierarchical' => false,
'menu_icon' => 'dashicons-admin-post',
'supports' => array( 'title', 'editor', 'author', 'thumbnail' ),
'has_archive' => true,
'rewrite' => array( 'slug' => 'bbioon-items' ),
'query_var' => true,
);
register_post_type( 'bbioon_custom_post', $args );
}
add_action( 'init', 'bbioon_register_custom_post_type' );
/**
* Map custom meta capabilities to primitive capabilities.
*
* This tells WordPress how our custom capabilities (e.g., 'edit_bbioon_custom_post')
* relate to the standard capabilities it understands.
*
* @param array $caps Primitive capabilities required.
* @param string $cap Capability being checked.
* @param int $user_id The user ID.
* @param array $args Arguments for the capability check.
* @return array Filtered capabilities.
*/
function bbioon_map_meta_caps( $caps, $cap, $user_id, $args ) {
if ( 'edit_bbioon_custom_post' == $cap || 'delete_bbioon_custom_post' == $cap || 'read_bbioon_custom_post' == $cap ) {
$post = get_post( $args[0] );
$post_type = get_post_type_object( $post->post_type );
$caps = array();
if ( 'edit_bbioon_custom_post' == $cap ) {
if ( $user_id == $post->post_author ) {
$caps[] = $post_type->cap->edit_posts;
} else {
$caps[] = $post_type->cap->edit_others_posts;
}
} elseif ( 'delete_bbioon_custom_post' == $cap ) {
if ( $user_id == $post->post_author ) {
$caps[] = $post_type->cap->delete_posts;
} else {
$caps[] = $post_type->cap->delete_others_posts;
}
} elseif ( 'read_bbioon_custom_post' == $cap ) {
$caps[] = $post_type->cap->read;
}
}
return $caps;
}
add_filter( 'map_meta_cap', 'bbioon_map_meta_caps', 10, 4 );
Notice the capability_type in register_post_type and the map_meta_cap filter. That is where you tell WordPress how your custom capabilities should behave and which core capabilities they map to. The Abilities API expects this kind of explicit definition now, rather than a blanket user_has_cap filter trying to catch everything. With that in place, your custom roles keep working even as core changes.
The long and short of it: test and adapt
The lesson is twofold. First, read the dev notes for major WordPress releases; they are there for a reason. Second, do not assume old custom code will keep working through big core updates without checking. APIs change, and sometimes that means adapting your approach. Running beta releases and testing on staging is worth the effort: it warns you about conflicts like the one that caught my client off guard before they reach production. Better to catch these early than react after they break.
This stuff gets complicated fast. If you are tired of debugging someone else’s mess and just want your site to work, drop my team a line. We have probably seen it before.