Transform Learning with a Powerful Enterprise LMS

Learn more
WordPress LMS

LifterLMS Hooks Reference: Enrollment, Completion and Commerce

A developer reference to the LifterLMS actions and filters behind enrollment, lesson and course completion, quizzes, orders and certificates, with the firing order and the traps in each.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Diagram of LifterLMS hooks firing in order across enrollment, lesson and course completion, and order status changes

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.

HookTypeParametersFires when
before_llms_user_enrollmentaction$user_id, $product_idStart of enroll(), before any checks
llms_user_enrolled_in_courseaction$user_id, $product_idAfter enrollment rows are written for a course
llms_user_added_to_membership_levelaction$user_id, $product_idAfter 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_statusUnenrollment; llms_ is stripped, so memberships fire llms_user_removed_from_membership
llms_user_enrollment_deletedaction$user_id, $product_id, $enrollment_triggerEnrollment rows are deleted, not just expired

Three behaviours matter in practice:

  • Re-enrollment fires the same hook. llms_user_enrolled_in_course fires 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, hook llms_user_course_enrollment_created instead.
  • 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 fire llms_user_added_to_membership_level followed by several llms_user_enrolled_in_course calls. Leaving the membership unenrolls the courses it granted, unless another of the student's memberships also auto-enrolls them, and each unenrollment fires llms_user_removed_from_course.
  • Unenrollment keeps history. llms_user_removed_from_{$post_type} appends a new status row (expired or cancelled); 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:

HookParametersNotes
llms_mark_{$status}$student_id, $object_id, $object_type, $triggerllms_mark_complete or llms_mark_incomplete, for every object type; the only one that passes the trigger
lifterlms_lesson_completed$student_id, $object_idFrom the dynamic call with $object_type = lesson
lifterlms_section_completed$student_id, $object_idFires when the last lesson of a section completes
lifterlms_course_completed$student_id, $object_idFires when the last section completes
lifterlms_course_track_completed$student_id, $object_idObject is a course_track term ID, not a post
lifterlms_lesson_incompleted (and the other _incompleted forms)$student_id, $object_idSame 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:

  1. lifterlms_order_complete ($order_id), marked in the source as kept for one affiliate plugin
  2. llms_enroll_student() with trigger order_ plus the order ID, which fires the enrollment hooks above
  3. lifterlms_product_purchased ($user_id, $product_id)
  4. 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.

HookTypeUse
lifterlms_new_pending_orderactionOrder created at checkout, before payment; passes $order, $student, $user_data
llms_manual_payment_dueactionManual gateway order awaiting offline payment; passes $order, $gateway
llms_charge_recurring_payment_scheduledactionNext recurring charge queued in Action Scheduler
llms_unenroll_on_error_orderfilterReturn false to keep access when an order is refunded, cancelled, failed or put on hold
lifterlms_payment_gatewaysfilterRegister 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_updated is 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, so llms_user_earned_certificate is the hook for pushing certificates to an HR system.
  • lifterlms_engagement_actions filters 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

FilterParametersUse with care because
llms_get_enrollment_status$status, $user_id, $product_id, $use_cacheEnrollment and access checks read it; an external lookup here runs on most page loads
llms_page_restricted$results, $post_idFinal say on restriction; a bug opens or closes paid content
llms_user_enrollment_allowed_post_types$post_typesMakes a custom post type enrollable; default is course and llms_membership
llms_mark_complete_parent_data$parent_data, $object, $object_id, $object_typeChanges the completion cascade
lifterlms_autoadvancetrueAffects 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 init priority 5. Attach callbacks on lifterlms_init or plain init at the default priority, not at plugin load, if they call LifterLMS functions.
  • Assuming one purchase per order. As above, lifterlms_product_purchased follows order status changes.
  • Heavy work on access filters. llms_get_enrollment_status and llms_page_restricted run 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

  1. LifterLMS source: includes/models/model.llms.student.php (8.0.1) - LifterLMS (gocodebox)
  2. LifterLMS source: includes/controllers/class.llms.controller.orders.php (8.0.1) - LifterLMS (gocodebox)
  3. LifterLMS developer documentation - LifterLMS
  4. Plugin API: Hooks - WordPress.org
  5. Action Scheduler documentation - Action Scheduler
Add as a preferred source on Google

Add All-in-One LMS as a preferred source to see more of our articles in Google’s Top Stories.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Keep reading

Before you go - get a free LMS audit

Tell us which LMS you use and what is not working. An engineer replies within one business day.

Choose which optional cookies we may use. You can change this at any time from "Cookie preferences" in the footer. Cookie policy · Privacy policy