Low-Level WordPress LMS Customization with the MasterLife Theme
Most articles about LMS sites focus on plugins and page builders. In practice, when you maintain an education site long-term, you eventually end up touching the low-level PHP that drives course loops, queries, and caching.
In this post I will walk through how I wired an LMS layout using WordPress hooks, custom queries, and some simple caching. On one of my projects I happened to use the as the front-end layer, but all of the techniques below are theme-agnostic and can be reused on any LMS setup.
1. Designing the data model: courses, lessons, and taxonomies
Before writing a single line of code, I decided on a basic structure:
-
Custom post type
course -
Custom post type
lesson -
Taxonomy
course_category -
Meta fields like
course_level,course_duration,course_enrollments
If your theme already registers these post types, you can still extend them. For example, I add my own meta key for difficulty:
function ml_register_course_meta() {
register_post_meta(
'course',
'_ml_course_level',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => true,
)
);
}
add_action( 'init', 'ml_register_course_meta' );
Exposing the field in REST is useful later if you build dashboards or front-end filters with JavaScript.
2. Rewriting the main course archive query
Most LMS themes ship with a default archive loop. I wanted fine-grained control over sorting and filtering, so I intercepted the main query with pre_get_posts:
function ml_customize_course_archive( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( is_post_type_archive( 'course' ) ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'meta_key', '_ml_course_popularity' );
$query->set( 'orderby', 'meta_value_num' );
$query->set( 'order', 'DESC' );
if ( isset( $_GET['level'] ) && $_GET['level'] !== '' ) {
$meta_query = (array) $query->get( 'meta_query' );
$meta_query[] = array(
'key' => '_ml_course_level',
'value' => sanitize_text_field( wp_unslash( $_GET['level'] ) ),
);
$query->set( 'meta_query', $meta_query );
}
}
}
add_action( 'pre_get_posts', 'ml_customize_course_archive' );
This gives me a course archive that is sorted by popularity, with an optional URL parameter to filter by level (beginner, intermediate, advanced).
3. Tracking enrollments and computing popularity
To sort by popularity, I maintain a _ml_course_popularity meta field. Instead of updating this value on every page view, I only update it when a real enrollment happens:
function ml_course_enrolled( $course_id, $user_id ) {
$count = (int) get_post_meta( $course_id, '_ml_course_enrollments', true );
$count++;
update_post_meta( $course_id, '_ml_course_enrollments', $count );
update_post_meta( $course_id, '_ml_course_popularity', $count );
}
You can hook ml_course_enrolled() into your LMS plugin’s enrollment action. This keeps the logic isolated and avoids littering template files with enrollment math.
4. Caching expensive course lists with transients
Some pages, such as “Top courses this month”, require heavier queries with multiple meta conditions. Running those queries on every request is wasteful, so I cache the results in a transient:
function ml_get_top_courses( $limit = 6 ) {
$cache_key = 'ml_top_courses_' . $limit;
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$args = array(
'post_type' => 'course',
'posts_per_page' => $limit,
'meta_key' => '_ml_course_popularity',
'orderby' => 'meta_value_num',
'order' => 'DESC',
);
$query = new WP_Query( $args );
set_transient( $cache_key, $query->posts, HOUR_IN_SECONDS );
return $query->posts;
}
Then in a template part you can render the list:
$top_courses = ml_get_top_courses( 6 );
foreach ( $top_courses as $course ) {
setup_postdata( $course );
// Output title, level, duration, etc.
}
wp_reset_postdata();
Whenever a course enrollment happens, you can invalidate the transient:
function ml_flush_top_courses_cache() {
global $wpdb;
$pattern = '_transient_ml_top_courses_%';
$wpdb->query(
$wpdb->prepare(
"DELETE FROM $wpdb->options WHERE option_name LIKE %s",
$pattern
)
);
}
Call ml_flush_top_courses_cache() inside your enrollment callback if your traffic pattern justifies it.
5. Building a small REST endpoint for course stats
For admin dashboards or front-end SPA components, I prefer to expose a minimal REST endpoint instead of making multiple traditional requests:
function ml_register_course_stats_route() {
register_rest_route(
'ml/v1',
'/course-stats',
array(
'methods' => 'GET',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'callback' => 'ml_get_course_stats',
)
);
}
add_action( 'rest_api_init', 'ml_register_course_stats_route' );
function ml_get_course_stats() {
$args = array(
'post_type' => 'course',
'posts_per_page' => -1,
'fields' => 'ids',
);
$query = new WP_Query( $args );
$total = $query->found_posts;
$enroll_sum = 0;
foreach ( $query->posts as $course_id ) {
$enroll_sum += (int) get_post_meta( $course_id, '_ml_course_enrollments', true );
}
return array(
'total_courses' => $total,
'total_enrolled' => $enroll_sum,
);
}
This gives you a simple JSON response with course statistics that can be reused in multiple views without duplicating logic.
6. Takeaways from running this in production
After running this setup in a real education project, a few lessons stand out:
-
Treat course data as a first-class domain model, not just “pretty pages”.
-
Use
pre_get_poststo regain control over how courses are listed and sorted. -
Cache expensive lists with transients or another layer, instead of relying on default queries.
-
Keep enrollment and statistics logic in dedicated functions so that theme changes do not break business rules.
Using an LMS-ready theme simply gives you a convenient UI and layout, but the real stability comes from these lower-level building blocks. Once they are in place, switching designs or adjusting front-end components becomes much less risky, and your education site behaves more like a well-designed application than a collection of unrelated pages.