Summary
learn_press_get_post_by_name() resolves a lesson/quiz slug with no course or language scoping. When a translation plugin (e.g. Polylang) creates a translated lesson with the same post_name as its English source — which is normal, intended behavior for those plugins — the lookup can silently return the wrong-language post instead of the one that actually belongs to the course being viewed. This causes two distinct problems on a live site:
- Wrong content is rendered. A learner viewing an English course can land on a lesson page that renders a German/Spanish/etc. lesson, inside the English course's own UI (title, curriculum sidebar, progress bar).
- Corrupted progress data is persisted. Because LearnPress's own lesson-view tracking trusts whatever post object the buggy lookup returns, it writes an
lp_user_item row recording the wrong (translated) post's ID as a lesson viewed/completed inside the English course. That row references a post that was never in the course's section_items at all, so it becomes permanent orphaned junk in the user's progress history.
On our own site we found 119 corrupted progress rows across 15 users and 7 courses, all created in the same ~2 week window, all with the identical signature: translated-language item, English-language course.
Environment
- LearnPress (latest, 4.x)
- Polylang Pro + a machine-translation add-on that bulk-creates translated lesson/quiz posts
- Any setup where a translated CPT post is allowed to share
post_name with its source post (standard Polylang behavior — it does not enforce unique slugs across languages)
Root cause
wp-content/plugins/learnpress/inc/lp-core-functions.php, function learn_press_get_post_by_name() (~line 460):
function learn_press_get_post_by_name( $name, $type, $single = true ) {
$post_name = sanitize_title( $name );
$id = LP_Object_Cache::get( $type . '-' . $post_name, 'learn-press/post-names' );
if ( false === $id ) {
foreach ( array( $name, urldecode( $name ) ) as $_name ) {
$args = array( 'name' => $_name, 'post_type' => array( $type ) );
$posts = get_posts( $args ); // <-- no language/course scoping
if ( $posts ) {
$post = $posts[0]; // <-- get_posts() defaults to orderby=date DESC,
$id = $post->ID; // so the *newest* same-slug post always wins
...
}
}
}
return $id ? get_post( $id ) : false;
}
Two compounding defaults make this exploitable whenever slugs collide across languages:
get_posts() defaults to orderby => date DESC — when a translated sibling post shares the slug, whichever was created most recently wins the lookup, regardless of which one actually belongs to the course/language being viewed.
get_posts() defaults to suppress_filters => true — this causes Polylang's own pre_get_posts language filtering to be skipped entirely, so even a lang query var set elsewhere has no effect on this specific call.
This function is called from the main course-item resolver in inc/class-lp-page-controller.php (~line 401) and again from inc/Gutenberg/GutenbergHandleMain.php (~line 161), both without any course/language disambiguation.
Compounding this: LP_Course::get_item() (inc/course/abstract-course.php, ~line 1043) performs no validation that the resolved item ID actually belongs to the course's section_items — it wraps whatever ID it's given. So there is no safety net downstream of the bad lookup either.
Suggested fix
Scope the lookup by the current course's Polylang language before running the query, and stop suppressing filters so Polylang's own language filtering can apply. We're running the following as a local pre_get_posts patch and have verified it resolves both the display and data-corruption symptoms with no regressions:
add_action( 'pre_get_posts', function ( $query ) {
if ( ! ( $query instanceof WP_Query ) || is_admin() ) {
return;
}
if ( ! function_exists( 'pll_current_language' ) && ! function_exists( 'pll_get_post_language' ) ) {
return;
}
$name = $query->get( 'name' );
if ( empty( $name ) ) {
return;
}
$post_type = $query->get( 'post_type' );
if ( ! is_array( $post_type ) || count( $post_type ) !== 1 ) {
return;
}
if ( ! in_array( $post_type[0], array( 'lp_lesson', 'lp_quiz' ), true ) ) {
return;
}
$lang = '';
if ( isset( $GLOBALS['course'] ) && is_object( $GLOBALS['course'] )
&& method_exists( $GLOBALS['course'], 'get_id' ) && function_exists( 'pll_get_post_language' ) ) {
$lang = (string) pll_get_post_language( $GLOBALS['course']->get_id() );
}
if ( '' === $lang && function_exists( 'pll_current_language' ) ) {
$lang = (string) pll_current_language( 'slug' );
}
if ( '' === $lang ) {
return;
}
$query->set( 'suppress_filters', false );
$query->set( 'lang', $lang );
}, 5 );
We'd suggest folding an equivalent scoping directly into learn_press_get_post_by_name() itself (guarded behind function_exists('pll_get_post_language') so it's a no-op without Polylang), plus adding a section_items membership check in LP_Course::get_item() as defense in depth — that second check alone would have prevented the wrong content from ever rendering, independent of the lookup bug.
Happy to share our full patch, the exact repro steps (any Polylang + machine-translation setup that duplicates a lesson slug across languages inside a course reproduces this reliably), or test further against a patched build if useful.
Summary
learn_press_get_post_by_name()resolves a lesson/quiz slug with no course or language scoping. When a translation plugin (e.g. Polylang) creates a translated lesson with the samepost_nameas its English source — which is normal, intended behavior for those plugins — the lookup can silently return the wrong-language post instead of the one that actually belongs to the course being viewed. This causes two distinct problems on a live site:lp_user_itemrow recording the wrong (translated) post's ID as a lesson viewed/completed inside the English course. That row references a post that was never in the course'ssection_itemsat all, so it becomes permanent orphaned junk in the user's progress history.On our own site we found 119 corrupted progress rows across 15 users and 7 courses, all created in the same ~2 week window, all with the identical signature: translated-language item, English-language course.
Environment
post_namewith its source post (standard Polylang behavior — it does not enforce unique slugs across languages)Root cause
wp-content/plugins/learnpress/inc/lp-core-functions.php, functionlearn_press_get_post_by_name()(~line 460):Two compounding defaults make this exploitable whenever slugs collide across languages:
get_posts()defaults toorderby => date DESC— when a translated sibling post shares the slug, whichever was created most recently wins the lookup, regardless of which one actually belongs to the course/language being viewed.get_posts()defaults tosuppress_filters => true— this causes Polylang's ownpre_get_postslanguage filtering to be skipped entirely, so even alangquery var set elsewhere has no effect on this specific call.This function is called from the main course-item resolver in
inc/class-lp-page-controller.php(~line 401) and again frominc/Gutenberg/GutenbergHandleMain.php(~line 161), both without any course/language disambiguation.Compounding this:
LP_Course::get_item()(inc/course/abstract-course.php, ~line 1043) performs no validation that the resolved item ID actually belongs to the course'ssection_items— it wraps whatever ID it's given. So there is no safety net downstream of the bad lookup either.Suggested fix
Scope the lookup by the current course's Polylang language before running the query, and stop suppressing filters so Polylang's own language filtering can apply. We're running the following as a local
pre_get_postspatch and have verified it resolves both the display and data-corruption symptoms with no regressions:We'd suggest folding an equivalent scoping directly into
learn_press_get_post_by_name()itself (guarded behindfunction_exists('pll_get_post_language')so it's a no-op without Polylang), plus adding asection_itemsmembership check inLP_Course::get_item()as defense in depth — that second check alone would have prevented the wrong content from ever rendering, independent of the lookup bug.Happy to share our full patch, the exact repro steps (any Polylang + machine-translation setup that duplicates a lesson slug across languages inside a course reproduces this reliably), or test further against a patched build if useful.