Building a membership site: patterns, pitfalls and fixes
Building a membership site in WordPress requires careful planning around roles, payments, content restriction, and customer communication. Most teams discover their first membership implementation has gaps—a customer loses access mid-month, renewal emails land in spam, or a block of premium content is accidentally visible to free users. This guide walks you through the patterns that work, the pitfalls we've hit in production, and the fixes that keep your membership site running smoothly.
Roles or memberships—Plugins or custom capabilities
Your first decision is architectural: do you build a custom capability system or lean on an existing membership plugin? Each approach has trade-offs worth understanding.
WordPress ships with Editor, Author, Contributor, Subscriber, and Administrator roles out of the box. For a simple membership site, you might add a member or premium_member role and grant it custom capabilities. This keeps your codebase minimal and gives you full control over logic.
true,
'edit_posts' => true,
'delete_posts' => true,
'upload_files' => true,
'access_premium_content' => true,
)
);
?>Then check capabilities in your templates:
This approach works for single-tier memberships. Problems emerge once you need tiered access (gold, silver, bronze), expiration dates, or pause functionality. A user's role doesn't naturally encode "expires on 2025-03-15" or "paused until next billing cycle."
Membership plugins like MemberPress, Paid Memberships Pro, or Restrict Content Pro solve this by storing membership records in a custom table with start dates, end dates, and status. They hook into WordPress user flows and handle role assignment automatically.
Our pattern: use custom capabilities for simple cases (binary member/non-member), but integrate a membership plugin if you need dates, tiers, or pause states. If you're building custom, store membership metadata on the user:
'active', // active, paused, expired, cancelled
'start_date' => '2025-01-01',
'end_date' => '2026-01-01',
'tier' => 'gold',
);
update_user_meta( $user_id, 'membership_data', $membership_data );
?>Then write a helper function to check both current membership status and expiration:
Payments—Stripe subscriptions via WooCommerce or a membership plugin
Payment handling is where many implementations break. You need to process initial charges, handle recurring billing, manage failed cards, and respond to cancellations. Don't build this yourself unless you have significant payment infrastructure experience.
Two solid approaches:
WooCommerce Subscriptions + Stripe: If your site already uses WooCommerce, WooCommerce Subscriptions extends it with recurring billing. Customers purchase a "subscription product," Stripe charges them monthly, and WooCommerce updates order status automatically. You then hook order status changes to update your custom membership metadata.
get_user_id();
$renewal_date = $subscription->get_date( 'next_payment' );
update_user_meta( $user_id, 'membership_data', array(
'status' => 'active',
'end_date' => $renewal_date,
) );
}
add_action( 'woocommerce_subscription_status_cancelled', 'sync_membership_to_cancelled' );
function sync_membership_to_cancelled( $subscription ) {
$user_id = $subscription->get_user_id();
update_user_meta( $user_id, 'membership_data', array(
'status' => 'cancelled',
) );
}
?>Dedicated membership plugin (Stripe native): Plugins like Restrict Content Pro and MemberPress handle Stripe subscriptions directly. They store subscription IDs in their own tables and update membership status when webhooks arrive from Stripe. This is simpler if you're not using WooCommerce for other products.
Common mistake: failing to verify Stripe webhooks. Always validate the webhook signature before processing payments:
type === 'customer.subscription.updated' ) {
// Update membership
}
?>Content rules—Restrict by post type, category or block
Once memberships exist and payments flow, you need to gate the content. Common strategies:
Restrict entire post type: Make a custom post type called "Premium Course" visible only to members. Hook template_redirect to redirect non-members to a login or signup page:
Restrict by category: If premium content lives in the "Premium Tutorials" category, check category archive templates:
Restrict individual blocks: For granular control, register a custom block that checks membership before rendering:
function( $attributes, $content ) {
if ( ! user_has_active_membership( get_current_user_id() ) ) {
return '<p>This content is for members only. <a href="' . esc_url( wp_login_url() ) . '">Log in</a></p>';
}
return $content;
},
) );
?>A pitfall: checking membership only on page load. If a user's subscription expires while they're reading, they won't notice until refresh. For sensitive memberships, consider a small JavaScript call on page load that re-verifies access and redirects if needed.
Emails—Welcome, renewal and failed payment messages
Email is often the weakest part of membership automation. Messages vanish into spam, don't send at all, or have broken links. Treat emails as first-class logic, not afterthoughts.
Welcome email: Fire this immediately after payment confirmation. Include login credentials, welcome instructions, and your support email:
get_user_id();
$user = get_user_by( 'id', $user_id );
$to = $user->user_email;
$subject = 'Welcome to Your Membership!';
$message = sprintf(
"Hi %s,\n\nYour membership is now active. Log in here: %s\n\nIf you have questions, reply to this email.\n\nBest,\nThe Team",
$user->user_nicename,
wp_login_url()
);
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
wp_mail( $to, $subject, $message, $headers );
}
?>Renewal upcoming: Send a courtesy email 7 days before renewal. This reduces surprise and helps users pause if needed:
-1,
'orderby' => 'id',
'order' => 'ASC',
) );
foreach ( $subscriptions as $subscription ) {
$renewal_date = strtotime( $subscription->get_date( 'next_payment' ) );
$days_until = floor( ( $renewal_date - time() ) / 86400 );
if ( $days_until === 7 ) {
$user = $subscription->get_user();
wp_mail(
$user->user_email,
'Your Membership Renews in 7 Days',
"Your subscription will renew on " . date( 'F j, Y', $renewal_date ) . "."
);
}
}
} );
// Add to wp_schedule_event if not already scheduled
if ( ! wp_next_scheduled( 'wp_scheduled_event_membership_renewal_reminder' ) ) {
wp_schedule_event( time(), 'daily', 'wp_scheduled_event_membership_renewal_reminder' );
}
?>Failed payment: When Stripe reports a failed charge via webhook, notify the user immediately and give them a way to update their card:
type === 'charge.failed' ) {
$charge = $event->data->object;
$customer_id = $charge->customer;
// Find the subscription and user
$subscription = get_subscription_by_stripe_customer( $customer_id );
$user = $subscription->get_user();
$message = sprintf(
"Hi %s,\n\nYour payment failed. Please update your card: %s\n\nWe'll retry in 3 days.",
$user->user_nicename,
wp_login_url() . '?action=update_billing'
);
wp_mail( $user->user_email, 'Payment Failed—Update Your Card', $message );
}
?>Pro tip: use a transactional email service (SendGrid, Mailgun, AWS SES) instead of wp_mail for better deliverability and logging. WordPress's built-in mail often lands in spam on shared hosts.
Common mistakes
- No expiration check on every page load: A user's subscription expires, but they stay logged in and can still see content. Check
user_has_active_membership()before serving premium posts or blocks. - Unvalidated webhook payloads: Process Stripe webhooks without signature verification, and a malicious actor can grant themselves free membership.
- Forgetting to handle subscription pause/resume: A user pauses for 3 months but their role never updates. If you support pause, mirror pause state in your membership metadata.
- Emails sent via wp_mail on shared hosting: They get rate-limited or land in spam. Use a service or ensure proper SPF/DKIM setup.
- No audit trail: When a user complains they lost access, you can't tell why. Log all membership changes to a custom table or wp_logs.
Key takeaways
- Store membership state in user meta (status, start date, end date, tier) so you can query and expire memberships reliably.
- Lean on WooCommerce Subscriptions or a dedicated plugin rather than building Stripe integration yourself—payment handling is complex and risky.
- Check membership status on every page load for premium content; don't rely on roles alone.
- Send transactional emails (welcome, renewal reminders, failed payments) immediately via a reliable service, and always validate webhook signatures.
- Log all membership state changes so you can troubleshoot and audit when users report access issues.






Comments
Loading comments…