return true;
}
// Guest handling
if (!$user->exists()) {
return in_array('unauthenticated', $policy, true);
}
// Role intersection check
$user_roles = (array) $user->roles;
return !empty(array_intersect($policy, $user_roles));
}
#### 2. Query-Level Filtering
To prevent restricted posts from appearing in archives, category pages, and search results, we modify the main query. This uses the `post__in` parameter to whitelist accessible posts.
**Rationale:** Filtering at the query level ensures that restricted content is never loaded into the loop, reducing memory usage and preventing accidental exposure via template tags.
```php
/**
* Apply role-based filtering to main queries.
*
* @param WP_Query $query The query object.
*/
function apply_query_level_gating(WP_Query $query): void {
// Safety checks: Admin, secondary queries, singular views
if (is_admin() || !$query->is_main_query() || is_singular()) {
return;
}
$current_user = wp_get_current_user();
// Admins bypass filtering
if (in_array('administrator', $current_user->roles, true)) {
return;
}
// Collect accessible post IDs
$accessible_ids = [];
// Retrieve all published posts for evaluation
// Note: For high-traffic sites, implement caching or limit scope
$posts = get_posts([
'post_type' => 'post',
'post_status' => 'publish',
'fields' => 'ids',
'posts_per_page' => -1,
]);
foreach ($posts as $post_id) {
if (evaluate_content_visibility($post_id, $current_user)) {
$accessible_ids[] = $post_id;
}
}
// Apply filter to query
if (empty($accessible_ids)) {
$query->set('post__in', [0]); // Force empty result
} else {
$query->set('post__in', $accessible_ids);
}
}
add_action('pre_get_posts', 'apply_query_level_gating');
3. Direct Access Enforcement
Query filtering does not prevent direct URL access. Users can still navigate to /post-slug if they know the URL. We enforce access control during the template redirection phase.
Rationale: The template_redirect hook executes after the query is run but before the template is loaded. This is the optimal point to intercept requests and redirect unauthorized users.
/**
* Enforce access control on singular views.
*/
function enforce_singular_access(): void {
if (!is_singular()) {
return;
}
$post = get_post();
if (!evaluate_content_visibility($post->ID)) {
// Redirect to login with redirect_to parameter
wp_safe_redirect(wp_login_url(get_permalink($post->ID)));
exit;
}
}
add_action('template_redirect', 'enforce_singular_access');
4. Inline Content Gating via Shortcodes
For granular control within post content, we implement shortcodes. This allows editors to hide specific sections based on user roles.
/**
* Render content based on role requirements.
* Usage: [role_gate roles="editor,subscriber"]Restricted text[/role_gate]
*/
function render_role_gated_block(array $atts, ?string $content = null): string {
$atts = shortcode_atts(['roles' => ''], $atts, 'role_gate');
if (empty($content)) {
return '';
}
$required_roles = array_map('trim', explode(',', $atts['roles']));
$user = wp_get_current_user();
if (!$user->exists()) {
return '';
}
if (!empty(array_intersect($required_roles, (array) $user->roles))) {
return do_shortcode($content);
}
return '';
}
add_shortcode('role_gate', 'render_role_gated_block');
5. Editor Interface
Editors require a UI to define visibility policies. We add a meta box to the post editor screen.
/**
* Register meta box for visibility configuration.
*/
function register_visibility_meta_box(): void {
add_meta_box(
'content_visibility_panel',
'Access Control',
'render_visibility_panel',
['post', 'page'],
'side',
'high'
);
}
add_action('add_meta_boxes', 'register_visibility_meta_box');
/**
* Render meta box content.
*/
function render_visibility_panel(WP_Post $post): void {
wp_nonce_field('visibility_nonce_action', 'visibility_nonce_field');
$policy = get_post_meta($post->ID, '_access_gate', true);
$policy = is_array($policy) ? $policy : [];
$roles = wp_roles()->roles;
?>
<div class="visibility-controls">
<label>
<input type="checkbox" name="access_policy[]" value="anyone"
<?php checked(in_array('anyone', $policy)); ?>>
Public Access
</label>
<label>
<input type="checkbox" name="access_policy[]" value="unauthenticated"
<?php checked(in_array('unauthenticated', $policy)); ?>>
Guests Only
</label>
<hr>
<strong>Allowed Roles:</strong>
<?php foreach ($roles as $role_key => $role_data): ?>
<label>
<input type="checkbox" name="access_policy[]" value="<?php echo esc_attr($role_key); ?>"
<?php checked(in_array($role_key, $policy)); ?>>
<?php echo esc_html($role_data['name']); ?>
</label>
<?php endforeach; ?>
</div>
<?php
}
/**
* Persist visibility policy on post save.
*/
function persist_visibility_meta(int $post_id): void {
if (!isset($_POST['visibility_nonce_field']) ||
!wp_verify_nonce($_POST['visibility_nonce_field'], 'visibility_nonce_action')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
if (isset($_POST['access_policy']) && is_array($_POST['access_policy'])) {
$sanitized = array_map('sanitize_text_field', $_POST['access_policy']);
update_post_meta($post_id, '_access_gate', $sanitized);
} else {
delete_post_meta($post_id, '_access_gate');
}
}
add_action('save_post', 'persist_visibility_meta');
Pitfall Guide
Production implementations of content gating must address specific edge cases. The following pitfalls are derived from real-world deployment scenarios.
-
Query Explosion on Large Sites
- Explanation: Iterating through all posts in
pre_get_posts causes severe performance degradation on sites with thousands of posts.
- Fix: Implement a transient cache for the accessible IDs list, or limit the query to specific post types/categories. For enterprise scale, consider a custom database table or Elasticsearch integration.
-
Cache Poisoning
- Explanation: Page caching plugins may serve restricted content to unauthenticated users if the cache key does not vary by user role.
- Fix: Configure your caching solution to exclude pages with
_access_gate meta. Alternatively, use a cache variation based on user cookies or implement a "no-cache" header for restricted content.
-
Role vs. Capability Mismatch
- Explanation: Roles are labels; capabilities are permissions. Relying solely on role slugs can break if roles are renamed or custom capabilities are used.
- Fix: Map roles to capabilities internally, or allow policy definitions based on capabilities. Use
user_can() for checks where possible.
-
Shortcode Leakage in Feeds
- Explanation: Shortcodes may render in RSS feeds or REST API responses, exposing restricted content to scrapers.
- Fix: Disable shortcodes in feeds using
remove_filter('the_excerpt_rss', 'do_shortcode') and filter REST API responses to strip gated content.
-
Admin Lockout
- Explanation: Logic errors in the evaluation function can inadvertently block administrators from accessing content.
- Fix: Always include an explicit super-admin bypass at the start of the evaluation function. Test thoroughly with a secondary admin account.
-
Direct URL Bypass via Embeds
- Explanation: WordPress embeds may bypass
template_redirect checks, exposing content via oEmbed endpoints.
- Fix: Hook into
oembed_request and embed_template to enforce access checks. Return a 404 or redirect for restricted posts in embed contexts.
-
Meta Serialization Issues
- Explanation: Storing complex data without validation can lead to corrupted meta values or security vulnerabilities.
- Fix: Strictly sanitize input using
sanitize_text_field and validate data types. Use update_post_meta with explicit type casting.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small Site (<1k posts) | Core-First Gating | Low overhead, simple implementation | Minimal dev time |
| Large Site (>10k posts) | Core-First + Caching | Prevents query explosion | Requires cache config |
| Complex Drip/Payments | Membership Plugin | Native support for subscriptions | Plugin license cost |
| Multi-Site Network | Core-First + MU Plugin | Consistent policy across sites | Centralized maintenance |
Configuration Template
Use this template to standardize role mappings and policy defaults across your project.
/**
* Configuration constants for content gating.
*/
define('GATING_META_KEY', '_access_gate');
define('GATING_PUBLIC_FLAG', 'anyone');
define('GATING_GUEST_FLAG', 'unauthenticated');
/**
* Default policy for new posts.
* Returns array of allowed roles.
*/
function get_default_visibility_policy(): array {
return [GATING_PUBLIC_FLAG];
}
/**
* Map custom roles to capabilities if needed.
*/
function map_role_to_capabilities(string $role): array {
$role_obj = get_role($role);
return $role_obj ? array_keys($role_obj->capabilities) : [];
}
Quick Start Guide
- Paste the Evaluator: Add
evaluate_content_visibility to your theme's functions.php or a custom plugin.
- Add Hooks: Include the
pre_get_posts and template_redirect handlers.
- Deploy UI: Add the meta box registration and render functions.
- Test Access: Create a test post, set visibility to "Editor Only", and verify access as different user roles.
- Configure Cache: Exclude posts with
_access_gate meta from your caching layer.
This architecture provides a robust, performant solution for role-based content visibility. By leveraging WordPress core capabilities and adhering to production best practices, you can implement granular access control without the overhead of membership plugins.