
When we set out to build Masteriyo LMS — a learning management system plugin for WordPress — we faced a challenge familiar to any serious WordPress plugin developer: how do you architect a complex, feature-rich plugin that stays maintainable as it grows from 10,000 lines of code to 100,000+?
This post documents the architectural decisions, patterns, and lessons we learned building a plugin that handles courses, lessons, quizzes, enrollments, payments, and certificates — all within the WordPress ecosystem.
Most WordPress plugin tutorials start with a functions.php-style approach: hook into WordPress actions, add some global functions, maybe a few classes. This works fine for simple plugins but breaks down fast when you’re building complex software:
my_plugin_do_thing() everywhere, impossible to testMy_Plugin class with 50 methods and 3000 linesMasteriyo needed to be different. We wanted a codebase that a new developer could understand in a day, extend safely, and test confidently.
The foundation of Masteriyo’s architecture is a plugin kernel — a central service container that bootstraps the plugin and manages all dependencies. This pattern is heavily inspired by Laravel’s IoC container.
// Main plugin file: masteriyo.php
final class Masteriyo {
private static ?self $instance = null;
private Container $container;
private function __construct() {
$this->container = new Container();
$this->register_providers();
}
public static function instance(): static {
return static::$instance ??= new static();
}
private function register_providers(): void {
$providers = [
DatabaseServiceProvider::class,
PostTypeServiceProvider::class,
TaxonomyServiceProvider::class,
RestApiServiceProvider::class,
OrderServiceProvider::class,
EnrollmentServiceProvider::class,
CourseServiceProvider::class,
QuizServiceProvider::class,
];
foreach ($providers as $provider) {
(new $provider($this->container))->register();
}
add_action('init', function() use ($providers) {
foreach ($providers as $provider) {
(new $provider($this->container))->boot();
}
});
}
public function get(string $abstract): mixed {
return $this->container->get($abstract);
}
}
function masteriyo(): Masteriyo {
return Masteriyo::instance();
}
This gives us a clean entry point — masteriyo()->get(CourseRepository::class) — and lets every service declare its dependencies explicitly.
Each major feature area gets its own service provider. This is where we bind interfaces to implementations, register hooks, and declare dependencies:
class CourseServiceProvider extends AbstractServiceProvider {
public function register(): void {
$this->container->bind(
CourseRepositoryInterface::class,
CourseRepository::class
);
$this->container->singleton(
CourseService::class,
fn(Container $c) => new CourseService(
$c->get(CourseRepositoryInterface::class),
$c->get(MediaService::class),
$c->get(EventDispatcher::class),
)
);
}
public function boot(): void {
// Register hooks AFTER all providers are bound
add_action('save_post_' . PostType::COURSE, [
$this->container->get(CourseService::class),
'on_save_course'
], 10, 2);
add_filter('the_content', [
$this->container->get(CourseRenderer::class),
'maybe_render_course_content'
]);
}
}
WordPress developers often write SQL directly or scatter get_post() / get_post_meta() calls throughout their codebase. Repositories centralize all data access behind a clean interface:
interface CourseRepositoryInterface {
public function find(int $id): ?Course;
public function find_by_slug(string $slug): ?Course;
public function find_many(CourseQueryArgs $args): CourseCollection;
public function save(Course $course): Course;
public function delete(int $id, bool $force = false): bool;
public function count(CourseQueryArgs $args): int;
}
class CourseRepository implements CourseRepositoryInterface {
public function find(int $id): ?Course {
$post = get_post($id);
if (!$post || PostType::COURSE !== $post->post_type) {
return null;
}
return $this->hydrate($post);
}
public function find_many(CourseQueryArgs $args): CourseCollection {
$query = new WP_Query([
'post_type' => PostType::COURSE,
'post_status' => $args->status ?? 'publish',
'posts_per_page' => $args->per_page ?? 10,
'paged' => $args->page ?? 1,
'orderby' => $args->orderby ?? 'date',
'order' => $args->order ?? 'DESC',
'tax_query' => $this->build_tax_query($args),
'meta_query' => $this->build_meta_query($args),
]);
return new CourseCollection(
array_map([$this, 'hydrate'], $query->posts),
$query->found_posts,
$args->per_page ?? 10
);
}
private function hydrate(WP_Post $post): Course {
$meta = get_post_meta($post->ID);
return new Course(
id: $post->ID,
title: $post->post_title,
slug: $post->post_name,
description: $post->post_content,
price: new Money((int)($meta['_price'][0] ?? 0), 'USD'),
duration: (int)($meta['_duration'][0] ?? 0),
difficulty: Difficulty::from($meta['_difficulty'][0] ?? 'beginner'),
authorId: (int)$post->post_author,
status: CourseStatus::from($post->post_status),
);
}
}
Instead of passing WP_Post objects everywhere, Masteriyo uses domain models — PHP classes that represent business concepts, not database rows. This keeps business logic clean and testable:
final readonly class Course {
public function __construct(
public int $id,
public string $title,
public string $slug,
public string $description,
public Money $price,
public int $duration,
public Difficulty $difficulty,
public int $authorId,
public CourseStatus $status,
public ?DateTimeImmutable $publishedAt = null,
public array $lessonIds = [],
public array $categoryIds = [],
) {}
public function isFree(): bool {
return $this->price->amount === 0;
}
public function isPublished(): bool {
return $this->status === CourseStatus::Published;
}
public function estimatedHours(): float {
return round($this->duration / 60, 1);
}
public function canBeEnrolledBy(User $user): bool {
if (!$this->isPublished()) return false;
if ($this->isFree()) return true;
return $user->hasActiveSubscription() || $user->hasPurchased($this->id);
}
}
WordPress actions work fine for simple cases, but when multiple subsystems need to react to the same event (e.g., “course enrolled”), you get tight coupling. Masteriyo uses a lightweight event dispatcher:
// Events are value objects — serializable, testable
final readonly class CourseEnrolledEvent {
public function __construct(
public int $enrollmentId,
public int $courseId,
public int $studentId,
public DateTimeImmutable $enrolledAt,
) {}
}
// Listeners are independent, focused classes
class SendEnrollmentEmailListener {
public function __construct(
private readonly EmailService $emailService,
private readonly UserRepository $userRepository,
) {}
public function handle(CourseEnrolledEvent $event): void {
$student = $this->userRepository->find($event->studentId);
if (!$student) return;
$this->emailService->send(
to: $student->email,
template: 'enrollment-confirmation',
data: ['course_id' => $event->courseId]
);
}
}
// Registration in provider
class EnrollmentServiceProvider extends AbstractServiceProvider {
public function boot(): void {
$dispatcher = $this->container->get(EventDispatcher::class);
$dispatcher->listen(
CourseEnrolledEvent::class,
SendEnrollmentEmailListener::class,
UpdateEnrollmentStatsListener::class,
GenerateCertificateListener::class,
);
}
}
Masteriyo exposes a comprehensive REST API using proper WP_REST_Controller subclasses. The key is schema-first development — define your schema first, then implement endpoints:
class CoursesController extends WP_REST_Controller {
protected string $namespace = 'masteriyo/v1';
protected string $rest_base = 'courses';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->rest_base, [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_items'],
'permission_callback' => [$this, 'get_items_permissions_check'],
'args' => $this->get_collection_params(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_item'],
'permission_callback' => [$this, 'create_item_permissions_check'],
'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE),
],
'schema' => [$this, 'get_public_item_schema'],
]);
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[d]+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_item'],
'permission_callback' => [$this, 'get_item_permissions_check'],
'args' => ['context' => $this->get_context_param(['default' => 'view'])],
],
'schema' => [$this, 'get_public_item_schema'],
]);
}
public function get_items(WP_REST_Request $request): WP_REST_Response|WP_Error {
$args = new CourseQueryArgs(
per_page: (int) $request['per_page'],
page: (int) $request['page'],
status: sanitize_key($request['status'] ?? 'publish'),
search: sanitize_text_field($request['search'] ?? ''),
);
$collection = $this->courseService->find_many($args);
$response = rest_ensure_response(array_map(
fn(Course $c) => $this->prepare_item_for_response($c, $request)->get_data(),
$collection->items()
));
$response->header('X-WP-Total', $collection->total());
$response->header('X-WP-TotalPages', $collection->total_pages());
return $response;
}
public function get_item_schema(): array {
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'course',
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'title' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'price' => ['type' => 'integer', 'description' => 'Price in cents'],
'difficulty' => ['type' => 'string', 'enum' => ['beginner', 'intermediate', 'advanced']],
'duration' => ['type' => 'integer', 'description' => 'Duration in minutes'],
],
];
}
}
The most common performance killer in WordPress plugins is the N+1 query problem. If you load 20 courses and then call get_post_meta($course_id) inside the loop, you’re making 21 database queries. Masteriyo solves this with eager loading:
class CourseRepository {
public function find_many_with_meta(CourseQueryArgs $args): CourseCollection {
$query = new WP_Query([/* ... */]);
$posts = $query->posts;
if (empty($posts)) {
return new CourseCollection([], 0, $args->per_page);
}
// Prime the meta cache for ALL posts at once — 1 query instead of N
$post_ids = array_column($posts, 'ID');
update_meta_cache('post', $post_ids);
// Also prime taxonomy cache
update_object_term_cache($post_ids, PostType::COURSE);
// Now hydrate — all meta/term fetches hit the cache
return new CourseCollection(
array_map([$this, 'hydrate'], $posts),
$query->found_posts,
$args->per_page
);
}
}
update_meta_cache() is a WordPress core function that loads all post meta in a single WHERE post_id IN (...) query and populates the object cache. This turns 21 queries into 3 (posts query + meta query + term query).
WordPress plugins are notoriously hard to test because everything depends on WordPress functions. Brain Monkey lets you mock WordPress functions in PHPUnit tests without loading WordPress:
class CourseRepositoryTest extends TestCase {
use BrainMonkeyTestTrait;
private CourseRepository $repository;
protected function setUp(): void {
parent::setUp();
BrainMonkeysetUp();
$this->repository = new CourseRepository();
}
protected function tearDown(): void {
BrainMonkey earDown();
parent::tearDown();
}
public function test_find_returns_null_for_nonexistent_post(): void {
Functionsexpect('get_post')
->once()
->with(999)
->andReturn(null);
$result = $this->repository->find(999);
$this->assertNull($result);
}
public function test_find_returns_course_for_valid_post(): void {
$post = (object)[
'ID' => 42,
'post_type' => 'mto-course',
'post_title' => 'Learn PHP 8',
'post_name' => 'learn-php-8',
'post_content'=> 'Deep dive into PHP 8',
'post_status' => 'publish',
'post_author' => 1,
];
Functionsexpect('get_post')->once()->with(42)->andReturn($post);
Functionsexpect('get_post_meta')->once()->with(42)->andReturn([
'_price' => ['4999'],
'_difficulty' => ['beginner'],
'_duration' => ['120'],
]);
$course = $this->repository->find(42);
$this->assertInstanceOf(Course::class, $course);
$this->assertSame('Learn PHP 8', $course->title);
$this->assertSame(4999, $course->price->amount);
}
}
Masteriyo’s apply_filters('masteriyo_course_data', $data, $course_id) pattern lets third-party plugins extend every piece of data without hacking core files. Add filter hooks to every significant data transform.
User activity, quiz attempts, and progress data accumulate fast. Post meta isn’t designed for this. We created dedicated wp_masteriyo_user_activities and wp_masteriyo_quiz_attempts tables with proper indexes from day one.
// Custom table schema
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$wpdb->prefix}masteriyo_user_activities (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
item_id bigint(20) NOT NULL,
item_type varchar(50) NOT NULL,
activity_type varchar(50) NOT NULL,
progress float DEFAULT 0,
completed tinyint(1) DEFAULT 0,
started_at datetime NOT NULL,
modified_at datetime NOT NULL,
PRIMARY KEY (id),
KEY user_item (user_id, item_id),
KEY item_type (item_type, item_id)
) $charset_collate;";
Course listings and enrollment checks happen on every page load. We built a caching layer around the repository that works with both object cache (Redis/Memcached) and transients:
class CachedCourseRepository implements CourseRepositoryInterface {
public function __construct(
private readonly CourseRepository $inner,
private readonly CacheInterface $cache,
) {}
public function find(int $id): ?Course {
$key = "course:$id";
return $this->cache->remember($key, 3600, fn() => $this->inner->find($id));
}
public function save(Course $course): Course {
$saved = $this->inner->save($course);
$this->cache->delete("course:{$course->id}");
$this->cache->delete("course_listing"); // Bust listing cache too
return $saved;
}
}
Building Masteriyo taught us that complex WordPress plugins don’t have to be complex codebases. By applying proven software engineering patterns — service containers, repositories, domain models, event systems — you can build plugins that are a pleasure to work with as they scale.
The up-front investment in architecture pays back immediately when you need to add features, debug issues, or onboard a new team member. The alternative — a tightly coupled, procedural plugin — becomes progressively harder to change with every new feature.
WordPress is a platform, not a limitation. Treat it that way.