In Bagisto, 30 of its 42 packages depend on each other in a single group. You cannot change one of them without the others coming along. I measured four more open source Laravel apps split into modules. Every one has the same shape. The smallest group covers 53% of the modules. The largest covers 95%.
None of these apps is broken. They all work. This post is about something else: what folders do and do not do once a codebase grows.
What I measured
| App | Modules built with | Modules | In the big cycle group |
|---|---|---|---|
| Bagisto (e-commerce) | namespaces in packages/Webkul | 42 | 30 (71%) |
| Advising App (student advising) | InterNACHI/modular | 37 | 29 (78%) |
| LaravelEcomm (e-commerce) | nwidart/laravel-modules | 29 | 25 (86%) |
| Krayin CRM (CRM) | namespaces in packages/Webkul | 20 | 19 (95%) |
| Leantime (project management) | plain folders in app/Domain | 45 | 24 (53%) |
A "cycle group" is a set of modules where each one depends on all the others, directly or through the chain. If Sales uses Shop and Shop uses Sales, they form a group of two. Add Checkout using Sales and Shop using Checkout, and the group grows to three.
I measured them with Cordon Modulith, a static analyser I wrote for module boundaries in Laravel. It reads the PHP files and records which module uses which class. It does not boot the app. Each run took less than 7 seconds. The exact commit, the configuration and the steps to repeat each run are on the case study pages.
These are snapshots of how the code is organised, not judgments. None of these projects claims to enforce module boundaries, and many of the dependencies below are deliberate trade-offs.
Two different problems
The tool reports two things, and they behave very differently.
Access to another module's internals. A controller in Shop reads Webkul\Sales\Models\Order directly. Out of the box, the tool only treats Contracts, Events, Data, Enums and Exceptions as public, so this kind of report is very noisy on a first run. Bagisto starts at 919.
Cycles between modules. Sales depends on Shop and Shop depends on Sales, whatever classes they use.
The first problem is mostly configuration. The second one is design.
Most of the first report is configuration
Real apps share things on purpose. Bagisto has a Core package that every other package extends. It reads data through repositories by design. It exposes facades such as Cart. Declaring that brought its access reports from 919 down to 158:
'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions', 'Repositories', 'Facades'], 'modules' => [ 'Core' => ['open' => true], 'DataGrid' => ['open' => true], ], The same reading of each codebase gave similar drops elsewhere. Krayin went from 368 to 52. Leantime went from 443 to 166 after declaring its services. LaravelEcomm halved. Advising App barely moved, from 1,162 to 1,154, because it already uses Contracts as its API.
What remains after that is real and specific. In Bagisto, eight packages build orders through an internal OrderResource class of Sales. In Leantime, a dozen modules skip the service layer and read the Users and Setting repositories directly. These are short, fixable lists.
The cycles do not move
In all five apps, the cycle groups are exactly the same before and after declaring a public API. Declaring an API changes what a module is allowed to touch. It does not change who depends on whom.
Leantime shows it clearly. Its services are public, and some cycles go only through services:
// app/Domain/Menu/Services/Menu.php use Leantime\Domain\Projects\Services\Projects as ProjectService; // app/Domain/Projects/Hxcontrollers/ProjectCard.php use Leantime\Domain\Menu\Services\Menu; Every reference is allowed, and Menu and Projects still need each other.
In Krayin, the smallest cycle fits in two files. Attribute values log their changes with a trait from Activity. That trait checks for attribute values from Attribute. Each module needs the other one to exist.
Bagisto has the one that worries me most. Sales, the domain package, sends a mail class that lives in Shop, the storefront:
// packages/Webkul/Sales/src/Traits/InvoiceReminder.php use Webkul\Shop\Mail\Customer\InvoiceOverdueReminder; Shop uses 11 classes from Sales. The domain depends on the screen that shows it.
"But Eloquent wants both sides"
It does. Eloquent relations are usually defined on both models: hasMany on the parent and belongsTo on the child. LaravelEcomm does exactly that:
// Modules/Product/Models/Product.php return $this->hasMany(Cart::class); // Modules/Cart/Models/Cart.php return $this->belongsTo(Product::class); So a cycle between two modules is normal Laravel. Often it means the two modules are really one. Merge them, or accept it and move on.
The finding here is not a pair. It is a group of 25 or 30 modules where you cannot test, change or extract any one of them without pulling in most of the rest.
That is why the tools that enforce modules outside PHP reject cycles outright. Spring Modulith's verification requires the module graph to be acyclic. Packwerk's validate rejects cycles between packages, and its authors cite Robert Martin's Acyclic Dependencies Principle. Shopify later removed Packwerk's privacy check, because it pulled attention away from the dependency graph, which was the point. Of the two problems above, the graph is the one that lasts.
What I would do with a 30-module group
I would not try to fix it at once. Three steps, in order:
- Freeze it. Record the current violations in a baseline and make CI fail only on new ones. Nothing gets worse from today.
- Declare what is shared on purpose. Kernels, repositories, facades, whatever the code already treats as an API. This clears the access noise so the real list is visible.
- Break one cycle where the direction is wrong. In Bagisto, I would start with
Salessending aShopmail.Salescan fire anInvoiceOverdueevent and letShopdecide which mail to send. Then measure again.
One edge at a time. The group shrinks when the edges that hold it together go away.
Limits
Five apps is a sample, not a census. I picked them because they are open source, modular and large enough to matter. Bagisto and Krayin come from the same company. Three of the five are e-commerce or CRM. The tool does not read Blade templates, so dependencies from views are not counted. And a reference is not always strong coupling. A class_exists() check counts the same as inheritance.
Your turn
If you split a Laravel app into modules, how many of them could you change or remove without touching any other?


