Knowledge Direct (KD7) — Multi-Tenant LMS Platform

Knowledge Direct (KD7) is Apti’s flagship learning management system. It’s one codebase serving dozens of enterprise clients, and every one of them gets its own subdomain, database, branding, course catalog and settings. Since 2023 it’s been my baby, end to end: new features, bug fixes, servers, deploys, and the occasional “why is this on fire?!” moment.

Here’s the fun twist. KD7 runs on CentOS 7, PHP 5.6, Phalcon 2, AngularJS 1.7 and MongoDB 3.2, all long past end-of-life, and swapping any of them out is a much bigger conversation than any single feature. So the game is shipping modern, reliable features anyway. That means small, reversible changes, digging all the way to the root cause, and writing things down so every change makes the next one easier.

The lay of the land

KD7 is really three apps in a trench coat. The public app (with the asset server beside it) serves every client’s learner and admin screens (the AngularJS theme ships as its own versioned package) and hands the real work to an internal core API over HTTP. The core API owns the business logic and each client’s dedicated MySQL database.

Anything slow, like publishing a course, enrolling a few hundred people or spinning up a new client, gets tossed onto a job queue for background workers. A small Node.js socket server pushes live notifications back to the browser. And the client is figured out from the web address on every request, so one deployment serves everybody while keeping their data, files and settings neatly apart.

Knowledge Direct platform architecture Browsers send requests to the public app, which resolves the tenant from the hostname. The public app calls the internal core API over HTTP; the core API reads and writes each tenant's dedicated MySQL database. The public app also keeps sessions and xAPI state in MongoDB. Long-running work is placed on a job queue and processed by background workers, which also write through to the tenant databases. For live notifications, the app publishes a message to a socket server, which pushes it to the browser over a WebSocket. HTTPS HTTP ZMQ WebSocket Public app PHP / Phalcon AngularJS theme tenant from hostname Browser learner & admin UI Core API business logic MongoDB sessions, xAPI state Job queue Beanstalkd tubes Socket server Node.js / Socket.io MySQL one DB per tenant Workers publish, enroll…
Simplified request, job and notification paths. Every client shares the code; each one gets its own database and file storage.

Living with a vintage stack

On KD7, upgrading one piece usually means upgrading all of them, so “just update the library” is almost never on the menu. When Algolia’s client library started failing TLS handshakes against the server’s old OpenSSL, there was no compatible version to move to. So I vendored the client, patched it myself and wrote down exactly why. When Braintree’s SDK started rejecting certificates and blocking real payments, I got a vendor patch in under pressure. When Zoom retired its JWT API, I migrated us before the deadline instead of after the outage.

The other secret weapon is documentation. A lot of how KD7 worked lived only in people’s heads, so I keep living docs for the architecture, the API and the infrastructure. Those docs make careful changes cheap. They’re also the context I feed Claude, which I use every day for diagnosis and scaffolding. I still own every review and every test.

Some favorites

Course Styles: every course, on brand

It started with a readability complaint: the course player’s gray-on-gray text was hard to read, and clients asked for black on gray instead. Fixing one color for everyone raised a better question: why not let each client set their own? The course player’s colors already lived in SCSS variables, so instead of reinventing the styling I exposed those same colors as settings: a default palette for each client, plus overrides for each course.

Every course is published as a self-contained package, so at publish time the chosen colors get injected back in as CSS custom properties, and the player’s styles read those variables where they used to read hard-coded Sass values. Same stylesheet, a different look for every client. All the defaults live in one place, so adding a new themeable color is a quick checklist and not an archaeology dig.

  • SCORM
  • xAPI
  • CSS
  • Sass
  • Multi-Tenant Architecture

Bulk enrollment, rebuilt in eight phases

Enrolling thousands of learners at once (one client’s member group alone is 4,780) used to be a black box. One bad row could sink the whole batch, admins had no idea how far along it was, and a big job could hog the queue and hold up everything else.

Over eight small phases, each one shippable on its own, I isolated failures per learner, gave big jobs their own queue, batched the database writes and added a live progress bar with a per-learner results summary. Now it’s boring, which is exactly what you want.

The full story

  • Beanstalkd
  • PHP
  • Angular
  • Performance Metrics

A whole new look for learners

I led a months-long redesign of the main learner theme (navigation, dashboard, account pages and storefront) that touched roughly 700 files. Along the way I pulled a shared AngularJS component library out of a lot of copy-pasted markup, tidied colors, spacing and type into a real design system, moved 100+ files over to Phosphor Icons, and swapped hard-coded text for translation keys.

Under the hood I rebuilt the Webpack config (vendor splitting, custom chunks, slimmer locale data) and updated the DI-annotation tooling so ES6-class components survive transpilation.

  • Angular
  • UI/UX
  • Webpack
  • i18n

Plugging security holes

I found that a deactivated user could bring their own account back to life just by asking for a password reset. Oops! The fix touched both apps: new checks in the API, revoked tokens, and sessions that close the moment someone is deactivated.

I also toughened up self-registration with domain-based blocking, reCAPTCHA / Cloudflare Turnstile and required email confirmation before an account goes live.

  • Security Audits
  • PHP
  • SSO/Identity

Data that heals itself

A bunch of long-standing bugs turned out to be data quietly drifting out of sync. Learning paths never auto-completed if a course was added after someone enrolled. Deactivated users still showed up as “active” because two status fields disagreed.

Instead of a migration and a pile of manual cleanup, I added lightweight checks that spot the drift and fix it when the page loads, at basically no cost. When the cause was structural (deleting a learning path never cleaned up its course links), I fixed the cascade itself, and that whole family of bugs just went away.

  • Data Architecture
  • PHP
  • Incident Response

Reports people actually want

A multi-district education client was building their monthly reports by hand in Airtable. I built them a reporting system with saved, filtered views; a CSV export laid out district → school → course → module → learner to match their spreadsheet exactly; and scheduled delivery, so every principal and district rep automatically gets just their own data each month. No more report day!

  • Data Architecture
  • PHP
  • Requirements Gathering

Goodies for course authors

One-click copies of assessments, surveys and resource libraries (questions and cross-references included). Asset tracking that shows exactly which courses use a file, so stale ones can finally be deleted. A trackable document-link block. A compact certificate picker. And a theme manager that had lived in the codebase for years without ever actually working. It works now!

  • Angular
  • PHP
  • UI/UX

Integrations

An in-app Zoom webinar player built on the Meeting SDK, a Vimeo upload flow, a third-party single sign-on provider (plus reconciling and backfilling member identity records), and a Node.js CLI for importing and reconciling a partner’s member and credit data.

  • Third-Party API Integration
  • SSO/Identity
  • Node.js

Bulk enrollment: then and now

Bulk enrollment before and after the rebuild Before: an admin's list of learners became one job on the shared queue, processed all-or-nothing, so a single bad row failed the whole batch, large jobs delayed other work, and the admin saw no progress. After: the job goes to a dedicated queue; a worker enrolls each learner in isolation so failures are recorded per learner, writes to the database in batches, and reports progress to a live progress bar, finishing with a per-learner results summary. Before After Upload list up to 4,780 learners One job, shared queue all-or-nothing ✗ One bad row fails the batch big jobs block other work no progress, no per-learner errors Upload list up to 4,780 learners Dedicated queue Worker each learner isolated Batched DB writes fewer round-trips Live progress bar ✓ Results summary per-learner successes & failures
The rebuilt job keeps each learner’s failure to itself, writes in batches, and keeps the admin posted as it goes.

Bug stories I like telling

Most bug reports show up as one symptom from one client. The fun ones are where that symptom leads somewhere much bigger:

  • People named “D’Andrea” couldn’t be found by name, because HTML entities never got decoded before searching. I fixed it once, in the shared search code the whole API uses.
  • Admins couldn’t see the access codes they’d just bought. Two different code flows set two different “owner” fields, and the admin and learner screens each checked a different one.
  • “Message everyone in this course” was quietly messaging… everyone. A missing filter plus a reference to a property that didn’t exist will do that.
  • A recurring course-player crash in Bugsnag came from learner progress that outlived a content republish. I guarded against it defensively, since every course bakes in its own copy of the player.
  • Exporting a report to CSV only exported the page you were looking at.
  • A report filter that rebuilt its arrays on every AngularJS digest cycle, which sent the page into a re-render spiral.

Looking back

KD7 taught me to respect an old platform instead of grumbling about it. It makes money every single day, and every change should leave it a little better understood and a little more reliable than before. It also made a pretty great case for replacing it, so I wrote up a full modernization plan. That plan became KD8.

Stack & skills

  • PHP
  • Phalcon
  • Angular
  • MySQL
  • MongoDB
  • Beanstalkd
  • Node.js
  • Socket.io
  • Algolia
  • Jenkins
  • AWS
  • Multi-Tenant Architecture
  • SSO/Identity
  • xAPI
  • SCORM
  • WebSockets
  • Redis
  • Elasticsearch
  • Nginx