When Elite Business Club Romania needed an online learning platform, every off-the-shelf WordPress LMS plugin fell short in one way or another. So we built our own - a production-grade WordPress plugin that handles courses, enrollments, payments, live Zoom sessions, homework review, 1:1 tutoring, diplomas, and a comprehensive security posture. Here is the engineering story behind APEX Digital LMS.
Why we didn't use LearnDash or Tutor LMS
The market for WordPress LMS plugins is mature. LearnDash, Tutor LMS, LifterLMS, and Sensei all offer course management out of the box. So why build from scratch?
The answer isn't philosophical - it's practical. Our client's requirements overlapped with what existing plugins do, but the specific combination made every off-the-shelf option a poor fit:
- WooCommerce-native payments - not a separate cart or Stripe-only checkout, but the full WooCommerce flow with coupons, invoicing, order management, and HPOS compatibility
- Bricks Builder integration - the entire frontend is built in Bricks, so course pages need custom Bricks template functions and query loops, not shortcode-rendered templates
- Vimeo-only video hosting with anti-skip protection and server-verified watch progress - not a generic oEmbed
- Live Zoom sessions embedded in the browser with role-based access (host vs. attendee) determined by enrollment status
- 1:1 tutoring sessions as a separate course type with scheduling, file exchange, and reminder emails
- Two-tier pricing - the same course sold as standard or standard+tutoring at different prices, both through WooCommerce
- Romanian language first, with specific typography rules (sentence case only, no blue colors anywhere)
Any single requirement could have been solved with an existing plugin plus custom code. All of them together meant we'd spend more time fighting the plugin's architecture than building features. A custom plugin gave us full control over the data model, the integration points, and the user experience.
Architecture: a service layer inside WordPress
WordPress plugins tend toward a "hooks and functions" style - procedural code scattered across action callbacks. That works for small plugins, but an LMS has enough interacting subsystems that we needed structure.
We settled on a service layer pattern. Each domain concern lives in its own service class:
Enrollment_Service- creating, querying, and managing course enrollmentsProgress_Service- tracking per-module and per-course completionHomework_Service- submission, review, file managementAccess_Service- determining who can see whatTutoring_Service- session scheduling and lifecycleQuiz_Service- attempts, scoring, anti-cheat measuresDiploma_Service- generation, secure URLs, template renderingRating_Service- content voting and aggregation
Every service is registered as a singleton on the main plugin class and accessed via
apex_lms()->enrollment(), apex_lms()->progress(), etc.
Services talk to each other through method calls, not through hooks. Hooks are reserved
for external integration points - apex_lms_user_enrolled,
apex_lms_course_completed, apex_lms_homework_submitted -
fired after the internal operation succeeds.
This separation has a practical benefit: when WooCommerce fires
woocommerce_order_status_completed, our order handler calls
Enrollment_Service::enroll() directly. The enrollment service handles
validation, duplicate checks, and status transitions in one place - whether the
enrollment comes from a WooCommerce order, an admin form, or a REST API call.
The enrollment engine: making concurrent webhooks safe
Enrollment sounds simple: user buys course, user gets access. In practice, payment gateways send webhooks concurrently, WooCommerce fires multiple status transitions per order, and admin staff manually enroll students at the same time. The naive "check if enrolled, then insert" pattern is a textbook TOCTOU race condition.
Our approach: insert first, handle conflicts. The enrollments table has
a UNIQUE KEY (user_id, course_id). Instead of checking before inserting, we
attempt the insert and let the database enforce uniqueness. On duplicate key, we fetch
the existing enrollment: if it's active or completed, we return it idempotently; if it's
suspended or expired, we reactivate it with the new payment data.
Refund handling was harder than expected. WooCommerce's LIKE patterns in
order-enrollment lookups were treating literal underscores as wildcards, silently matching
zero rows. Refunds never actually suspended anything. We found this during a systematic
security audit (more on that later) and fixed it with proper $wpdb->esc_like()
calls.
Never trust "check-then-act" patterns in WordPress plugin code. Payment gateways, cron jobs, and admin actions run concurrently. Use database constraints as your source of truth.
Progress tracking: five module types, one unified system
A course in APEX LMS can contain five types of modules, each with different completion criteria:
- Video modules - completion at 90% watch time (configurable), verified server-side against actual elapsed time to prevent fast-forwarding cheats
- Text modules - manual "mark as complete" button
- Quiz modules - automatic on passing score
- Live session modules - marked complete after the session window or via associated quiz
- Exam modules - timed quiz with a scheduled date, optional Zoom proctoring
Course-level progress is the percentage of completed modules. When it hits 100%, the
system fires apex_lms_course_completed, which triggers diploma generation,
a congratulation email, and an enrollment status update - all atomically wrapped
in a database transaction with an UPDATE ... WHERE completed_at IS NULL
guard to prevent duplicate completion events from concurrent requests.
Homework and assessment: server-side truth
Two principles guided our quiz and homework implementation:
- Never send correct answers to the browser. Quiz questions are delivered without answer flags. When the student submits, the server scores the attempt. This isn't optional security theater - it's the only way to make online assessments meaningful.
- Tokenize all file access. Homework files are stored in a protected
directory with
.htaccessdeny rules. Downloads go through a nonce-verified AJAX endpoint that checks ownership (student, tutor, or admin). Direct URLs don't work.
The homework workflow is straightforward: student uploads a file with optional notes, the submission gets status "pending", an admin reviews it and either approves, requests revision, or marks it as reviewed. Each status change triggers an email. In sequential-access courses, a pending or rejected homework blocks progression to the next module - the student literally cannot continue until the admin approves.
Quiz attempts use an atomic start mechanism: INSERT ... SELECT FROM DUAL WHERE NOT EXISTS
(... AND completed_at IS NULL). This prevents the subtle bug where two concurrent
"Start quiz" clicks create two incomplete attempts.
Live sessions and Zoom: 13 iterations to get it right
The Zoom integration has the most eventful development history of any feature in this plugin. Our changelog tells the story across 13 versions, from v3.0.5 to v3.8.0.
Attempt 1: Client View SDK. The Zoom Meeting SDK's "Client View" takes
over the full browser viewport. It worked, but prepareWebSDK() injected
Zoom's CSS on page load, breaking typography, buttons, and layout across the entire page.
We deferred SDK loading to join time - better, but the user lost all page context.
Attempt 2: Component View SDK in a dialog. The Component View renders
in a container element, so we put it in a native HTML <dialog>. This
introduced a cascade of issues: the dialog's "top layer" blocked MUI portal elements
(Zoom's dropdowns and menus); the SDK's gallery view expanded to screen-resolution
dimensions during screen sharing, pushing toolbars off-screen; and a Romanian locale
string (ro-RO) silently hung the SDK because it only supports 12 locales.
Attempt 3: Component View in a fixed div. We replaced <dialog>
with a position: fixed overlay. MUI portals worked again, but the SDK's
hard maximum dimensions (1440x810px) conflicted with our CSS trying to fill the
viewport. We added computeViewSizes() helpers and resize listeners.
It mostly worked - until it didn't, in edge cases we kept discovering.
Final solution: Client View in a new tab. We stripped everything back.
The join button is now a plain <a target="_blank"> link. It opens
a standalone HTML page served by WordPress (?apex_zoom_meeting=1) that loads
the Zoom Client View SDK from CDN, generates the JWT signature server-side (no AJAX
round-trip), and auto-joins. On leave, the tab closes or shows a "back to course" link.
Total CSS removed: ~128 lines of dialog rules. Total JavaScript removed: the entire
zoom-meeting.js file. Total bugs resolved: all of them.
Don't embed complex third-party UI frameworks (React + MUI) inside WordPress page context. The CSS and DOM conflicts are endless. Give the SDK its own clean page and communicate through simple URLs.
The tutoring system: 1:1 sessions and tier variants
APEX LMS supports two course types. Standard courses are module-based: the student progresses through videos, texts, quizzes, and live sessions. Tutoring courses are session-based: an admin schedules 1:1 meetings between a tutor and a student.
In version 3.12, we added a third option: tier variants. A standard course can optionally offer a 1:1 tutoring tier at a separate price. The system auto-creates two WooCommerce products per course. The enrollment table stores which tier was purchased. Tutoring session creation is gated by enrollment tier - only students who bought the 1:1 tier can schedule sessions.
The session lifecycle is simple but has meaningful automation:
- Admin creates session -> student receives confirmation email
- 24 hours before -> both student and tutor receive reminder emails
- Session time -> "Live now" badge appears, Zoom join button activates
- 2-hour window passes -> session moves to "past sessions"
- Admin cancels -> student receives cancellation email
Students can add any session to their calendar (Google, Outlook, Yahoo, or Apple/ICS)
through a dropdown button. The ICS files are generated server-side with proper timezone
handling - a lesson we learned the hard way after
strtotime() treated local Romanian times as UTC, shifting everything by 2 hours.
Diplomas: auto-generated, print-optimized, fraud-resistant
When a student completes a course, the system generates a participation diploma with a
unique 32-character hex URL (/diploma/{hash}/). No authentication required
to view it - the hash is the access token. This means students can share it on
LinkedIn, add it to their CV, or print it directly.
We offer two templates: a modern minimalist design and a classic formal design with
gold borders, Playfair Display serif typography, and an ornamental seal. Both are
HTML pages optimized for A4 landscape printing with @page rules,
print-color-adjust: exact, and mm-based dimensions.
The seal went through five iterations: hand-drawn SVG starburst, computed 24-point starburst, medal/coin design, and finally a photorealistic ribbon image. Getting the SVG math right for the starburst (outer radius 50, inner radius 42, evenly spaced at 7.5-degree intervals) was a reminder that visual details matter as much as backend logic.
Double-issuance prevention uses an atomic UPDATE ... WHERE diploma_issued = 0
pattern. Only the request that flips the flag from 0 to 1 fires the diploma event.
The 86-issue security audit
Between versions 2.27 and 3.0, we ran a comprehensive five-phase security and quality audit. The numbers:
- 13 critical issues - race conditions in enrollment, membership, and diploma issuance; IDOR vulnerabilities in homework review; missing file upload validation; SQL injection vectors in LIKE patterns
- 22 high issues - access control gaps in REST endpoints and AJAX handlers; unescaped output; missing capability checks; file security (tokenized downloads)
- 31 medium issues - REST argument validation, rate limiting, data exposure in localized scripts, timezone inconsistencies
- 20 performance issues - N+1 queries, missing caches, unbounded queries, redundant DB calls
The most impactful fix was securing homework file downloads. Previously, files were
accessible via their direct URL - anyone who guessed or intercepted the path
could download another student's homework. We moved files to a protected directory,
added .htaccess deny rules, and routed all downloads through a nonce-verified
endpoint that checks ownership before streaming the file with readfile().
Another critical fix: the can_access_course() method - a read-only
predicate that was supposed to return true/false - was calling
enroll() as a side effect. This meant that checking course access
could create enrollments, leading to ghost enrollments after refunds.
Performance: from N+1 queries to batch operations
The performance phase of our audit focused on eliminating unnecessary database queries.
The biggest win: get_next_module(), called on every dashboard load, course
page, and AJAX request. For a 10-module course, it executed 11 queries (1 for the module
list + 10 individual progress lookups). We replaced it with a single LEFT JOIN
query with LIMIT 1.
Other optimizations:
- Progress recalculation - merged 3 separate queries (completed count, last module, time sum) into 1 query using conditional aggregation
- Bricks aggregate stats - a single
SUM()query replaced N per-enrollment lookups in template functions - Batch file loading - tutoring session files loaded with one
INquery instead of N per-session calls - Quiz service caching - per-request caches reduced quiz page queries from 8-9 to 3
- Post meta priming -
update_meta_cache('post', $course_ids)primes meta for all enrolled courses in one query
We also added practical guards: LIMIT 500 on unbounded queries,
no_found_rows => true on queries that don't need pagination counts,
and transient-based cron scheduling gates that skip wp_next_scheduled()
on 99.9% of page loads.
Design system: constraints as features
Two constraints define the visual identity of APEX Digital LMS:
- No blue colors. Anywhere. The palette is zinc grays, accent green
(
#2e981a), and white. No exceptions - not in the admin, not in emails, not in status badges. When we found three blue hex values in the admin CSS during the audit, we replaced them immediately. - Sentence case only. No title case in headings, buttons, labels, or error messages. "Submit homework" not "Submit Homework". This applies in both Romanian and English. It's a small rule with an outsized effect on visual consistency.
The design tokens are defined as CSS custom properties in a single file:
apex-lms-design-tokens.css. Spacing follows a 4px base scale. Typography
uses Inter for body text and Manrope for headings. Every color, spacing value, shadow,
and border radius has a token. The admin CSS has its own token set for consistent styling
across WordPress admin pages.
We consolidated three separate button systems into one canonical .btn system,
eliminated 75 references to a legacy --apex-* variable namespace, and
standardized all email template colors to the zinc palette. These aren't glamorous
changes, but they're the difference between a codebase that feels like a product and
one that feels like a collection of features.
Content protection: pragmatic deterrence
An LMS selling paid courses needs some form of content protection. We're realistic about what client-side protection can achieve - a determined user with browser DevTools can always extract content. But we can make casual copying inconvenient enough that most users won't bother.
The protection system offers three layers, each independently toggleable:
- Anti-copy on LMS pages - disables text selection, right-click, and copy shortcuts on course and module pages
- Site-wide protection - extends the same protections to the entire frontend, with exceptions for forms and interactive elements
- DevTools blocking - intercepts F12, Ctrl+Shift+I/J/C, and Ctrl+U shortcuts. Administrators are automatically exempt.
The implementation carefully normalizes DOM events - a toElement() helper
converts Text and Comment nodes to their parent Element before calling
matches()/closest(), preventing TypeErrors on raw text interactions.
Form inputs, the WordPress admin bar, and interactive elements are whitelisted so the
site remains fully functional.
Transactional emails: branded end-to-end
The plugin sends eight automated emails: enrollment confirmation, course completion, homework submitted/reviewed, membership welcome, tutoring session scheduled/reminder/cancelled. All emails are WooCommerce-native - they appear in WooCommerce > Settings > Emails and support the standard enable/disable, subject, and heading customizations.
Beyond the custom LMS emails, we override WooCommerce's global email header, footer, and CSS styles. Every email - order confirmations, invoices, refund notices - uses the same zinc/green palette with the company branding. No default WooCommerce purple or blue leaks through.
What we learned
- WordPress is a capable application platform when you bring your own architecture. The hook system, database abstraction, and user/role infrastructure are solid. What WordPress doesn't give you is structure - you have to bring that yourself.
- WooCommerce integration is the right call for payments. Building a custom checkout would have been months of work for an inferior result. WooCommerce handles gateways, invoicing, refunds, coupons, and tax calculation. We just connect to the events.
- Third-party SDK embedding is fragile. The Zoom saga cost us 13 versions of iteration. The final solution - a clean new tab - is simpler, more reliable, and required less code than any of the embedded approaches.
- Security audits pay for themselves. The 86-issue audit found race conditions that had existed since v1. Every insert-first pattern, every atomic update, every tokenized download makes the system more trustworthy.
- Design constraints reduce decisions. "No blue, sentence case only" sounds restrictive. In practice, it means every new feature automatically looks consistent without design review. The constraint is the design system.
APEX Digital LMS is now at version 3.12, serving courses for Elite Business Club Romania. It handles video courses, live sessions, quizzes, exams, homework, 1:1 tutoring, memberships, diplomas, and a full WooCommerce integration - all from a single WordPress plugin.
If your business needs a learning platform that fits your exact requirements rather than forcing you into a template, get in touch.
Need a custom plugin or platform?
We build WordPress plugins, WooCommerce integrations, and custom web applications.