The Non-Mono Monorepo
Published 2 days ago
I did not set out to build a monorepo.
For years, my open-source packages were exactly what you would expect them to be: separate repositories, separate Composer packages, separate release histories, and separate projects that happened to be maintained by the same person. That separation was useful. Each package could evolve on its own, support the versions it needed to support, and be installed without carrying the weight of everything else I maintained.
The problem was never that the repositories were separate.
The problem was that maintaining them separately eventually stopped matching the way I thought about them.
As the number of packages grew, the repeated work became harder to ignore. Composer configuration drifted. GitHub Actions evolved at different speeds. Testing conventions improved in one package but remained stale in another. Some repositories gained better development tooling while others still depended on scratch applications and old habits. A change that was conceptually about the ecosystem became a sequence of nearly identical changes performed one repository at a time.
None of that made the individual repositories wrong.
It made the boundary around the work feel wrong.
A fleet of independent packages
There is a strong argument for keeping packages independent.
A package should be understandable on its own. Its repository should contain its source, tests, documentation, development environment, and release history. A consumer installing one package should not need to understand the rest of the ecosystem. A change to one package should not require publishing every package around it.
Those properties matter to me, especially for open source.
They are also the reason a traditional monorepo never felt like an obvious answer. Moving everything into one Git repository would make cross-package changes easier, but it would change other things I did not actually want to change. Repository boundaries would disappear. Release history would become shared infrastructure. CI would need to understand which packages had changed. Tooling would have to compensate for the fact that independently distributed packages now lived inside one source repository.
That can be a perfectly good tradeoff.
It just was not the tradeoff I needed.
What I wanted was much smaller.
I wanted the packages to remain independent when they were being developed, tested, released, and consumed individually.
I also wanted to be able to treat them as one system when the work crossed repository boundaries.
Those two goals sound contradictory only if the Git repository is the thing that defines the boundary of the system.
Increasingly, I do not think it has to.
The directory became the system
My packages already lived together on disk.
oss/
├── curator/
├── mason/
├── recently/
├── content-faker/
├── ...
For a long time, that directory was organizationally boring. It was just where I cloned repositories.
The repositories were the real projects.
The directory was storage.
That distinction started to change as more of my work involved coding agents.
An agent working inside curator/ should think about Curator. It should read Curator's instructions, understand Curator's dependencies, run Curator's tests, and avoid making assumptions about the rest of my packages.
But some tasks are not Curator tasks.
Updating a shared CI convention is a fleet task. Auditing Laravel support across every package is a fleet task. Applying the same Workbench standard across twenty repositories is a fleet task. Checking which packages still support an older PHP version is a fleet task.
For those jobs, opening repositories one at a time is an artificial constraint.
The interesting project is not any single repository.
It is the collection.
That realization changed the role of the oss/ directory. It stopped being merely the place where repositories happened to live and became an orchestration boundary.
The packages did not move into a monorepo.
The directory around them became useful.
Repository boundaries and work boundaries are different
Git repositories answer important questions.
Where is this code versioned? What gets released together? What history belongs together? What can a contributor clone independently?
Those are not the only questions involved in maintaining software.
There is another set of questions.
Which repositories follow the same standard? Which packages support the same framework versions? Which projects are active? Which ones are experimental? Which ones are legacy but still need maintenance? Which change needs to be propagated across the fleet?
Those questions exist above the repository.
Trying to force both kinds of responsibility into the same boundary creates unnecessary pressure. If I want fleet-wide coordination, I do not necessarily need fleet-wide version control. If I want an agent to understand twenty repositories as one maintenance surface, I do not necessarily need to put those twenty repositories into one Git history.
I only need a place where the relationships between them can be expressed.
That is what the orchestration layer provides.
Context becomes part of the architecture
Once the directory became an intentional project, it needed more than a collection of folders.
It needed context.
At the fleet level, an agent should be able to understand things that do not belong in any individual package:
oss/
├── AGENTS.md
├── fleet/
│ └── ...
├── curator/
├── mason/
├── recently/
└── ...
The exact files are less important than the separation they represent.
The root can describe the ecosystem: how repositories are classified, which conventions apply to all of them, how cross-repository work should be approached, and what an agent is allowed to change.
Each repository can still own its own instructions.
That matters because fleet context should not flatten repository context.
Curator may have filesystem concerns that Mason does not. A Filament plugin may need a browser-accessible Workbench while a small Laravel utility does not. One package may support multiple Filament versions on the same branch while another uses separate major branches.
The fleet should know that those differences exist.
It should not erase them.
This is where orchestration begins to feel meaningfully different from a monorepo. A monorepo often creates one development surface containing many packages. This approach creates a development surface around many repositories.
The repositories remain authoritative for themselves.
The orchestration layer is authoritative for the work that spans them.
Standards made the shape more obvious
The need became especially clear while standardizing package development environments.
I wanted every applicable Laravel and Filament package to have a predictable Workbench. The goal was simple enough: clone the package, run Composer, start the development server, and have a realistic environment in which the package could actually be exercised.
That sounds like a repository-level concern, and implementation absolutely is.
But deciding how to roll that convention across an entire ecosystem is not.
The standard belongs above any one repository. The audit belongs above any one repository. The migration process belongs above any one repository. Individual packages then implement the standard according to their actual capabilities.
That distinction turned out to be useful.
Instead of assuming every package looked like the first successful migration, the fleet could classify repositories and reason about the differences. A media-heavy Filament plugin, a UI package, and a plain Laravel library could all conform to the same development philosophy without being forced into identical implementations.
The orchestration layer provided consistency.
The repositories preserved specificity.
That is exactly the balance I had been looking for.
A monorepo is one way to create proximity
One reason monorepos are attractive is proximity.
When related software lives together, cross-cutting work becomes easier to see. Tooling can inspect the whole system. Changes can be coordinated. Shared conventions have somewhere obvious to live.
But Git is not the only mechanism capable of creating that proximity.
My repositories already have it on disk.
An orchestration project can use that physical proximity without changing the version-control model underneath it.
That means I can do something like:
work on curator
and give an agent a narrow repository context.
Or I can do something like:
audit the oss fleet for packages still supporting Laravel 11
and give the agent the directory as its context.
The same repositories participate in both workflows.
Only the boundary of the task changes.
That feels obvious now, but it is a surprisingly powerful distinction.
The unit of version control does not have to be the unit of reasoning.
The non-mono monorepo
I keep coming back to the phrase non-mono monorepo because it describes the shape better than it probably should.
It has many of the ergonomic properties I would want from a monorepo. The projects live together. Fleet-wide tooling can see them together. Shared standards can be applied across them. An agent can operate at the ecosystem level. Cross-repository maintenance does not require pretending that each repository exists in isolation.
But nothing has actually become mono.
The Git repositories remain separate.
The Composer packages remain separate.
The release cycles remain separate.
The dependency graphs remain real dependency graphs rather than internal workspace relationships.
Each package can still be cloned, understood, developed, tested, and released independently.
The orchestration layer does not replace those boundaries.
It gives me another boundary above them.
That may be the important part.
Software architecture tends to make us think in terms of choosing the correct container. Monolith or services. Monorepo or polyrepo. Package or application. We spend a lot of time asking which box the code should live inside.
Sometimes the useful answer is that different kinds of work need different boxes.
For source ownership and releases, I want independent repositories.
For ecosystem maintenance, I want a fleet.
For day-to-day package development, I want one package and its Workbench.
For a cross-cutting migration, I want the whole oss/ directory.
None of those views invalidates the others.
They are different projections of the same system.
The repository was never the whole project
This is probably the larger lesson I took from the process.
I used to think of the repository as the natural boundary of development. You opened a repository, worked on the repository, committed the repository, and moved on to the next one.
That still makes sense when the work belongs to one package.
It makes much less sense when you are maintaining an ecosystem.
Once enough independent projects share conventions, compatibility concerns, tooling, and maintenance practices, something exists above them whether you name it or not. You can continue managing that layer manually, repository by repository, or you can give it a place to exist.
I did not need a monorepo to do that.
I needed orchestration.
The oss/ directory is still full of independent Git repositories. Nothing about how those packages are installed or released has fundamentally changed.
What changed is that I no longer pretend the repositories are the only meaningful boundary.
Sometimes I am maintaining Curator.
Sometimes I am maintaining Mason.
Sometimes I am maintaining the fleet.
Being able to express all three turns out to be more useful to me than forcing them into one repository just so I can call the result a monorepo.