Tutor LMS architecture comes down to one singleton, one config object and one large utility class. When WordPress loads the plugin file, tutor.php builds the TUTOR\Tutor instance, which registers the custom tables on $wpdb, instantiates roughly 45 components in one constructor and then fires tutor_loaded. Everything else you touch as a developer hangs off that: tutor() for configuration, tutor_utils() for helper and model calls, an action router for form posts, a template loader with theme overrides, and a key-authenticated REST namespace.
This overview is for developers who need a mental map before they write an integration, debug a conflict or plan a migration. It covers the load sequence, the main objects, how data is split between WordPress tables and custom tables, and where Tutor LMS Pro plugs in. For step-by-step site building, use the guides on Tutor LMS Expert.
Checked against the source of Tutor LMS 4.1.3 and Tutor LMS Pro 4.1.3 (October 2026). File and line references below are from that release; confirm them against the version you run before you rely on a line number.
The load sequence: from tutor.php to tutor_loaded
The bootstrap is short and runs at plugin-include time, not on a WordPress hook:
tutor.phprequires the Composer autoloader, definesTUTOR_VERSION,TUTOR_FILEandTUTOR_ENV, and registers the activation, deactivation and uninstall callbacks.- It then runs
$GLOBALS['tutor'] = tutor_lms();, andtutor_lms()returnsTutor::get_instance(). - The
TUTOR\Tutorconstructor (classes/Tutor.php) adds$wpdbaliases for 17 tables, includes the function files, and firestutor_before_load. - It boots the GDPR module and instantiates the components directly:
Addons,Post_types,Assets,Admin,Ajax,Options_V2,Shortcode,Course,Lesson,Template,Quiz,WooCommerce,TutorEDD,RestAuth,QuizBuilder,Ecommerce,Migrationand more. Each component registers its own hooks in its constructor. run()firestutor_before_runandtutor_after_run, and the constructor then firestutor_loaded.- Only after that does it attach
init_action()andupdate_instructor_capability()toinit.
The practical consequence: tutor_loaded fires while the Tutor plugin file is being included. A plugin that WordPress includes earlier can hook it; a plugin included later has missed it. For third-party code, hooking plugins_loaded and checking that tutor() exists is the dependable pattern.
<?php
// Not run on a live site; test on staging first.
add_action( 'plugins_loaded', 'acme_tutor_bootstrap' );
function acme_tutor_bootstrap() {
if ( ! function_exists( 'tutor' ) ) {
return; // Tutor LMS is not active.
}
add_action( 'tutor_after_enrolled', 'acme_on_tutor_enrolled', 10, 3 );
}
function acme_on_tutor_enrolled( $course_id, $user_id, $enrolled_id ) {
// Push the enrolment to a CRM queue, an LRS, etc.
}
The core objects: tutor(), tutor_utils() and the __call bridge
Three global helpers cover most developer access.
| Helper | Returns | Use it for |
|---|---|---|
tutor_lms() / $GLOBALS['tutor'] | The TUTOR\Tutor singleton | Reaching component instances held as properties |
tutor() | TUTOR\Config | Paths, URL, version, nonce names, has_pro and every post type name |
tutor_utils() (alias tutils()) | One cached TUTOR\Utils instance | Options, enrolment checks, course lookups, dashboard menus |
tutor() exposes its settings as properties through __get, and each post type name passes through a filter: tutor()->course_post_type is courses by default (filter tutor_course_post_type), tutor()->lesson_post_type is lesson, tutor()->quiz_post_type is tutor_quiz and tutor()->enrollment_post_type is tutor_enrolled. Read the names from tutor() rather than hard-coding them, especially courses, which replaced the pre-1.3.1 course.
TUTOR\Utils is over 10,000 lines. Since 2.0.6, much of its logic lives in model classes under Tutor\Models, and Utils::__call() forwards unknown method calls to CourseModel, LessonModel, QuizModel, WithdrawModel and EnrollmentModel. That keeps legacy calls such as tutor_utils()->do_enroll() working. It also means a mistyped method name silently returns null instead of throwing an error, so call the model class directly in new code, for example Tutor\Models\EnrollmentModel.
Request handling: actions, AJAX and REST
Tutor accepts input through four channels, and knowing which one a feature uses tells you where to hook.
- Form-post router. On
init,Tutor::init_action()reads thetutor_actionrequest field and firestutor_action_{$tutor_action}. Login, password reset and the native "pay now" flow (tutor_action_tutor_pay_now) use this. - AJAX. Core registers 137 unique
wp_ajax_*actions, 8 of them also available to logged-out users. The React course builder saves through actions such astutor_create_course,tutor_update_courseandtutor_save_topic. Nonces use actiontutor_nonce_actionand field_tutor_nonce. Tutor also overrideswp_doing_ajaxto return true whenevertutor_ajax_actionis in the request. - REST. Core registers 13 routes in the
tutor/v1namespace: read-onlyGETroutes for courses, topics, lessons, quizzes, ratings and author data, plus the e-commerce webhook. Read routes authenticate with API keys generated under Tools > Rest API and sent as a BasicAuthorizationheader. Pro adds 48 route and method registrations for writes, enrolments and students. Thecourses,course-categoryandcourse-tagobjects also appear in the corewp/v2API because they are registered withshow_in_rest. - Shortcodes and blocks.
[tutor_dashboard],[tutor_course],[tutor_cart],[tutor_checkout]and others, plus the registration and cart-button blocks.
One REST detail matters on some hosts: the key check reads the header through apache_request_headers(), so a server that strips Authorization before PHP breaks key authentication.
How the data is split
Tutor uses WordPress tables for content and people, and custom tables where row-per-post would not scale.
| Data | Where it lives |
|---|---|
| Course, topic, lesson, quiz, assignment | wp_posts: courses > topics > lesson / tutor_quiz / tutor_assignments, linked by post_parent |
| Enrolment | One tutor_enrolled post per enrolment: post_author is the student, post_parent the course, status completed, pending or cancel |
| Lesson progress | User meta _tutor_completed_lesson_id_{lesson_id} |
| Course completion | A course_completed comment whose content is the 16-character completion hash |
| Quiz questions and attempts | tutor_quiz_questions, tutor_quiz_question_answers, tutor_quiz_attempts, tutor_quiz_attempt_answers |
| Native commerce | tutor_orders, tutor_order_items, tutor_ordermeta, tutor_coupons, tutor_carts and related tables |
| Revenue sharing | tutor_earnings, tutor_withdraws |
| Settings | One serialized option, tutor_option |
Two consequences for integrations. First, quiz questions are not posts, so post-based duplication or migration tools miss them. Second, several commerce tables use real MySQL foreign keys, which matters for partial restores and table-by-table exports. The WordPress LMS custom post types article compares this layout with the other plugins.
Extension points: hooks, models and settings filters
Core 4.1.3 contains 844 unique hook names. The ones most integrations need:
| Event | Hook | Arguments |
|---|---|---|
| Enrolment confirmed | tutor_after_enrolled | $course_id, $user_id, $enrolled_id |
| Enrolment row written (pending or completed) | tutor_after_enroll | $course_id, $enrolled_id |
| Lesson completed | tutor_lesson_completed_after | $lesson_id, $user_id |
| Course completed | tutor_course_complete_after | $course_id, $user_id |
| Quiz finished | tutor_quiz_finished | $attempt_id, $quiz_id, $user_id |
| Block an enrolment | filter tutor_allow_course_enrollment | Return a WP_Error to block |
Two behaviours are easy to miss. tutor_after_enrolled fires only for completed enrolments, while tutor_after_enroll also fires for pending ones created for purchasable courses. And Utils::get_option() runs apply_filters( $key, $value ) on every setting key, so each key in tutor_option is also an unprefixed filter name, such as monetize_by. That is convenient for overrides and a collision risk with other plugins that use the same generic names.
Templates and the 4.0 learning area
tutor_get_template() resolves a template name by looking in the child theme's tutor/ folder, then the parent theme's, then the plugin's templates/ folder, and finally Pro's when requested. Dots in the name become folders, so single.course.reviews maps to single/course/reviews.php. The tutor/ folder name is hard-coded: tutor()->template_path is defined but not read, so use the tutor_get_template_path filter to move a template.
The Template class takes over the course archive, single course, private video, public profile and dashboard through template_include at priority 99. Returning false from tutor_lms_should_template_override switches all of that off.
Version 4.0 added three learning modes, stored in setting learning_mode: Modern (the default), Kids and Legacy. In Modern and Kids mode, lessons, quizzes and assignments render through templates/learning-area/index.php, and the tutor_single_content_template filter applies only in Legacy mode. Theme overrides of the old single-lesson and single-quiz templates therefore stop applying after a switch to Modern mode.
Where Tutor LMS Pro plugs in
Pro is a separate plugin with its own namespaces (TUTOR_PRO and TutorPro\*). Its TUTOR_PRO\Init class checks that tutor/tutor.php is active and that the core version is at least TUTOR_CORE_REQ_VERSION (4.1.3 in this release) by reading the core plugin's Version header. Only then does it attach its loader to tutor_loaded. If the check fails, Pro shows an admin notice and loads nothing, so update core before Pro.
The loader always creates the admin and asset classes, applies a licence check in wp-admin, fires tutor_pro_before_load, instantiates the always-on Pro features, then includes every folder under addons/ and fires tutor_{addon}_addon_loaded for each. Each add-on's constructor checks its own on/off state, which core stores in option tutor_addons_config and changes through Addons::addon_enable_disable(). Enabling some add-ons creates their tables on tutor_addon_before_enable_{$basename}, so switch add-ons on through the admin screen, not by editing the option directly.
Architecture decisions to make before you customise
- Load timing. Hook
plugins_loadedor later and check fortutor(); do not assumetutor_loadedfires after your plugin is included. - Model calls over the bridge. Call
Tutor\Models\*classes directly so typos fail loudly. - Learning mode first. Decide Modern or Legacy before writing template overrides; the override paths differ.
- Reporting joins. Plan reports across
wp_posts,wp_comments,wp_usermetaand the quiz tables, and filter enrolments oncompletedstatus, because raw SQL also seespendingrows. - Licence wording. The core plugin header says GPLv2 or later and its readme says GPLv3; if licensing matters for your distribution, check both files in the version you ship.
For how this compares with other plugins' internals, see the LifterLMS architecture and LearnPress architecture overviews, and the WordPress LMS development guide.
How we can help
We design and build integrations, data migrations and performance work on WordPress LMS stacks, including Tutor LMS. See our Tutor LMS and WordPress LMS pages. For hands-on Tutor LMS site setup and configuration, Tutor LMS Expert covers the step-by-step side.
Frequently asked questions
What is the main class in Tutor LMS?
TUTOR\Tutor in classes/Tutor.php. tutor.php stores its singleton in $GLOBALS['tutor'] through tutor_lms(). Its constructor registers the custom tables on $wpdb, creates the components and fires tutor_loaded.
What is the difference between tutor() and tutor_utils()?
tutor() returns the TUTOR\Config object with paths, version, nonce names and post type names. tutor_utils() returns a cached TUTOR\Utils instance with helper methods, and forwards unknown methods to the course, lesson, quiz, withdraw and enrolment models.
Which hook should a plugin use to extend Tutor LMS?
Tutor fires tutor_loaded while its own plugin file is included, so a plugin loaded after it misses that hook. Hooking plugins_loaded and checking that tutor() exists works regardless of load order. For events, use hooks such as tutor_after_enrolled and tutor_course_complete_after.
Does Tutor LMS store quiz questions as posts?
No. Quizzes are tutor_quiz posts, but questions and answers are rows in tutor_quiz_questions and tutor_quiz_question_answers, and attempts are in tutor_quiz_attempts. Tools that copy only posts miss them.
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
- Tutor LMS plugin on WordPress.org - WordPress.org
- Tutor LMS source repository - Themeum (GitHub)
- Plugin API: actions and filters - WordPress Developer Resources
Learn more about this topic with AI
Gemini opens with the prompt copied - paste it into the chat to start.