php

Building a modular application framework on top of Laravel

A technical look at how I structured mods as a modular application framework on top of Laravel, including discovery, theming, and package splitting.

Last year I wrote about why I started the mods experiment. This is the more technical version — implementation decisions, what held up under pressure, and what had to be rebuilt.

By 2018 I had been thinking about modular application architecture long enough that the interesting part was no longer the vision. It was the accumulation of specific tradeoffs: what is discoverable, what breaks load order, where the coupling creeps back in, how presentation concerns propagate across module boundaries. The problems that make module systems hard are not philosophy problems. They are engineering problems with concrete forms, and the only way to find them is to build something and watch it resist you.

Module discovery and registration

The first real problem in any modular framework is discovery. The application needs to know where modules live, which ones are enabled, how they boot, and in what order.

Laravel already has service providers, and that gave me something real to work from. Rather than inventing a parallel bootstrapping lifecycle, mods treats modules as structured units that register their own service providers. The module has a defined home, it registers itself through familiar Laravel mechanisms, and the application boot process stays coherent because it is still fundamentally the same process — just organized at the module level.

This kept the modular layer additive rather than antagonistic. You can add mods to an existing Laravel application without rewriting how it boots. That was important. If the module system required abandoning what makes Laravel good, it would not be worth building.

The tricky question was explicitness. Automatic discovery is appealing until it becomes mysterious. When something boots unexpectedly or a module appears in the wrong order, you want to be able to trace why. I wanted modules to be easy to register while keeping the application boot path traceable — a balance that kept pushing back against fully automatic scanning.

What a module actually owns

For modularity to mean something in practice, a module needs to own more than PHP classes. Routes, views, configuration, providers, and sometimes assets — a module that only contains business logic is not yet a proper architectural boundary. It is a namespace convention with extra steps.

Laravel already supports route loading, view namespacing, and config merging well. The work in mods was making those features behave naturally at module scope, so that each module feels internally complete without the application-level configuration becoming a registration ceremony. A module declaring its own routes in its own directory, resolving its own views, and providing its own config defaults is a different thing from a service provider that imports paths from scattered locations.

Once a module can carry its own presentation and configuration concerns cleanly, application growth stops happening only in the global app/ folder. It happens in the right module, in the right place. The application grows by adding or deepening modules rather than by making one folder denser until it stops making sense.

Dependency direction between modules

Modularity is not only a folder problem. It is a dependency problem. Two modules can look clean on disk and still be tightly coupled in code if they call each other’s implementations directly everywhere. Clean structure does not prevent coupling. Only designed communication paths do.

For mods, this meant thinking carefully about how modules talk to each other. Direct calls are the simplest option — one module resolves another service and calls it. Honest, easy to trace, and often the right answer for stable dependencies between modules.

Where that starts failing is in workflows that cross module boundaries without establishing a permanent contract: notifying other modules about state changes, or letting multiple modules participate in a shared behavior without requiring them to know about each other. Events work well in those cases. A module fires an event and does not care who handles it. Other modules hook in independently.

The lesson I kept relearning: there is no universal answer between events and direct calls. Using events for everything produces indirection without benefit — code that is hard to trace because the flow is distributed across listeners for no architectural reason. The useful rule is closer to: direct calls where the dependency is real and intended to be stable, events where the system benefits from letting other parts participate without knowing who they are.

View resolution and theming across module boundaries

Theming is where modular architecture becomes most demanding.

It is one thing to say a module has its own views. It is another to define how those views are resolved when a module provides defaults, a theme wants to override them, and an application-level customization wants to change presentation without rewriting the module. View resolution precedence — which template wins when multiple candidates exist — seems like a detail until it breaks. When it breaks, presentation behavior becomes unpredictable in ways that are hard to debug.

My earlier Magento work stayed relevant here. Magento’s layout and theme systems are not simple, but they take presentation indirection seriously in a way that most PHP frameworks do not. The useful idea from that experience is not the specific mechanism — XML layout instructions are their own kind of pain — but the underlying principle: presentation should be overridable through structure rather than through copied templates scattered across the application. Override by placement, not by file duplication.

In mods, this meant designing a view resolution chain that understood module boundaries explicitly. When resolving a view, the system should know whether to look in the module first or in the theme first, how to fall back gracefully when neither provides what is needed, and how much of this the module should control versus how much the framework should mediate.

Those three questions create tension with each other. A module that fully controls its own view resolution is harder for themes to override. A theme that takes full precedence makes it difficult for modules to provide sensible defaults. This was harder than I expected going in.

The lesson that applies beyond theming: in modular systems, presentation concerns are not separate from architecture. They are part of it. How views are resolved and overridden is a design decision with the same weight as how dependencies are expressed between modules.

Subtree splits and package structure

As mods grew, I wanted the ability to publish parts of the framework as independent Composer packages — mods/form, mods/theme, mods/view, mods/foundation. Some components were general enough to be useful outside the mods context.

Subtree splits solve this structurally. Development stays in one repository where everything is cohesive. On release, each logical package directory gets split into its own read-only Composer repository. The consuming project sees separate packages and can require only what it needs.

The appealing part: it mirrors the architecture honestly. Some pieces are core and tightly coupled to the modular lifecycle. Others are general enough to stand alone. Subtree splits let you represent that distinction without maintaining separate repositories from the start, which would add enormous coordination overhead before the project is mature enough to need it.

The harder part is workflow — version alignment between the main repository and split packages, release automation, making sure split packages stay trustworthy for consumers. Mechanical friction, but it adds up when you are the only person maintaining the tooling.

What I had to rebuild

The most useful part of building a framework experiment is finding the places where first instincts were too optimistic.

I underestimated how much supporting structure modularity needs. Discovery and bootstrapping are only the beginning. The harder problems were dependency direction, getting modules to communicate without creating implicit coupling between implementations; theming precedence when multiple override paths apply; and calibrating how much automatic behavior developers can trust before the system starts feeling like a black box.

I also had to become more honest about what should remain Laravel and what should become framework-specific. The modular layer works best when it respects what Laravel already does well and adds a stronger module model on top of it. When the framework layer starts fighting Laravel’s conventions — trying to own too much of the application lifecycle or redefining things Laravel has already defined clearly — the result is a system that is harder to work with than plain Laravel while also not delivering the module benefits.

The clearest lesson from the implementation work: good framework design is not about inventing more system than necessary. It is about being precise about where the new abstraction starts and where it stops. Every decision to extend into territory that the underlying framework already owns well costs more than it earns.

That precision is what I am still working toward. The technical problems are solvable. Knowing exactly which problems are worth solving, and stopping before solving the ones that are not, is the harder part.