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.
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.
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
- learndash_get_course_steps() function reference, developers.learndash.com
- learndash_get_course_id() function reference, developers.learndash.com
- Keeping the Knowledge Base in Sync, Course 10