The LifterLMS hooks most integrations need are a short list: llms_user_enrolled_in_course and llms_user_added_to_membership_level for enrollment, llms_user_removed_from_{$post_type} for unenrollment, the dynamic lifterlms_{$object_type}_{$status}d family for lesson, section, course and track completion, and lifterlms_product_purchased plus the order status hooks for commerce. Almost every CRM sync, onboarding sequence or HR export we build on LifterLMS hangs off those.
This reference lists each hook with its parameters, where it fires, and the behaviour that catches developers out: completion hooks that are never written as literal strings, purchase hooks that can fire more than once per order, and enrollment hooks that also fire on re-enrollment. It stays at code level; for step-by-step LifterLMS setup help, see our sister site LifterLMS Expert.
Checked against the LifterLMS 8.0.1 source (includes/models/model.llms.student.php, includes/controllers/class.llms.controller.orders.php, includes/class.llms.engagements.php), as of October 2026. File line numbers move between releases, so search by hook name rather than line.
How LifterLMS names its hooks
LifterLMS 8.0.1 core contains roughly 1,174 unique hook names in literal do_action() and apply_filters() calls, and about 1,259 once the bundled REST, blocks, CLI and helper libraries are included. Two prefixes coexist: older hooks start with lifterlms_, newer ones with llms_. Neither prefix tells you anything about age or stability on its own.
More important is that about 120 of those names are dynamic. The well-known lifterlms_course_completed never appears as a string in the codebase. It is produced by one call in LLMS_Student::update_completion_status():
do_action( "lifterlms_{$object_type}_{$status}d", $student_id, $object_id );
So if you grep the plugin for a hook name and find nothing, search for the dynamic pattern before concluding the hook does not exist. The same applies to llms_user_removed_from_{$post_type}, llms_user_earned_{$type} and the order status hooks.
Enrollment hooks
All enrollment changes go through LLMS_Student. The public wrappers are llms_enroll_student(), llms_unenroll_student() and llms_delete_student_enrollment(), so hooks fire the same way whether the change came from checkout, an admin screen, a membership auto-enroll, the REST API or your own code.
| Hook | Type | Parameters | Fires when |
|---|---|---|---|
before_llms_user_enrollment | action | $user_id, $product_id | Start of enroll(), before any checks |
llms_user_enrolled_in_course | action | $user_id, $product_id | After enrollment rows are written for a course |
llms_user_added_to_membership_level | action | $user_id, $product_id | After enrollment in a membership |
llms_user_{$post_type}_enrollment_{$action_type} | action | $user_id, $product_id | $post_type is course or membership; $action_type is created (first enrollment) or updated (re-enrollment) |
llms_user_removed_from_{$post_type} | action | $user_id, $product_id, $trigger, $new_status | Unenrollment; llms_ is stripped, so memberships fire llms_user_removed_from_membership |
llms_user_enrollment_deleted | action | $user_id, $product_id, $enrollment_trigger | Enrollment rows are deleted, not just expired |
Three behaviours matter in practice:
- Re-enrollment fires the same hook.
llms_user_enrolled_in_coursefires for a first enrollment and for a student coming back after expiry or cancellation. If your callback must run only once per student and course, hookllms_user_course_enrollment_createdinstead. - Memberships cascade. Joining a membership enrolls the student in its auto-enroll courses with the trigger
membership_plus the membership ID, so one checkout can firellms_user_added_to_membership_levelfollowed by severalllms_user_enrolled_in_coursecalls. Leaving the membership unenrolls the courses it granted, unless another of the student's memberships also auto-enrolls them, and each unenrollment firesllms_user_removed_from_course. - Unenrollment keeps history.
llms_user_removed_from_{$post_type}appends a new status row (expiredorcancelled); the earlier rows stay. The storage model is covered in how LifterLMS stores enrollment.
Completion and progress hooks
LLMS_Student::update_completion_status() writes the completion row and then fires two actions, before cascading up to the parent object:
| Hook | Parameters | Notes |
|---|---|---|
llms_mark_{$status} | $student_id, $object_id, $object_type, $trigger | llms_mark_complete or llms_mark_incomplete, for every object type; the only one that passes the trigger |
lifterlms_lesson_completed | $student_id, $object_id | From the dynamic call with $object_type = lesson |
lifterlms_section_completed | $student_id, $object_id | Fires when the last lesson of a section completes |
lifterlms_course_completed | $student_id, $object_id | Fires when the last section completes |
lifterlms_course_track_completed | $student_id, $object_id | Object is a course_track term ID, not a post |
lifterlms_lesson_incompleted (and the other _incompleted forms) | $student_id, $object_id | Same pattern with $status = incomplete; the odd spelling is what the pattern produces |
Because the cascade runs lesson, then section, then course, then track, the final lesson of a course fires four or more completion hooks in a single request. A callback attached to llms_mark_complete sees all of them, so filter on $object_type.
Two related hooks sit on the request side. The lesson "Mark Complete" form fires llms_trigger_lesson_completion ($user_id, $lesson_id, $trigger), which LifterLMS itself listens to in order to mark the lesson complete; firing it from your own code is a supported way to request completion. The lifterlms_autoadvance filter, default true, controls the redirect to the next lesson afterwards.
Quizzes fire from LLMS_Quiz_Attempt::do_completion_actions(): lifterlms_quiz_completed always, then one of lifterlms_quiz_passed, lifterlms_quiz_failed or lifterlms_quiz_pending. Each passes $student_id, $quiz_id, $attempt, where $attempt is the LLMS_Quiz_Attempt object, so the grade is available without another query.
Commerce hooks: orders, transactions and purchases
Orders and transactions are post types (llms_order, llms_transaction). LLMS_Controller_Orders::transition_status() listens to WordPress transition_post_status, ignores no-op changes, strips the llms- and txn- prefixes and fires:
lifterlms_{$post_type}_status_{$old_status}_to_{$new_status}lifterlms_{$post_type}_status_{$new_status}
Both pass $obj, $old_status, $new_status, where $obj is an LLMS_Order or LLMS_Transaction. That gives hooks such as lifterlms_order_status_active, lifterlms_order_status_on-hold and lifterlms_transaction_status_succeeded.
LifterLMS uses its own status hooks. complete_order() is attached to both lifterlms_order_status_completed and lifterlms_order_status_active, and runs in this order:
lifterlms_order_complete($order_id), marked in the source as kept for one affiliate pluginllms_enroll_student()with triggerorder_plus the order ID, which fires the enrollment hooks abovelifterlms_product_purchased($user_id, $product_id)lifterlms_access_plan_purchased($user_id, $plan_id)
The catch: complete_order() runs on every transition into completed or active, not once per order. A recurring order that goes on hold after a failed charge and returns to active fires lifterlms_product_purchased again. Treat it as "access granted or restored", and make callbacks idempotent. For revenue events on renewals, lifterlms_transaction_status_succeeded is the accurate hook, one per successful charge.
| Hook | Type | Use |
|---|---|---|
lifterlms_new_pending_order | action | Order created at checkout, before payment; passes $order, $student, $user_data |
llms_manual_payment_due | action | Manual gateway order awaiting offline payment; passes $order, $gateway |
llms_charge_recurring_payment_scheduled | action | Next recurring charge queued in Action Scheduler |
llms_unenroll_on_error_order | filter | Return false to keep access when an order is refunded, cancelled, failed or put on hold |
lifterlms_payment_gateways | filter | Register a gateway class; core ships only the manual gateway |
If recurring charges stop firing at all after a migration, the hooks are not the problem; see LifterLMS recurring payments not working.
Registration and engagement hooks
lifterlms_user_registered($user_id, $posted_data, $location) fires after a LifterLMS registration or checkout form creates a user.lifterlms_user_updatedis the equivalent for account edits.llms_user_earned_{$type}($user_id, $generated_id, $related_id, $engagement_id) fires after a certificate or achievement is awarded, sollms_user_earned_certificateis the hook for pushing certificates to an HR system.lifterlms_engagement_actionsfilters the list of trigger hooks the engagement engine listens to. The engine attaches to enrollment, completion, quiz and purchase hooks at priority 777, so ordinary callbacks at priority 10 run before any engagement is processed.
Delays, handlers and the award checks are covered in LifterLMS engagements explained.
Filters that change outcomes
| Filter | Parameters | Use with care because |
|---|---|---|
llms_get_enrollment_status | $status, $user_id, $product_id, $use_cache | Enrollment and access checks read it; an external lookup here runs on most page loads |
llms_page_restricted | $results, $post_id | Final say on restriction; a bug opens or closes paid content |
llms_user_enrollment_allowed_post_types | $post_types | Makes a custom post type enrollable; default is course and llms_membership |
llms_mark_complete_parent_data | $parent_data, $object, $object_id, $object_type | Changes the completion cascade |
lifterlms_autoadvance | true | Affects every lesson completion redirect |
Example: queue CRM updates from enrollment and completion
Callbacks run inside the request that triggered them: checkout, a lesson completion, an admin bulk action. An HTTP call to a CRM there slows the learner down and fails silently when the CRM is slow. LifterLMS already loads Action Scheduler, so queue the work instead. Not run on a live site; test on staging first.
<?php
/**
* Plugin Name: Myprefix LifterLMS CRM Sync
*/
add_action( 'llms_user_course_enrollment_created', 'myprefix_queue_enrolled', 10, 2 );
add_action( 'lifterlms_course_completed', 'myprefix_queue_completed', 10, 2 );
add_action( 'myprefix_crm_push', 'myprefix_crm_push', 10, 3 );
function myprefix_queue_enrolled( $user_id, $course_id ) {
as_schedule_single_action( time(), 'myprefix_crm_push', array( 'enrolled', absint( $user_id ), absint( $course_id ) ), 'myprefix' );
}
function myprefix_queue_completed( $user_id, $course_id ) {
as_schedule_single_action( time(), 'myprefix_crm_push', array( 'completed', absint( $user_id ), absint( $course_id ) ), 'myprefix' );
}
function myprefix_crm_push( $event, $user_id, $course_id ) {
$user = get_userdata( $user_id );
if ( ! $user || ! defined( 'MYPREFIX_CRM_TOKEN' ) ) {
return;
}
$response = wp_remote_post(
'https://crm.example.com/api/learning-events',
array(
'timeout' => 10,
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . MYPREFIX_CRM_TOKEN,
),
'body' => wp_json_encode(
array(
'event' => sanitize_key( $event ),
'email' => $user->user_email,
'course_id' => absint( $course_id ),
)
),
)
);
if ( is_wp_error( $response ) || 300 <= (int) wp_remote_retrieve_response_code( $response ) ) {
llms_log( sprintf( 'CRM push failed: %s user %d course %d', $event, $user_id, $course_id ), 'myprefix-crm' );
}
}
The token lives in wp-config.php, not the database, and the CRM call carries IDs rather than names, so a renamed course does not break matching. Using llms_user_course_enrollment_created rather than llms_user_enrolled_in_course stops re-enrollments from creating duplicate CRM events; swap it if you want those too.
Pitfalls we see in LifterLMS hook code
- Hooking too early. LifterLMS registers its post types at
initpriority 5. Attach callbacks onlifterlms_initor plaininitat the default priority, not at plugin load, if they call LifterLMS functions. - Assuming one purchase per order. As above,
lifterlms_product_purchasedfollows order status changes. - Heavy work on access filters.
llms_get_enrollment_statusandllms_page_restrictedrun on most front-end requests. Cache anything external. - Background enrollments. Bulk enrolling a membership's members into a course runs in a background processor, so hooks fire from that process, not from the admin's request. Do not rely on
is_admin()or the current user inside callbacks; use the IDs passed in. - Template changes after upgrades. Hooks are more stable than template overrides, which is one more reason to unhook and re-hook template callbacks rather than copy files. The WordPress LMS development guide covers that trade-off across plugins.
How we can help
We build LifterLMS integrations that sync enrollment, completion and purchases with CRMs, HR systems and data warehouses, with queued delivery and idempotent handlers. See our LMS plugin development service, or contact us with the systems you need connected.
Frequently asked questions
Which hook fires when a LifterLMS course is completed?
lifterlms_course_completed, with $student_id and $course_id. It is generated by the dynamic call lifterlms_{$object_type}_{$status}d in LLMS_Student::update_completion_status(), so it does not appear as a literal string in the plugin. llms_mark_complete fires just before it and also passes the object type and trigger.
What is the difference between llms_user_enrolled_in_course and lifterlms_product_purchased?
llms_user_enrolled_in_course fires for any course enrollment, paid or not. lifterlms_product_purchased fires only from the order controller, after the enrollment, when an order moves to completed or active. Use the first for access-driven automations and the second for purchase-driven ones.
How do I detect a LifterLMS unenrollment?
Hook llms_user_removed_from_course or llms_user_removed_from_membership. Both receive the user ID, product ID, trigger and the new status (expired or cancelled). Hard deletes fire llms_user_enrollment_deleted instead.
Which hook should I use for recurring payment renewals?
lifterlms_transaction_status_succeeded, which fires once per successful transaction and passes the LLMS_Transaction object. Order-level hooks only fire when the order status changes, which does not happen on a normal renewal of an active order.
Free 30-minute consultation
Not sure where to start? Talk to an LMS engineer.
Tell us what platform you run and what you need. You’ll get honest, practical advice - even if the answer is that you don’t need us.
Sources & references
- LifterLMS source: includes/models/model.llms.student.php (8.0.1) - LifterLMS (gocodebox)
- LifterLMS source: includes/controllers/class.llms.controller.orders.php (8.0.1) - LifterLMS (gocodebox)
- LifterLMS developer documentation - LifterLMS
- Plugin API: Hooks - WordPress.org
- Action Scheduler documentation - Action Scheduler
Learn more about this topic with AI
Gemini opens with the prompt copied - paste it into the chat to start.