In this tutorial, we will be building a system to keep track of LearnDash lesson/topic views, and set a view limit.

The message seen when user has remaining views.

When the view limit is reached, content is blocked.

When the view limit is reached and content is locked.

Every month, the view limit increases by a certain number, which will allow students to view even more posts. In our example, our increment will be 5.

When the increment event occurs.

(At this point, I manually triggered the reset event with WP Crontrol, hence the same date. Didn’t want to wait a month!)

If a user has already viewed a lesson, it will not count against the limit, and still be accessible even if the limit is maxed out.

Already viewed posts do not count against the limit, and are still accessible no matter what.

The Idea Behind This

Saw an interesting question in the LearnDash Facebook group which read, in part:

How do we limit people to 5 Lessons (not Courses) per month?

In LearnDash, there is the Lesson Schedule feature.

In a linearly-progressed course, you could simply divide the number of days in a month (30) by the “monthly lesson limit” (5).

That would give you 6, so you could use that number to space each lesson release 6 days apart.

  1. Lesson 1: Released Immediately
  2. Lesson 2: Released after 6 days
  3. Lesson 3: Released after 12 days
  4. Lesson 4: Released after 18 days
  5. Lesson 5: Released after 24 days
  6. Lesson 6: Released after 30 days. ONE MONTH LATER.
  7. Lesson 7: Released after 36 days

And so on.

However, this didn’t quite meet the questioner’s needs, for a couple reasons:

1) What if the user wanted to consume Lessons 1 through 5 all on “Day 0”? They couldn’t do that with LearnDash Lesson Schedule. They would have to wait until at least “Day 24” as per the above example.

2) What if the the course had Free Form progression? For example, what if they wanted to consume Lesson 7, Lesson 21, Lesson 42, Lesson 1, and then Lesson 6, all on “Day 0”?

Once again, can’t do that with LearnDash Lesson Schedule unless all students (past, present, and future) happen to progress through the course in the exact same order, and you can accurately predict that order.

The chances of both of those conditions being met would border on impossible.

The Scope of This Tutorial

We will:

  • Set a starting “view limit” of 5
  • Track/limit only on LearnDash lessons/topics
  • Add a user meta field to keep track of viewed post IDs
  • Communicate view count, view limit, and “limit increase date” to user
  • If post has already been viewed, do not count that against the user
  • Hook into learndash_delete_user_data to wipe out tracking data
  • Exempt users with edit_others_courses capability from tracking
  • Add a “monthly” recurrence to WordPress scheduled events
  • Schedule a monthly function to add another 5 to the “view limit”

We will not:

  • We will not cover how to customize view limits for individual users/groups.
  • We will not cover how to exempt specific posts from tracking/limitation.
  • We will not cover how to variably increase view limit month over month.
  • We will not integrate with any external services like CRMs.

I hope it goes without saying that any of the above “we will not” items would greatly complicate the scope of the tutorial.

This project be entirely self-contained within LearnDash and WordPress.

Step #1: Think about architecture

Before we write a line of code, we should think about how we’re going to architect this.

Here’s a high-level outline of what we’ll be doing:

Set up a user meta field for “views”

This field will store the post ID of each viewed post.

At my first pass at this project, I originally just counted views without accounting for which posts were viewed.

This would’ve led to a terrible experience because revisiting already-viewed posts would count against the limit.

Set up a separate user meta field for “view limit”

While it would be possible to stuff the view limit into the same user meta field, I decided against it.

I thought keeping it as a separate field would make it more straightforward to increase view limits for individual users.

Filter content on tracked posts

We’ll inject a few different messages depending on how the view count and view limit compare with the_content.

If the post has already been viewed (content unlocked):

You have already viewed this lesson. It will not count against your total. You have viewed $view_count out of $view_limit. More views at $increment_time.

If the student has remaining views and has not just reached the view limit (content unlocked):

You are running out of steps. You have viewed $view_count out of $view_limit. More views at $increment_time.

If the student has just reached the view limit (content unlocked):

You have run out of steps. You have viewed $view_count out of $view_limit. More views at $increment_time.

If the student will exceed the view limit with another view (content locked):

You have reached the maximum of $view_limit. This content is locked until the increment occurs at $increment_time.

Track and limit accordingly

We will run a function on tracked post types (LearnDash Lessons and LearnDash Topics) that will check the view count and view limit of the user.

If the current view count matches the current view limit, the content will be locked and the function stops running.

If it doesn’t, we’ll add the post ID to the “views” user meta field. This, in essence, increases the view count of the user.

Schedule a monthly function to increase view limit of each relevant user

We’ll be leveraging WordPress core’s wp_schedule_event for this.

I’ve actually never done this before, so special thanks to WP Shout which helped me understand how this works.

The Snippet

This is a real monster at over 200 lines long.

I tried to organize and comment as best I could, although you may need to read it over a few times to get a better idea of what’s going on.

Reserved For Pro: This section will be soon for LMSCoder Pro members only. If you’re reading this, be sure to save this page.

For more info on our upcoming paid memberships, subscribe to our newsletter at the bottom of this page.

/* Return some defaults */
function lmscoder_get_view_limit_increment() {
	return 5;
}

function lmscoder_get_views_meta_key() {
	return 'lmscoder_views';
}

function lmscoder_get_view_limit_meta_key() {
	return 'lmscoder_view_limit';
}

function lmscoder_view_limit_incrementer_schedule_hook_name() {
	return 'lmscoder_view_limit_increment_hook';
}

function lmscoder_view_limit_incrementer_event_name() {
	return 'lmscoder_view_limit_increment_event';
}

/* Content filters */
function lmscoder_doesnt_count_html( $content ) {
	$views = lmscoder_get_current_views();
	$view_count = count( $views );
	$view_limit = strval( lmscoder_get_view_limit() );
	$increment_time = lmscoder_get_scheduled_increment_time();

	$html = "<p>You have already viewed this lesson. It will not count against your total. You have viewed $view_count out of $view_limit. More views at $increment_time.</p>" . $content;
	
	return $html;
}

function lmscoder_almost_locked_html( $content ) {
	$views = lmscoder_get_current_views();
	$view_count = count( $views );
	$view_limit = intval( lmscoder_get_view_limit() );
	$increment_time = lmscoder_get_scheduled_increment_time();

	// If we have just reached the limit, show a different message?
	if ( $view_count === $view_limit ) {
		$html = "<p>You have run out of steps. You have viewed $view_count out of $view_limit. More views at $increment_time.</p>" . $content;
	} else {
		$html = "<p>You are running out of steps. You have viewed $view_count out of $view_limit. More views at $increment_time.</p>" . $content;
	}
	
	return $html;
}

function lmscoder_locked_content_html() {
	$view_limit = strval( lmscoder_get_view_limit() );
	$increment_time = lmscoder_get_scheduled_increment_time();

	$html = "<p>You have reached the maximum of $view_limit. This content is locked until the increment occurs at $increment_time.</p>";
	
	return $html;
}

/* Helpers */
function lmscoder_get_view_limit( int $user_id = 0 ) {
	if ( ! $user_id ) {
		$user_id = get_current_user_id();
	}
	
	$view_limit = get_user_meta( $user_id, lmscoder_get_view_limit_meta_key(), true );
	
	return $view_limit;
}

function lmscoder_get_current_views() {
	$current_views = get_user_meta( get_current_user_id(), lmscoder_get_views_meta_key(), true );
	
	if ( ! $current_views ) {
		$current_views = [];
	}
	
	return $current_views;
}

function lmscoder_get_scheduled_increment_time() {
	return date_i18n( 'F j, Y g:i a', wp_next_scheduled( lmscoder_view_limit_incrementer_event_name() ) );
}

function lmscoder_is_exempt( $user_id ) {
	$user = get_userdata( $user_id );
	
	if ( $user->has_cap( 'edit_others_courses' ) ) {
		return true;
	} else {
		return false;
	}
}

/* The tracker and limiter */
function lmscoder_view_tracker_and_limiter() {
	$user_id = get_current_user_id();

	// Just stop if user is not logged in
	if ( ! is_user_logged_in() ) {
		return;
	}

	// Just stop if user can edit others courses
	if ( lmscoder_is_exempt( $user_id ) ) {
		return;
	}
	
	// Just stop if not in a tracked post type
	$tracked_post_types = [
		'sfwd-lessons',
		'sfwd-topic',
	];
	
	if ( ! is_singular( $tracked_post_types ) ) {
		return;
	}
	
	$post_id = get_queried_object_id();
	$views = lmscoder_get_current_views();
	$view_count = count( $views );
	$view_limit = intval( lmscoder_get_view_limit() );

	// Just stop if post has already been viewed
	if ( in_array( $post_id, $views) ) {
		add_filter( 'the_content', function( $content ) {
			return lmscoder_doesnt_count_html( $content );
		} );

		return;
	}
	
	// If view count equals the view limit, or exceeds the view limit
	if ( $view_count === $view_limit || $view_count > $view_limit ) {
		add_filter( 'the_content', function() {
			return lmscoder_locked_content_html();
		} );

		return;
	}

	$views[] = $post_id;
	
	update_user_meta( $user_id, lmscoder_get_views_meta_key(), $views );
	
	add_filter( 'the_content', function( $content ) {
		return lmscoder_almost_locked_html( $content );
	} );
}
add_action( 'wp', 'lmscoder_view_tracker_and_limiter' );

/* Scheduled functions */
function lmscoder_view_limit_incrementer() {
	$users_with_limit = get_users( [
    	'meta_key' => lmscoder_get_view_limit_meta_key(),
	] );
	
	foreach ( $users_with_limit as $user ) {
		$user_id = $user->ID;
		
		$old_limit = lmscoder_get_view_limit( $user_id );
		$new_limit = $old_limit + lmscoder_get_view_limit_increment();
		
		update_user_meta( $user->ID, lmscoder_get_view_limit_meta_key(), $new_limit );
	}
}

function lmscoder_add_views_reset_recurrence( $schedules ) {
    $schedule_hook_name = lmscoder_view_limit_incrementer_schedule_hook_name();

    $schedules[ $schedule_hook_name ] = [
        'interval' => MONTH_IN_SECONDS,
        'display' => __( 'Once Monthly' ),
    ];
 
    return $schedules;
}
add_filter( 'cron_schedules', 'lmscoder_add_views_reset_recurrence' );

if ( ! wp_next_scheduled ( lmscoder_view_limit_incrementer_event_name() ) ) {
	wp_schedule_event( time(), lmscoder_view_limit_incrementer_schedule_hook_name(), lmscoder_view_limit_incrementer_event_name() );
}

add_action( lmscoder_view_limit_incrementer_event_name(), 'lmscoder_view_limit_incrementer' );

/* Delete user data on hook */
function lmscoder_delete_user_data( $user_id ) {
	delete_user_meta(
		$user_id,
		lmscoder_get_view_limit_meta_key()
	);

	delete_user_meta(
		$user_id,
		lmscoder_get_views_meta_key()
	);
}
add_action( 'learndash_delete_user_data', 'lmscoder_delete_user_data' );

/* Set the view limit on login or register */
function lmscoder_view_limit_genesis( $user_id ) {

	if ( lmscoder_is_exempt( $user_id ) ) {
		return;
	}
	
	$view_limit = lmscoder_get_view_limit( $user_id );
	
	if ( $view_limit ) {
		return;
	}
	
	return add_user_meta( $user_id, lmscoder_get_view_limit_meta_key(), lmscoder_get_view_limit_increment() );
}

function lmscoder_view_tracker_login( $user_login, $user ) {
	$user_id = $user->ID;
	
	return lmscoder_view_limit_genesis( $user_id );
}

As I was writing this I realized this would probably be best written in an object-oriented manner.

Also, it would probably be best loaded from a plugin and modified to take advantage of activation / deactivation hooks for the scheduled event.

Next Steps

I figured if I didn’t have a stopping point, I’d basically be writing a full-fledged membership plugin from scratch. So this is all you’re gonna get from me for now.

There are so many ways to build on this concept. Here are a few ideas:

  • You could charge impatient people for more views (i.e. increased view limits), a la carte.
  • You could increase the rate view limits are incremented to reward longevity. Month 1: 5 new views, Month 2: 6 new views, etc.
  • You could exempt specific posts from counting toward the limit, that otherwise would. “Freebies” of a sort.
  • You could exempt specific posts from counting toward the limit, depending on how many months they’ve been a member.
  • You could style the (almost) locked messages so it’s not a plain paragraph.

I like the idea so much, I’m considering setting up this very site with such a business model.

One of the classic membership site owner anxieties is that someone will sign up, download all the content, and cancel right afterward.

Why bother starting a membership at all if a substantial number of “members” abuse your efforts as a content creator?

One of the classic membership site member anxieties is that a future member will sign up, download all the content, and cancel right afterward.

Why bother with an ongoing membership of say 12 months @ $20/mo, if a member of one month can get all the same content for $20 instead of $240?

It’s more complicated than this. It takes time to implement content consumed in a membership. There could be a community, not just raw content involved.

I just can’t help but feel incentives are not quite ideal, and a credits system can help better align value and price for members.