Context
WECOZA runs adult education and training across South Africa — the same client group as the Triple E Training site. The operational side is not a content problem. It is agents, learners, classes, clients, sites with a parent-child hierarchy, exams, placements, progressions, attendance, deliveries, collections and QA visits, all of which have to reconcile.
From the WordPress dashboard it looks like an ordinary custom plugin: admin menus, shortcodes, AJAX endpoints, a settings page. Almost none of the logic is there.
The problem
This is the point where most WordPress projects go wrong. The data model is real — dozens of related entities with strict invariants — and the instinct is to model it in custom post types and post meta, then enforce the rules in PHP.
That works until it doesn’t. Post meta has no types, no foreign keys and no constraints, so every rule becomes a function somebody has to remember to call. And the data ends up trapped: it only exists in a shape WordPress understands, which means the CRM, a mobile app or anything else that wants it has to go through WordPress to get it.
The business needed its data to be valid independently of the CMS displaying it.
What I did
The plugin talks to a managed cloud Postgres over PDO, deliberately bypassing $wpdb. Forty-three tables model the operation properly, plus a dedicated wecoza_events sub-schema running a two-phase notification engine.
Cross-table invariants and audit logging are plpgsql trigger functions. They fire whether the write came from the plugin, a script, or someone at a psql prompt — which is the entire point. Re-implementing referential integrity in PHP would have meant rebuilding, badly, something Postgres already does correctly.
That leaves the PHP layer doing what it is genuinely good at: presentation, integration, AJAX round-trips, and CRUD rails. WordPress here is a rapid application framework with the boilerplate already solved, not a blogging platform being bullied into a job it hates.
The schema is maintained as a hand-written DBML file so the client can read the data model without knowing SQL. That matters more than it sounds: fourteen months of requirements arrived as meeting notes, PDFs and spreadsheets rather than tickets, and a readable schema is what keeps a business conversation and a database conversation pointed at the same thing.
The result
An operations platform in daily production use, where the business data is a shared resource rather than a WordPress implementation detail — reusable by a CRM, a web app, or whatever comes next, without going through the CMS to reach it.
The known debt is on a list rather than hidden: the AJAX endpoints are capability-gated but not yet CSRF-nonced, database credentials are still hard-coded rather than read from the settings the UI registers, and a 31,000-line legacy MySQLi portal still sits in-tree as a porting reference. Correctness of the business logic was the priority, and that ordering was the right call — but the list is real and it is written down.


