Building a Lightweight WordPress LMS with Custom Post Types and Hooks

18 阅读2分钟

Building a Lightweight WordPress LMS with Custom Post Types and Hooks

When I first tried to run an education site on WordPress, I did what most people do: install an all-in-one LMS plugin, import a demo, and hope everything works. It did work—for a while—but as the content grew, I started to hit the same problems over and over again:

  • Queries became slow

  • Custom views were hard to control

  • Small changes required digging through a huge plugin codebase

So I ended up taking a different path: keep WordPress as the core, but build a lightweight LMS layer myself using custom post types, meta fields, and a few hooks. In this post I’ll share the core ideas and code snippets that have been stable in production.

1. Data model: treat courses as a first-class domain

Instead of thinking “posts + some extra fields”, I treat the LMS as a small domain model:

  • course – custom post type, one entry per course

  • lesson – custom post type, one entry per lesson

  • course_category – taxonomy for course grouping

  • Meta fields for level, duration, enrollment count, etc.

That leads to a simple mental model:

  • A

    course

    is something a user can enroll in.

  • A

    lesson

    is a piece of content that belongs to a course.

Registering the post types

Here’s a trimmed version of what I use in a small plugin:

function ml_register_lms_types() {
    register_post_type( 'course', array(
        'label'       => 'Courses',
        'public'      => true,
        'has_archive' => true,
        'show_in_rest'=> true,
        'supports'    => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
    ) );

    register_post_type( 'lesson', array(
        'label'       => 'Lessons',
        'public'      => true,
        'has_archive' => false,
        'show_in_rest'=> true,
        'supports'    => array( 'title', 'editor' ),
    ) );

    register_taxonomy( 'course_category', 'course', array(
        'label'        => 'Course Categories',
        'hierarchical' => true,
        'show_in_rest' => true,
    ) );
}
add_action( 'init', 'ml_register_lms_types' );

Registering course meta

I usually add at least a level and duration field:

function ml_register_course_meta() {
    register_post_meta( 'course', '_ml_level', array(
        'type'              => 'string',
        'single'            => true,
        'sanitize_callback' => 'sanitize_text_field',
        'show_in_rest'      => true,
    ) );

    register_post_meta( 'course', '_ml_duration', array(
        'type'              => 'string',
        'single'            => true,
        'sanitize_callback' => 'sanitize_text_field',
        'show_in_rest'      => true,
    ) );
}
add_action( 'init', 'ml_register_course_meta' );

Exposing the fields in REST makes it easier to build dashboards or front-end filters later.

2. Linking lessons to courses

For the relationship between lesson and course, I use a simple meta key on the lesson side:

function ml_register_lesson_meta() {
    register_post_meta( 'lesson', '_ml_course_id', array(
        'type'              => 'integer',
        'single'            => true,
        'sanitize_callback' => 'absint',
        'show_in_rest'      => true,
    ) );
}
add_action( 'init', 'ml_register_lesson_meta' );

When creating or editing a lesson, I store the parent course ID in this meta key. Then I can fetch all lessons for a course with a straightforward query:

function ml_get_course_lessons( $course_id ) {
    $args = array(
        'post_type'      => 'lesson',
        'posts_per_page' => -1,
        'meta_key'       => '_ml_course_id',
        'meta_value'     => (int) $course_id,
        'orderby'        => 'menu_order',
        'order'          => 'ASC',
    );

    return new WP_Query( $args );
}

This avoids weird nested taxonomies and keeps the course page template clean.

3. Customizing the course archive with pre_get_posts

The default archive query is rarely what I want. I usually need:

  • A limited number of courses per page

  • Sorting by popularity or difficulty

  • Optional filtering by level from URL parameters

pre_get_posts lets me adjust the main query without rewriting templates:

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_enrollment_count' );
        $query->set( 'orderby', 'meta_value_num' );
        $query->set( 'order', 'DESC' );

        if ( isset( $_GET['level'] ) && $_GET['level'] !== '' ) {
            $level = sanitize_text_field( wp_unslash( $_GET['level'] ) );
            $meta_query   = (array) $query->get( 'meta_query' );
            $meta_query[] = array(
                'key'   => '_ml_level',
                'value' => $level,
            );
            $query->set( 'meta_query', $meta_query );
        }
    }
}
add_action( 'pre_get_posts', 'ml_customize_course_archive' );

Now I can support URLs like:

/courses/?level=beginner

without creating custom route handlers.

4. Tracking enrollments and popularity

To keep things simple, I don’t track every event. I only care about actual enrollments. Whenever a user enrolls in a course (through whatever checkout logic you use), I call a small helper:

function ml_mark_enrolled( $course_id, $user_id ) {
    $count = (int) get_post_meta( $course_id, '_ml_enrollment_count', true );
    $count++;

    update_post_meta( $course_id, '_ml_enrollment_count', $count );
}

Most LMS plugins or custom checkout flows have a hook like do_action( 'ml_course_enrolled', $course_id, $user_id );. I attach to that hook and call ml_mark_enrolled().

This gives me a single numeric field that I can reuse for sorting, statistics, or widgets like “most popular courses”.

5. Caching heavy queries with transients

Some sections—like “Top 10 courses this month”—can be expensive because of meta sorting and joins. I try not to run those queries on every request.

A small transient wrapper is often enough:

function ml_get_top_courses( $limit = 6 ) {
    $key    = 'ml_top_courses_' . $limit;
    $cached = get_transient( $key );

    if ( false !== $cached ) {
        return $cached;
    }

    $args  = array(
        'post_type'      => 'course',
        'posts_per_page' => $limit,
        'meta_key'       => '_ml_enrollment_count',
        'orderby'        => 'meta_value_num',
        'order'          => 'DESC',
        'no_found_rows'  => true,
    );
    $query = new WP_Query( $args );

    set_transient( $key, $query->posts, HOUR_IN_SECONDS );

    return $query->posts;
}

When a new enrollment happens, I simply clear the relevant transients:

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
        )
    );
}

Hook ml_flush_top_courses_cache() into the same enrollment action if real-time ranking is important for you.

6. A tiny REST endpoint for course statistics

For dashboards or small SPA-style widgets, I like to have a minimal REST endpoint:

function ml_register_stats_route() {
    register_rest_route( 'ml/v1', '/stats', array(
        'methods'             => 'GET',
        'permission_callback' => function () {
            return current_user_can( 'manage_options' );
        },
        'callback'            => 'ml_get_stats',
    ) );
}
add_action( 'rest_api_init', 'ml_register_stats_route' );

function ml_get_stats() {
    $args  = array(
        'post_type'      => 'course',
        'posts_per_page' => -1,
        'fields'         => 'ids',
        'no_found_rows'  => true,
    );
    $query = new WP_Query( $args );

    $total   = $query->found_posts;
    $enrolled = 0;

    foreach ( $query->posts as $course_id ) {
        $enrolled += (int) get_post_meta( $course_id, '_ml_enrollment_count', true );
    }

    return array(
        'total_courses'  => $total,
        'total_enrolled' => $enrolled,
    );
}

This endpoint gives a compact JSON snapshot of how the LMS is doing, which is easy to consume from any admin UI.

7. Where this fits in a real project

The approach above is not a full product; it’s a backbone you can plug into whatever visual layer you prefer. On one of my projects I combined this structure with an education-oriented WordPress theme so that I could focus on the data and logic while keeping the front end consistent. If you’re curious about how such a theme looks in practice, one of my test setups uses this demo project:

The important part, though, is the pattern itself: once courses, lessons, and enrollments are modeled cleanly, changing themes or redesigning pages becomes much less painful. The LMS stops feeling like a black box and starts behaving like a normal, understandable piece of application code running on top of WordPress.