You can build a 41,000-line WordPress plugin, 80+ classes, 26 admin screens, 170+ API endpoints, with zero runtime dependencies. Project Spark proves it, and this post explains the architecture decisions that kept a codebase of that size maintainable by a small team.
Architecture posts usually describe diagrams; this one describes decisions, including two we reversed. Because in a plugin that must run for years on hosting environments you will never see, the architecture that survives is rarely the cleverest one. It is the one whose behavior can be predicted, tested, and explained.
Key takeaway: In WordPress plugin development, every runtime dependency is a supply-chain liability you ship to someone else’s server. A zero-dependency architecture, vanilla PHP, vanilla JavaScript, WordPress core APIs, trades a little convenience for a decade of stability.
The shape of the system
At a high level, Project Spark is:
- ~143 PHP source files, ~41K lines, 82 classes, 25 traits, pure PHP 8, strict types, no Composer packages in production.
- A single orchestrator class that wires every hook, so the entire plugin’s behavior is discoverable from one file.
- Trait composition for the admin AJAX layer: 25 traits handle 108 AJAX endpoints, grouping related handlers without god-classes.
- ES modules bundled by esbuild: admin JavaScript lives in 38 small modules compiled into one minified bundle, modern DX, zero npm packages shipped to production.
- A 16-step setup wizard and a vertical-sidebar dashboard, all rendered server-side. We deliberately removed a lazy-loading AJAX tab system mid-project: ~160 KB of extra HTML was a cheap price for fewer moving parts.
Alongside the browser-facing layer sit two developer surfaces: a REST API with 39 routes for external integrations, and 30 command-line subcommands for scripted operations, imports, exports, audits, cache management. All three surfaces register through the same orchestrator, which matters for reasons that appear below.
Decisions that mattered more than they looked
Zero runtime dependencies is a security feature
No vendored libraries means no CVE monitoring for third-party code, no abandoned-package risk, and no update conflicts on client sites. Dev-time tooling (test frameworks, bundlers, static analysis) lives in dev dependencies only and is stripped from every release build.
The argument for this discipline is stronger for plugins than for almost any other software category. A web application runs on infrastructure its developers control; a distributed plugin runs on thousands of servers alongside arbitrary other plugins, each potentially shipping its own copy of the same libraries at different versions. PHP has no per-plugin isolation: two plugins loading different versions of one library is a conflict lottery. The only guaranteed escape is to load nothing. Every capability the plugin needs, HTTP, encryption, JSON, scheduling, exists in WordPress core or the PHP standard library, maintained by teams far larger than any dependency vendor’s.
The trade-off is honest: some wheels get reinvented, carefully. Writing an integration against a documented core API costs more up front than installing a package. It costs far less over five years of that package’s release cycle, deprecations, and security advisories.
One orchestrator, discoverable wiring
Every action, filter, AJAX handler, REST route, and CLI command registers through a central loader. Nothing hooks itself from a class constructor; nothing registers behavior as a side effect of being loaded. To answer “what does this plugin do when X happens?”, a maintainer reads one file.
This paid off later in an unexpected way: we wrote self-syncing tests that parse the orchestrator and automatically verify every registered callback exists and is public. When a handler is renamed, CI fails before a human ever notices. The first time that parser ran, it exposed an uncomfortable truth: a hand-maintained list elsewhere in the test suite had silently drifted more than fifty entries out of date. Structure you can parse is structure you can enforce, and structure you cannot parse will drift, guaranteed. Part 14 develops this into a full testing method.
Trait composition instead of god-classes
The admin’s 108 AJAX endpoints presented a classic structural dilemma. One controller class with 108 methods is unnavigable. A hundred single-method classes is ceremony without benefit. The middle path: 25 PHP traits, each grouping the handlers for one feature area, imports, locations, redirects, AI operations, audits, composed into a single controller.
Every handler follows the same non-negotiable preamble regardless of which trait it lives in: a request-forgery token check, a capability check, and rate limiting. Uniformity at this layer is itself a security property, and it is machine-verifiable, which is how the test suite guarantees no endpoint ships ungated (Part 13).
Modern JavaScript without shipping node_modules
The admin JavaScript began as one monolithic file and was decomposed, release by release, into 38 ES modules, each small enough to test in isolation, with its own unit tests. A bundler compiles them into a single minified file for production; the bundler itself is a development dependency and ships nowhere. One hard lesson from this migration: the build pipeline must fail loudly when the compiled bundle is older than its sources. We once shipped a release whose bundle predated a fix in the module source, the fix existed, tested and reviewed, and production never executed it. A freshness gate in the build script made that mistake structurally impossible afterwards.
Boring beats clever
Twice we built something clever and later deleted it: a lazy-tab AJAX loader and a script-detection heuristic for brand names. Both worked in the demonstration sense. Both created failure modes that were hard to reproduce and harder to explain, the lazy loader multiplied the ways a dashboard could half-load; the heuristic quietly deleted user content on mixed-script titles (Part 2).
Both were replaced by simpler, dumber designs: render everything inline, never rewrite stored text. The lesson generalizes: in a plugin that must run on thousands of unpredictable hosting environments, predictability is the top feature. Cleverness concentrates risk in exactly the places where remote debugging is hardest.
Render-time responsibility
As established in Part 2, storage is verbatim and presentation logic runs at render time. Architecturally this means the frontend output class owns all transformation, brand suffixes, locale, escaping, and everything upstream is a dumb pipe. The consequence for maintenance is enormous: presentation bugs are fixed in one layer, take effect everywhere instantly, and never require touching stored data.
How the layers stay honest
A structure this size only remains a structure if something enforces it. Three mechanisms do:
- Parse-and-verify tests. Parsers walk the orchestrator’s registrations (browser, REST, and CLI surfaces) and assert every callback exists, is public, and carries its security gates. Renames and omissions fail the build.
- Paired-list symmetry tests. Everything created must be cleaned up: options written at installation are checked against uninstall cleanup; metadata keys written anywhere are checked against the keys read anywhere. Both pairings had drifted before the tests existed; neither can drift now.
- A written scope. The final architectural decision was to stop. The plugin is feature-locked: bug fixes, security patches, and regression tests only (Part 20). Architecture erodes fastest under feature pressure; removing the pressure preserves the structure.
Release discipline: hundreds of small releases
The architecture was built and maintained through hundreds of small, individually verified releases rather than a handful of large ones, and this cadence is itself an architectural decision. Each release passed the same seven gates before shipping: the full PHP test suite, the JavaScript test suite, a static analysis pass, a custom automated audit that checks project-specific rules (security preambles on endpoints, escaping on output, absence of known anti-patterns), syntax linting across every supported PHP version, a documentation-drift detector that fails when the docs’ claims disagree with the code’s facts, and the build itself.
Small releases changed the economics of quality. A defect introduced in a 200-line release is found in minutes because the suspect surface is tiny; the same defect hidden in a 10,000-line release costs an afternoon. Rollback is trivial when each step is small. And the psychological effect matters as much as the mechanical one: when shipping is cheap and safe, improvements actually ship, instead of accumulating in a risky branch that everyone fears merging.
The documentation-drift gate deserves special mention because almost nobody automates it. Project documentation states facts, file counts, endpoint counts, feature descriptions, and facts rot. The drift detector recomputes the real numbers from the codebase on every release and fails the build when the documentation disagrees. Documentation that cannot drift is documentation a maintainer can trust; documentation a maintainer can trust actually gets read.
What I’d tell past me
Start the self-syncing structural tests earlier. Every place where two lists must stay in sync (registered endpoints vs. tests, options created vs. options cleaned up at uninstall, meta keys written vs. read) eventually drifted, until we wrote parsers that enforce the sync automatically. More on that in Part 14.
And distrust your own cleverness sooner. The two deleted systems consumed more engineering time in removal than they had in construction. The questions that would have prevented both: Can a stressed maintainer predict this behavior from reading one file? Can a test verify it without simulating a browser? If either answer is no, simplify before shipping, not after.
Finally, budget for deletion as a first-class activity. The healthiest weeks in this project’s history were the ones where the line count went down, removed features, collapsed duplication, retired workarounds. An architecture is not what you built; it is what remains after you have removed everything the system turned out not to need.
Next: Part 4, Triple-Engine Optimization: SEO + AEO + GEO
FAQ
Should WordPress plugins use Composer dependencies in production?
For distributed plugins, avoid it where feasible. Every runtime dependency adds supply-chain risk, version-conflict potential with other plugins, and update burden. Keep Composer for dev tooling only.
How do I structure a large WordPress plugin?
Use a single orchestrator for hook registration, group related functionality into classes and traits, keep frontend output in a dedicated class, and enforce structure with automated tests that parse your own registration code.
What is trait composition in PHP?
Traits let a class mix in reusable method groups without inheritance. Project Spark uses 25 traits to organize 108 AJAX handlers by feature area while keeping a single controller class.
Is esbuild good for WordPress plugin JavaScript?
Yes, it compiles modern ES modules into a single fast, minified IIFE bundle with no runtime overhead, which suits WordPress’s enqueue model perfectly.
How big can a WordPress plugin get before it becomes unmaintainable?
Size is not the limit, enforcement is. A 41,000-line plugin stays maintainable when hook wiring is centralized, structure is verified by tests that parse the code, and scope is deliberately closed.
What does zero-dependency mean in software?
It means the shipped product includes no third-party libraries at runtime, only the platform’s own APIs. Development tools like bundlers and test frameworks may still be used, but nothing external ships to production.




