From One Repo to Several—and Back Again
Several years into developing leoloso/PoP, a WordPress-based project, its hosting strategy has shifted four times. Each change followed a major re-architecture of the codebase itself, and each setup solved the problems of the previous one while introducing its own constraints. The path: a single repo for a monolith, one repo per package, a monorepo, and finally a multi-monorepo that chains public and private repositories together.
Stage 1: A Single Repo for Ten Sites
The project began as one WordPress site—a theme plus several plugins, all in one repo. When a second site needed similar functionality, the fastest approach was to duplicate the theme and add custom plugins. Those duplicates quickly added up; the repo eventually held code for roughly 10 sites and thousands of files.
That approach made spinning up new sites trivial but made changes painful. A single tweak meant searching for the same string across a decade of sites. Copy, paste, search, and replace became routine. The setup didn't scale, and it was time to structure the code as proper PHP packages.
Stage 2: Multirepo, One Package per Repo
Over the next couple of years, the application was fully decomposed into PHP packages managed through Composer and dependency injection. Publishing to Packagist introduced a hard constraint: each package needs its own composer.json at the root of its repository. That ruling out hosting multiple packages in a single repo.
So leoloso/PoP was split across an organization of repositories—getpop/root, getpop/component-model, getpop/engine, and many others. Because the code was also decoupled from WordPress (to keep it CMS-agnostic), the project ended up with over 200 granular packages.
Multirepo management didn't scale to that size. Every package must be versioned, and each version depends on specific versions of other packages. Setting up pull requests requires configuring composer.json in every package to reference the appropriate development branch. In practice, that friction led to skipping feature branches entirely and pointing all packages to dev-master.
Tools like meta can synchronize commits across multiple repositories, but they don't solve dependency versioning within composer.json. The multirepo solved modularity but introduced heavy coordination overhead.
Stage 3: The Monorepo
Moving all packages into a single monorepo addressed the versioning pain. All packages can be versioned and tagged together, and pull requests that touch many packages across the codebase are handled in one place.
Packagist's constraint still applies, so the monorepo decouples development from distribution: source code lives in the monorepo, while separate repositories—one per package—are used to publish releases to Packagist.
Switching leoloso/PoP to a monorepo involved several steps:
- Creating a folder hierarchy under
layers/for broader projects, with subfolders likepackages/,plugins/, andclients/for categories. - Copying source code from each existing repository (e.g., from
getpop/enginetolayers/Engine/packages/engine). History wasn't preserved; tools likehraban/tomonoorshopsys/monorepo-toolsexist for those who need it. - Marking all downstream repositories as read-only by prefixing their descriptions with
[READ ONLY], done in bulk via GitHub's GraphQL API. - Setting up tooling to split the monorepo back into downstream repos whenever a pull request merges. The chosen tool was Symplify's Monorepo Builder, which is PHP-based and extensible.
The monorepo dramatically improved development speed. Refactoring across 200 packages feels like working with one repository. It also enables releasing multiple WordPress plugins at once. A GitHub Actions workflow, configured through a custom PHP command (plugin-config-entries-json), generates all plugins on release.
Where the Monorepo Falls Short
Listed downsides of the monorepo include:
- A single license applies to the whole repo. Previously, libraries were MIT and the plugin GPLv2; simplification forced everything to GPLv2.
- Large amounts of code, documentation, and issues from different projects can confuse contributors.
- Tagging versions every package even when nothing in that package changed. The Monorepo Builder doesn't handle this automatically, though other projects (like Symfony) have solved it.
- Issue management needs strict labeling to keep projects separate.
The dealbreaker wasn't any of these. The monorepo can't mix public and private code. Planning a "PRO" version of the plugin that needs to live in a private repo—while reusing the same CI workflows and PHP configuration—required a different arrangement that didn't rely on copying files around manually.
Stage 4: Multi-Monorepo
The multi-monorepo links two monorepos via Git submodules. The public leoloso/PoP acts as an upstream repo; the private leoloso/GraphQLAPI-PRO embeds it under submodules/PoP as a submodule. This arrangement gives the private repo access to every file in the public one—scripts, source code, configuration—and lets it extend that code for the PRO plugin.
GitHub Actions, however, only loads workflows from .github/workflows at the root, not those nested under submodules/. So workflows must be copied into the downstream repo. A Composer script handles that copy, keeping the upstream files as the single source of truth.
The approach has some leaks. Any downstream checkout needs the --recurse-submodules flag. To keep workflows identical across both repos, the value for submodules is injected via an environment variable (CHECKOUT_SUBMODULES); it's empty for upstream and changed to "recursive" when copying workflows downstream. A contributor to the public repo could innocently remove that seemingly redundant line and break the private setup—there's no way for them to know about the downstream dependency.
No Final Destination
The repo hosting scheme evolved through four phases—single repo for the monolith, multirepo for package isolation, monorepo for manageable versioning, multi-monorepo for shared public/private code. There's no universal best practice; suitability depends on context. And this context keeps changing: if access needs ever split further—contractors seeing one private plugin but not others—the multi-monorepo may also need to be revisited.



