LearnDash Depth

Integrating Deeply With LearnDash's Content Structure

⏱ 16 min

Course 10’s sync lesson handles the common case: content edited fires save_post, content deleted fires before_delete_post. LearnDash has a structural case that neither of those catches: a lesson can be detached from a course, or a course rebuilt with steps reordered or removed, without the lesson post itself ever being saved or deleted. This lesson closes that gap with learndash_get_course_steps(), the one source of truth for what’s actually in a course right now.

What you'll learn in this lesson
Why generic save_post/before_delete_post sync misses LearnDash-specific changes
Detaching a step from a course doesn't touch the step's own post.
Re-embedding on save with real course context
Resolving course_id at save time with learndash_get_course_id().
Reconciling the index against the authoritative step list
learndash_get_course_steps() as the single source of truth to compare against.
A WP-CLI command for a full-site reconciliation pass
Catching every orphaned embedding across every course, not just the one you're looking at.
Prerequisites

LearnDash installed and active. Lesson 2’s course_id-tagged embeddings table and Course 10’s Keeping the Knowledge Base in Sync.

Step 1: Why the generic sync lesson isn’t enough here

Course 10’s Lesson 8 re-indexes on save_post and removes on before_delete_post. Neither fires when an instructor uses LearnDash’s Course Builder to drag a lesson out of a course, or removes a step from a course’s structure while leaving the lesson post itself untouched. The tutor’s index would keep that lesson’s chunks tagged with a course_id that no longer actually includes it, and the tutor would go on answering from content that’s no longer part of the course a student sees.

Step 2: Re-embed on save, with fresh course context

// File: lms-tutor/class-tutor-sync.php
add_action( 'save_post', function ( $post_id, $post ) {
	if ( ! in_array( $post->post_type, array( 'sfwd-lessons', 'sfwd-topic' ), true ) ) {
		return;
	}

	if ( wp_is_post_revision( $post_id ) || $post->post_status !== 'publish' ) {
		return;
	}

	lms_tutor_index_step( $post_id ); // From Lesson 2, resolves course_id via learndash_get_course_id().
}, 10, 2 );

This handles content edits correctly, the same discipline Course 10 established, but it only runs when this specific post is saved, which is exactly the case that misses structural changes made from the course side instead of the lesson side.

Step 3: Reconcile against the authoritative step list

learndash_get_course_steps( $course_id, array( 'sfwd-lessons', 'sfwd-topic' ) ) returns the current, real list of step IDs LearnDash considers part of a course right now. Any embedded row tagged with that course_id whose post_id isn’t in that list anymore is stale:

// File: lms-tutor/class-tutor-sync.php
function lms_tutor_reconcile_course( int $course_id ) {
	global $wpdb;

	$current_steps = learndash_get_course_steps( $course_id, array( 'sfwd-lessons', 'sfwd-topic' ) );
	$table         = $wpdb->prefix . 'ai_chunk_embeddings';

	$indexed_post_ids = $wpdb->get_col(
		$wpdb->prepare( "SELECT DISTINCT post_id FROM {$table} WHERE course_id = %d", $course_id )
	);

	$orphaned = array_diff( $indexed_post_ids, $current_steps );

	foreach ( $orphaned as $post_id ) {
		$wpdb->delete( $table, array( 'post_id' => (int) $post_id, 'course_id' => $course_id ) );
	}

	// Anything currently a real step but missing from the index gets (re)embedded.
	foreach ( $current_steps as $post_id ) {
		if ( ! in_array( (int) $post_id, array_map( 'intval', $indexed_post_ids ), true ) ) {
			lms_tutor_index_step( (int) $post_id );
		}
	}

	return array( 'removed' => count( $orphaned ), 'course_id' => $course_id );
}

This is the piece save_post alone can’t provide: a direct comparison between what the index currently believes and what LearnDash’s own course-steps function says is real, right now.

Step 4: A WP-CLI command for a full-site pass

// File: lms-tutor/class-tutor-sync.php
WP_CLI::add_command( 'lms-tutor reconcile', function () {
	$courses = get_posts( array( 'post_type' => 'sfwd-courses', 'posts_per_page' => -1, 'fields' => 'ids' ) );

	foreach ( $courses as $course_id ) {
		$result = lms_tutor_reconcile_course( $course_id );
		WP_CLI::log( "Course {$course_id}: removed {$result['removed']} stale rows." );
	}

	WP_CLI::success( 'Reconciliation complete across ' . count( $courses ) . ' courses.' );
} );

Run this on a schedule (a weekly wp cron event schedule call, or a server cron invoking wp lms-tutor reconcile) so structural drift from course editing gets caught even on courses no one happened to trigger a save_post on recently.

Test it: detach a step and confirm the reconciliation catches it

wp-env run cli wp eval '
print_r( learndash_get_course_steps( 3 ) );
'

Note a lesson ID currently in that list, remove it from the course using LearnDash’s Course Builder in wp-admin (not by editing or deleting the lesson post itself), then run:

wp-env run cli wp lms-tutor reconcile

Confirm the log reports at least one removed row for that course, and query the embeddings table directly to confirm that lesson’s course_id-tagged rows are gone.

Assuming save_post-based sync alone is sufficient for LearnDash content

A sync strategy that only listens for save_post and before_delete_post, exactly Course 10’s generic pattern, looks complete and will pass every test that involves editing or deleting a lesson directly. It silently misses every change made through LearnDash’s own course structure tools instead. If your tutor has ever answered from a lesson that’s clearly no longer part of the course a student is looking at, this is almost always why, and a scheduled reconciliation pass against learndash_get_course_steps() is the fix.

Recap

save_post re-indexing handles content edits, but LearnDash’s own Course Builder can detach, reorder, or remove steps without touching the step’s own post, which generic sync entirely misses. learndash_get_course_steps() is the authoritative, current list to reconcile the tutor’s course_id-tagged index against: anything indexed but no longer in that list gets removed, anything in that list but not yet indexed gets added. A scheduled WP-CLI pass across every course catches structural drift generic hooks never see. The next lesson turns to moderating community and forum content on the same membership site.

Resources & further reading

← Gating AI Features by Membership Level AI Moderation for Community and Forum Content →