LifterLMS Template Overrides: Do Them Safely and Keep Them Current
How LifterLMS finds template files, how to override one in a child theme, when a hook is the better choice, and a repeatable way to spot and update…
Read articleA practical reference to the LifterLMS hooks developers use most: enrollment, completion, quiz, order and registration actions, plus the filters that control access, lesson completion, redirects and the student dashboard, each with working code.
LifterLMS has hundreds of hooks. You will use about twenty of them for almost everything: reacting when a student enrolls or completes a course, changing who can access what, redirecting after an action, and rearranging course and lesson pages. This guide covers those LifterLMS hooks with code you can drop into a custom plugin.
It is written for WordPress developers who know how add_action() and add_filter() work. If you need a refresher, the WordPress hooks handbook is the place to start. Every hook name, argument and priority below was checked against the LifterLMS 10.3 source.
Put this code in a small custom plugin, not in a theme's functions.php. Business logic should not disappear when someone changes the theme. Prefix your functions, as we do here with llmsx_.
You will see two prefixes. Older hooks start with lifterlms_ and newer ones with llms_. Both are current; the prefix tells you nothing about whether a hook is deprecated.
Several important hooks are dynamic, built from a variable. The completion hook, for example, is lifterlms_{$object_type}_completed, which gives you lifterlms_lesson_completed, lifterlms_section_completed and lifterlms_course_completed. Searching the plugin for the literal name will not find these, so search for the pattern or use the tables below.
| Hook | Fires when | Arguments |
|---|---|---|
llms_user_enrolled_in_course | A student is enrolled in a course, by any route | $user_id, $course_id |
llms_user_added_to_membership_level | A student is added to a membership | $user_id, $membership_id |
llms_user_removed_from_course | A student is unenrolled or their access expires | $user_id, $course_id, $trigger, $new_status |
llms_user_removed_from_membership | The same for memberships | $user_id, $membership_id, $trigger, $new_status |
lifterlms_lesson_completed | A lesson is marked complete | $user_id, $lesson_id |
lifterlms_section_completed | Every lesson in a section is complete | $user_id, $section_id |
lifterlms_course_completed | Every lesson in a course is complete | $user_id, $course_id |
lifterlms_quiz_completed | A quiz attempt is finished, pass or fail | $user_id, $quiz_id, $attempt |
lifterlms_quiz_passed / lifterlms_quiz_failed | A finished attempt passed or failed | $user_id, $quiz_id, $attempt |
The enrollment hooks fire however enrollment happens: checkout, a free enroll button, an admin, the REST API or an add-on. That makes them the right place for integrations, not the checkout. $attempt is an LLMS_Quiz_Attempt object.
<?php
add_action( 'llms_user_enrolled_in_course', 'llmsx_notify_crm_of_enrollment', 10, 2 );
function llmsx_notify_crm_of_enrollment( $user_id, $course_id ) {
$user = get_userdata( $user_id );
if ( ! $user ) {
return;
}
wp_remote_post(
'https://crm.example.com/hooks/enrollment',
array(
'timeout' => 5,
'blocking' => false,
'body' => array(
'email' => $user->user_email,
'course' => get_the_title( $course_id ),
),
)
);
}
The request is non-blocking so a slow CRM cannot slow down checkout. For anything where a lost message matters, queue the work instead, or use LifterLMS's outgoing webhooks, described in our REST API and webhooks introduction.
<?php
add_action( 'lifterlms_quiz_failed', 'llmsx_quiz_failed_alert', 10, 3 );
function llmsx_quiz_failed_alert( $user_id, $quiz_id, $attempt ) {
$quiz = llms_get_post( $quiz_id );
if ( ! $quiz ) {
return;
}
$author = get_userdata( get_post_field( 'post_author', $quiz_id ) );
$student = get_userdata( $user_id );
if ( ! $author || ! $student ) {
return;
}
wp_mail(
$author->user_email,
sprintf( 'Quiz failed: %s', $quiz->get( 'title' ) ),
sprintf(
'%1$s scored %2$s%% on attempt %3$d.',
$student->display_name,
round( (float) $attempt->get( 'grade' ), 2 ),
(int) $attempt->get( 'attempt' )
)
);
}
Before writing code like this, check whether a built-in engagement already does the job. Many "send an email when" requests are covered by LifterLMS engagements without code.
A common request is to record completions somewhere other plugins or reports can read, such as user meta used by an HR export. The completion hook gives you exactly what you need:
<?php
add_action( 'lifterlms_course_completed', 'llmsx_record_completion', 10, 2 );
function llmsx_record_completion( $user_id, $course_id ) {
$completed = get_user_meta( $user_id, 'llmsx_completed_courses', true );
$completed = is_array( $completed ) ? $completed : array();
$completed[ $course_id ] = current_time( 'mysql' );
update_user_meta( $user_id, 'llmsx_completed_courses', $completed );
}
LifterLMS already stores completion dates itself, and its reporting screens and REST API read them. Copy them elsewhere only when another system genuinely needs its own copy, and remember that a student who marks a lesson incomplete again can make the course incomplete, which fires lifterlms_course_incompleted.
| Hook | Fires when | Arguments |
|---|---|---|
lifterlms_access_plan_purchased | An order becomes completed or active, after enrollment | $user_id, $plan_id |
lifterlms_product_purchased | The same, with the course or membership | $user_id, $product_id |
lifterlms_order_status_{status} | An order changes to a status | $order, $old_status, $new_status |
lifterlms_transaction_status_{status} | A transaction changes status, such as succeeded or failed | $transaction, $old_status, $new_status |
lifterlms_user_registered | A user registers through a LifterLMS form | $user_id, $posted_data, $location |
Order statuses in the dynamic hook have the llms- prefix removed, so you get lifterlms_order_status_completed, _active, _on-hold, _pending-cancel, _cancelled, _expired, _refunded and _failed. For registrations, $location is registration or checkout.
<?php
add_action( 'lifterlms_order_status_on-hold', 'llmsx_flag_on_hold_order', 10, 3 );
function llmsx_flag_on_hold_order( $order, $old_status, $new_status ) {
update_user_meta( $order->get( 'user_id' ), 'llmsx_payment_problem', current_time( 'mysql' ) );
$order->add_note( 'Customer flagged for payment follow-up.' );
}
| Filter | Controls |
|---|---|
llms_page_restricted | Whether the current user can see a post, and why not |
llms_allow_lesson_completion | Whether a lesson may be marked complete |
llms_show_mark_complete_button | Whether the Mark Complete button is shown |
llms_lesson_complete_redirect | Where a student goes after completing a lesson |
lifterlms_registration_redirect | Where a new user goes after registering |
lifterlms_completed_transaction_redirect | Where a buyer goes after checkout |
llms_get_student_dashboard_tabs | The student dashboard navigation |
lifterlms_loop_columns | Columns in course and membership catalogs |
llms_certificate_merge_data | Merge code values on awarded certificates |
llms_get_post_content | The full output of course, lesson, membership and quiz content |
Say lesson 123 can only be completed after the student has submitted a form handled by another plugin that stores user meta:
<?php
add_filter( 'llms_allow_lesson_completion', 'llmsx_gate_lesson_completion', 10, 5 );
function llmsx_gate_lesson_completion( $allow, $user_id, $lesson_id, $trigger, $args ) {
if ( 123 === (int) $lesson_id && ! get_user_meta( $user_id, 'llmsx_form_submitted', true ) ) {
return false;
}
return $allow;
}
add_filter( 'llms_show_mark_complete_button', 'llmsx_hide_button_until_form', 10, 2 );
function llmsx_hide_button_until_form( $show, $lesson ) {
if ( 123 === (int) $lesson->get( 'id' ) && ! get_user_meta( get_current_user_id(), 'llmsx_form_submitted', true ) ) {
return false;
}
return $show;
}
Use both filters together. Hiding the button alone is cosmetic; blocking completion is what enforces the rule.
<?php
add_filter( 'llms_get_student_dashboard_tabs', 'llmsx_dashboard_support_link' );
function llmsx_dashboard_support_link( $tabs ) {
$tabs['llmsx-support'] = array(
'endpoint' => false,
'nav_item' => true,
'title' => __( 'Get Help', 'llmsx' ),
'url' => home_url( '/support/' ),
);
return $tabs;
}
A tab with its own content and endpoint is also possible, but needs rewrite rules flushed and a content callback. A link like this needs neither.
Course and lesson pages are built from template functions attached to hooks. Remove one with remove_action() using the same priority, or add your own output at a priority between them.
| Hook | Function | Priority |
|---|---|---|
lifterlms_single_course_before_summary | lifterlms_template_single_video | 20 |
lifterlms_single_course_after_summary | lifterlms_template_course_author | 40 |
lifterlms_single_course_after_summary | lifterlms_template_single_prerequisites | 55 |
lifterlms_single_course_after_summary | lifterlms_template_pricing_table | 60 |
lifterlms_single_course_after_summary | lifterlms_template_single_course_progress | 60 |
lifterlms_single_course_after_summary | lifterlms_template_single_syllabus | 90 |
lifterlms_single_course_after_summary | lifterlms_template_single_reviews | 100 |
lifterlms_single_lesson_after_summary | lifterlms_template_complete_lesson_link | 10 |
lifterlms_single_lesson_after_summary | lifterlms_template_lesson_navigation | 20 |
One catch: courses edited in the block editor, Elementor or Beaver Builder are "migrated", and LifterLMS removes several of these actions because blocks, widgets or modules output the same elements instead. If remove_action() seems to do nothing, the element is probably coming from a block. Template changes beyond moving elements are covered in LifterLMS template overrides.
When a callback never runs, work through it in this order:
error_log() at the top of the file.add_action() to run when your plugin loads, not inside a later hook like template_redirect.add_action() must say so, as in the examples above.llms_user_enrolled_in_course does not fire. Test with a fresh student.With WP_DEBUG_LOG on, a single error_log( print_r( func_get_args(), true ) ); inside your callback shows exactly what LifterLMS passes. Remove it before going live, because those arguments can include personal data.
$user_id argument, never get_current_user_id(), inside action callbacks.wp_redirect() in an enrollment hook breaks checkout and API requests. Use the redirect filters.lifterlms_course_completed, with the user ID and course ID. It fires once all lessons in the course are complete.
Use lifterlms_access_plan_purchased or lifterlms_product_purchased, which fire when an order becomes completed or active, or llms_user_enrolled_in_course if you care about access rather than payment.
Search the plugin source for do_action( and apply_filters(. Each one has a docblock describing its arguments.
Yes. That is exactly how custom triggers are built; see custom engagement triggers.
Pick the one automation your team does by hand most often, find its hook in the tables above, and build it in a custom plugin on staging. Log what the callback receives the first few times it runs, then remove the logging.
If the logic is business-critical, touches payments, or needs to talk to other systems reliably, our LifterLMS plugin development service builds and maintains exactly this kind of code. Describe the workflow and we will tell you which hooks it needs.
How LifterLMS finds template files, how to override one in a child theme, when a hook is the better choice, and a repeatable way to spot and update…
Read article
How LifterLMS detects a staging copy and pauses recurring payments, what the site URL lock is, the wp-config constants that make staging safe by…
Read article
Create an API key, make your first authenticated request to /wp-json/llms/v1, and set up a signed webhook. Then choose between webhooks, polling and…
Read articleSkip the trial and error. Get specialist LifterLMS help and ship faster.