Skip to content
+40 754.636.306 Start a project RO
All posts
Engineering 12 min read Published March 1, 2026 Updated April 21, 2026

Why off-the-shelf WordPress LMS plugins failed us (case study)

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.

3.12 Current version
86 Security issues resolved
40+ Service methods
8 Automated emails

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 enrollments
  • Progress_Service - tracking per-module and per-course completion
  • Homework_Service - submission, review, file management
  • Access_Service - determining who can see what
  • Tutoring_Service - session scheduling and lifecycle
  • Quiz_Service - attempts, scoring, anti-cheat measures
  • Diploma_Service - generation, secure URLs, template rendering
  • Rating_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.

Lesson learned

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:

  1. 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.
  2. Tokenize all file access. Homework files are stored in a protected directory with .htaccess deny 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.

Lesson learned

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 IN query 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:

  1. 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.
  2. 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.

More insights

Strategy 14 min read May 23, 2026

What's the Best AI Builder in 2026? We Benchmarked Websites, Stores, Apps, and Brand Tools

We benchmarked 20+ AI builders across four categories: websites, e-commerce, apps, and brand tools. Real winners (Framer, Shopify, Lovable, Brandmark), a six-point scorecard, the 2026 agentic-commerce shift, and the one pattern every ranking hides. A data-heavy follow-up to our AI website builder analysis.

Read article
Strategy 15 min read May 21, 2026

The 2026 GEO Audit: 47 Checks That Get You Cited by ChatGPT, Perplexity, and AI Overviews

AI-referred sessions grew 527% in 2025. The 47-point GEO audit we run on every client: llms.txt, crawlers, schema, extractability, authority, freshness, and AI visibility tracking. Bridges the intro to AI discoverability and the llms.txt case study.

Read article
Marketing 15 min read May 5, 2026

What You Actually Get From a Google Ads Agency in 2026: The Full Scope, the Real ROAS, and Why It Costs What It Costs

Inside a Google Ads agency engagement in 2026: custom data layer, Consent Mode v2, feed engineering, product segmentation, copy and creative, AI-assisted optimization, and the contribution-margin ROAS over 6:1 we hold for most accounts.

Read article
Marketing 16 min read April 26, 2026

Google Ads for E-Commerce in 2026: The Honest Playbook for Performance Max, AI Max, and the Channels That Actually Pay Back

AI Max went GA in April. DSA sunsets in September. Performance Max now runs 67% of Shopping spend. We break down what actually works for Google Ads in e-commerce in 2026: feed engineering, PMax structure, realistic ROAS benchmarks, EU Consent Mode v2, and the audit framework we run on every account.

Read article
Marketing 10 min read April 21, 2026

How to Choose a PPC Agency in Romania in 2026

How to choose a PPC agency in Romania in 2026: what to ask, what to avoid, realistic pricing, and a reusable scorecard.

Read article
Strategy 13 min read April 13, 2026

Vibe Coding vs Hiring a Web Development Agency in 2026: The Honest Reality

Vibe coding ships in an afternoon. Then 45% of the code fails OWASP tests. We compare AI coding vs hiring an agency in 2026 with real data, 3-year costs, and the rescue work nobody mentions.

Read article
Strategy 11 min read March 28, 2026

Building Your Own Website with AI: What the Data Actually Says

AI website builders are a $6.3 billion market. They also have an 80% failure rate. We break down SEO penalties, hidden costs, security risks, and conversion gaps with real data, and show where AI actually delivers value.

Read article
E-commerce 14 min read March 25, 2026

WooCommerce in 2026: Pros, Cons, and Alternatives Compared

WooCommerce is free to install and powers 33% of online stores. We compare it against Shopify, BigCommerce, Magento, and 5 more platforms on total cost of ownership, performance benchmarks, and real-world fit for every budget.

Read article
Strategy 11 min read March 24, 2026

Beyond WordPress: the best alternatives in 2026 and when to use them

14 WordPress alternatives compared head-to-head: Webflow, Framer, Wix, Shopify, Ghost, Astro, Hugo and more. WordPress CMS market share dropped from 65.2% to 60.2% while SaaS builders grew 32.6% YoY. Real performance benchmarks, honest pricing, and a decision framework.

Read article
Strategy 8 min read March 21, 2026

Making Your Website Visible to AI: llms.txt and the New Discovery Layer

AI-referred website sessions grew 527% in 2025. When someone asks ChatGPT to recommend a business, will yours appear? llms.txt, structured data, and robots.txt rules are the new discovery layer. Here is how to implement it.

Read article
Strategy 9 min read March 18, 2026

AI in Web Development: the tool, not the replacement

Vibe coding ships fast and breaks everything. 45% of AI-generated code contains security flaws. AI is the most powerful tool in a developer's arsenal - but only in the right hands. Here is what the data says, what we have seen, and why human engineering still wins.

Read article
Performance 7 min read March 16, 2026

Optimizing Three.js for a Perfect PageSpeed Score

How we optimized a 520KB Three.js WebGL background to score 100 on mobile PageSpeed without sacrificing a single frame of animation. Dynamic imports, idle detection, and the art of yielding to the main thread.

Read article
Strategy 10 min read March 14, 2026

WordPress in 2026: Still Dominant at 43.5%. Here's Why (And What AI Changes)

Yes, WordPress still powers 43.5% of all websites in 2026. WooCommerce processes $35 billion in annual sales across 6.5 million stores. With WordPress 7.0 bringing native AI, the platform is accelerating, not slowing. Here is what the market share numbers say, and why we still build on it.

Read article
Performance 4 min read April 10, 2025

Optimizing Performance Without Compromise

Core Web Vitals, lazy loading, CDN, image compression - balancing performance with conversion tools like heatmaps and A/B testing.

Read article
Business 11 min read March 15, 2025

Best Ways to Get a Fair Quote for Your Website

Website quotes range from €500 to €50,000. We break down the 9 factors that drive fair pricing, with DO/DON'T lists, real scenarios, and a ready-to-use checklist for your next agency conversation.

Read article