Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 070 / 100

Building a membership site: patterns, pitfalls and fixes

Restrict content to paying members.

Expona Admin7 min read

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.

php
 true,
        'edit_posts'        => true,
        'delete_posts'      => true,
        'upload_files'      => true,
        'access_premium_content' => true,
    )
);
?>

Then check capabilities in your templates:

php

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:

php
 '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:

php

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.

php
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:

php
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:

php

Restrict by category: If premium content lives in the "Premium Tutorials" category, check category archive templates:

php

Restrict individual blocks: For granular control, register a custom block that checks membership before rendering:

php
 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:

php
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:

php
 -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:

php
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles