Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to Cordon Modulith are documented here. The project follows

## [Unreleased]

### Added
- Case studies in the documentation: Cordon run on Bagisto (namespaces), Advising App (InterNACHI/modular) and LaravelEcomm (nwidart/laravel-modules), with the configuration used for each and what remained after declaring their public API.

### Changed
- When more modules are tangled than the reported cycle shows, the `cycles` message now says so ("are tangled in dependency cycles, such as A -> B -> A") instead of reading as a single cycle. In real apps the group often spans 25 to 30 modules while the example cycle has two. The violation's target, and so existing baselines, are unchanged.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ x [cycles]
2 boundary violations.
```

**Tried on real apps:** on [Bagisto](https://github.com/bagisto/bagisto) (42 packages, 2,000 files) Cordon found 158 internal accesses and 30 packages tangled in dependency cycles, in 6 seconds and with no false positives. See the [case studies](https://chrisabner.github.io/Cordon-Modulith/case-studies/).

## Requirements

- PHP 8.3+
Expand Down
10 changes: 10 additions & 0 deletions docs-site/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
nav: [
{ text: 'Guide', link: '/guide/introduction' },
{ text: 'Recipes', link: '/recipes/expose-a-service' },
{ text: 'Case studies', link: '/case-studies/' },
{ text: 'Comparison', link: '/comparison' },
{ text: 'Changelog', link: 'https://github.com/ChrisAbner/Cordon-Modulith/blob/main/CHANGELOG.md' },
],
Expand Down Expand Up @@ -54,6 +55,15 @@ export default defineConfig({
{ text: 'Replace a cross-module Eloquent relation', link: '/recipes/cross-module-relations' },
],
},
{
text: 'Case studies',
items: [
{ text: 'Overview', link: '/case-studies/' },
{ text: 'Bagisto', link: '/case-studies/bagisto' },
{ text: 'Advising App', link: '/case-studies/advisingapp' },
{ text: 'LaravelEcomm', link: '/case-studies/laravelecomm' },
],
},
{
text: 'More',
items: [
Expand Down
51 changes: 51 additions & 0 deletions docs-site/case-studies/advisingapp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Advising App

[Advising App](https://github.com/canyongbs/advisingapp) by Canyon GBS is a student advising and CRM platform for colleges, built with Laravel and Filament. It is organised with [InterNACHI/modular](https://github.com/InterNACHI/modular): 37 modules under `app-modules/`, from `prospect` and `student-data-model` to `ai`, `workflow` and several integrations.

**Analysed:** commit [`0ced55f`](https://github.com/canyongbs/advisingapp/tree/0ced55f96e13d65ba21a3845fb2736522fdbca3b), October 2026, Laravel 13.39, InterNACHI/modular 3.0.2, PHP 8.4. Cordon detected the modules on its own; no resolver configuration was needed.

## Results

| | Default config | Public API declared |
|---|---|---|
| Modules | 37 | 37 |
| Files analysed | 2,463 | 2,463 |
| Cross-module references | 2,774 | 2,774 |
| `internal_access` | 1,162 | 1,154 |
| `cycles` | 1 group of 29 modules | 1 group of 29 modules |
| Time / memory | 3.8 s / 152 MB | 4.5 s / 152 MB |

The only addition was `DataTransferObjects` as a public namespace (8 reports). Unlike the other two apps, Advising App has no shared kernel, and the default public namespaces already match how it is organised: its contracts live in `Contracts` folders (14 of them, in 8 modules), which Cordon treats as public at any depth.

```php
'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions', 'DataTransferObjects'],
```

## Two models carry the product

28% of the reports point at just two classes:

| Class | References | From modules |
|---|---|---|
| `StudentDataModel\Models\Student` | 162 | 21 |
| `Prospect\Models\Prospect` | 162 | 20 |
| `Audit\Models\Concerns\Auditable` | 61 | 16 |
| `Team\Models\Department` | 23 | 7 |

Students and prospects are what the product is about, so it is no surprise that reports, engagements, forms, alerts and workflows all reach for them. What is interesting is that the app already has the abstraction: `StudentDataModel\Models\Contracts\Educatable`, implemented by both models and public under Cordon's defaults. Much of the code still names the concrete classes:

```php
// app-modules/student-data-model/src/Actions/ResolveEducatableFromEmail.php
public function __invoke(?string $email, bool $includingArchived = false): Student | Prospect | null

// app-modules/engagement/src/Filament/Pages/ViewEngagementResponse.php
if (($this->record->sender instanceof Prospect || $this->record->sender instanceof Student) && ...
```

Cordon gives the team a list of exactly where to move from `Student | Prospect` to `Educatable`, module by module.

`Auditable` is a trait that 16 modules use on their models. That is a deliberate shared mechanism, and declaring it (`'audit' => ['public' => ['Models\\Concerns\\Auditable']]`) would be a one-line decision.

## Cycles

29 of the 37 modules belong to one group of mutually dependent modules, with 54 direct back-and-forth pairs, for example `ai` and `assistant`. The heaviest dependencies all go towards `student-data-model` and `prospect`: `report` (51 references), `engagement` (41) and `form` (36 and 39). Those two modules also depend back on most of their users: `student-data-model` on 16 of them and `prospect` on 15, from `engagement` and `task` to `timeline` and `notification`. That is what ties the group together. A module everyone depends on stays stable only if it depends on almost nothing itself.
126 changes: 126 additions & 0 deletions docs-site/case-studies/bagisto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Bagisto

[Bagisto](https://github.com/bagisto/bagisto) is an open source e-commerce platform on Laravel, with about 28,000 stars on GitHub. Its features live in 42 packages under `packages/Webkul`, each with its own namespace (`Webkul\Sales`, `Webkul\Checkout`, ...), service provider and models. Packages are wired together with [Concord](https://konekt.dev/concord), which also provides model proxies so packages can refer to each other's models.

**Analysed:** commit [`7e4b38a`](https://github.com/bagisto/bagisto/tree/7e4b38a894d4f46ab3b691cf301b0908a60f9c4b), October 2026, Laravel 13.39, PHP 8.4.

## Results

| | Default config | Public API declared |
|---|---|---|
| Modules | 42 | 42 |
| Files analysed | 1,997 | 1,997 |
| Cross-module references | 1,497 | 1,497 |
| `internal_access` | 919 | 158 |
| `cycles` | 1 group of 30 modules | 1 group of 30 modules |
| Time / memory | 5.7 s / 113 MB | 6.1 s / 113 MB |

No parse errors and no false positives: every report points at a real reference (see [how we checked](./#what-we-found)).

## From 919 to 158

Most of the first report came from things Bagisto shares on purpose:

| What we declared | Why | Reports removed |
|---|---|---|
| `Core` and `DataGrid` as `open` | Every package extends their base classes: `Repository`, `TranslatableModel`, `CoreModuleServiceProvider`, `DataGrid`. They are a shared kernel. | 340 |
| `Repositories` as a public namespace | Packages read and write each other's data through repositories; the codebase is built around them as the access layer. | 323 |
| `Facades` as a public namespace | Facades such as `Cart` and `MagicAI` are the entry points packages offer each other. | 46 |
| 17 Concord proxies (`OrderProxy`, `CustomerProxy`, ...) | Proxies exist so that packages can refer to each other's models without depending on them. | 52 |

```php
// config/cordon.php
'resolver' => 'namespace',

'resolvers' => [
'namespace' => ['path' => 'packages/Webkul', 'namespace' => 'Webkul'],
],

'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions', 'Repositories', 'Facades'],

'modules' => [
'Core' => ['open' => true],
'DataGrid' => ['open' => true],
'Sales' => ['public' => ['Models\\OrderProxy', 'Models\\OrderItemProxy', 'Models\\InvoiceProxy']],
'Customer' => ['public' => ['Models\\CustomerProxy', 'Models\\CustomerGroupProxy']],
// ... one entry per package that exposes proxies (10 packages)
],
```

Proxies sit in the same `Models` namespace as the models they hide, so they have to be listed one by one. Apps built on Concord (Bagisto, Krayin, UnoPim) would benefit from a pattern such as `Models\*Proxy`. If that is your case, tell us in an issue.

## What remains

The remaining 158 reports are concentrated in a few places. Four patterns stand out.

### Order statuses are read from the model

`Webkul\Sales\Models\Order` is used directly by 7 other packages, and all 15 references read a status constant:

```php
// packages/Webkul/Paypal/src/Helpers/Ipn.php
$this->order = $this->orderRepository->update(['status' => Order::STATUS_PROCESSING], $this->order->id);

// packages/Webkul/Admin/src/Config/system.php
'value' => Order::STATUS_PENDING,
```

Every package that needs a status now depends on the whole Eloquent model. An `OrderStatus` enum in `Webkul\Sales\Enums` would be public by default and remove all 15 reports.

### Every payment gateway builds orders with a Sales transformer

The six payment gateways (PayPal, Stripe, Razorpay, PayU, PhonePe and PayGlocal), plus `Shop` and `Admin`, create an order from a cart the same way:

```php
// packages/Webkul/Stripe/src/Http/Controllers/StripeController.php
$data = (new OrderResource($cart))->jsonSerialize();
```

`OrderResource` lives in `Webkul\Sales\Transformers`, an internal namespace. "Create an order from this cart" is a real capability of `Sales`, used by 8 packages, and it deserves a published contract.

### The base payment class is an extension point

```php
// packages/Webkul/PhonePe/src/Payment/PhonePe.php
class PhonePe extends Payment
```

Six gateways extend `Webkul\Payment\Payment\Payment`. That is intended, so the right fix is to declare it: `'Payment' => ['public' => ['Payment\\Payment']]` or `#[PublicApi]` on the class. Cordon makes the decision explicit instead of leaving it implied.

### Domain code depends on the storefront

```php
// packages/Webkul/Sales/src/Traits/InvoiceReminder.php
use Webkul\Shop\Mail\Customer\InvoiceOverdueReminder;
```

`Sales` sends a mail class that lives in `Shop`, the storefront package, while `Shop` uses 11 classes from `Sales`. Each depends on the other.

## Cycles in the purchase flow

30 of the 42 packages belong to one group of mutually dependent modules. Even the four packages at the heart of a purchase form two cycles:

![Dependencies between Shop, Sales, Product and Checkout in Bagisto. Shop and Sales depend on each other, and so do Product and Checkout.](/case-studies/bagisto-checkout.png)

`Checkout`'s cart item asks `Product` for its type (`AbstractType`), while every product type validates a `Checkout` cart item:

```php
// packages/Webkul/Checkout/src/Models/CartItem.php
public function getTypeInstance(): AbstractType

// packages/Webkul/Product/src/Type/AbstractType.php
public function validateCartItem(CartItem $item): CartItemValidationResult
```

Neither package can change or be tested without the other. The [break a cycle](../recipes/break-a-cycle) recipe shows the usual way out: one side publishes a contract or an event and the other depends only on that.

## Adopting it

With the configuration above, Bagisto could adopt Cordon today without touching its code:

```bash
php artisan cordon:verify --generate-baseline # records the 159 current violations
php artisan cordon:verify # passes
```

From then on, a new controller in `Shop` that reads `Webkul\Sales\Models\Order` fails the build, while the 159 recorded ones are paid down over time. See [adopting in an existing project](../guide/baseline).
45 changes: 45 additions & 0 deletions docs-site/case-studies/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Case studies

We ran Cordon Modulith on three open source Laravel applications, one for each kind of module structure it supports. The goal was to check that it works on real code, find false positives, and see what kind of coupling grows in real modular monoliths.

| Application | Structure | Modules | Files | Violations (default config) | Violations (public API declared) | Time |
|---|---|---|---|---|---|---|
| [Bagisto](./bagisto) | Namespaces (`packages/Webkul/*`) | 42 | 1,997 | 920 | 159 | 6.1 s |
| [Advising App](./advisingapp) | InterNACHI/modular | 37 | 2,463 | 1,163 | 1,155 | 4.5 s |
| [LaravelEcomm](./laravelecomm) | nwidart/laravel-modules | 29 | 1,098 | 778 | 396 | 2.7 s |

All three were analysed in October 2026 on Laravel 13 and PHP 8.4. Each page lists the exact commit.

## What we found

- **No false positives.** We checked every one of the 2,858 `internal_access` reports by machine: the reported line contains the class (or its import alias), and the class is declared in the module Cordon named. We then read a sample of each kind by hand. Every report pointed at a real reference: inheritance, constructor injection, static calls, Eloquent relations or `instanceof`.
- **Most of the first report is configuration, not coupling.** Out of the box, Cordon only treats `Contracts`, `Events`, `Data`, `Enums` and `Exceptions` as public. Real apps share more on purpose: a `Core` module every other module extends, repositories meant as the access layer, facades. Declaring those removed 83% of Bagisto's report and half of LaravelEcomm's. See [what we declared and why](#how-we-declared-the-public-api).
- **What remains is real.** A handful of models used by almost every module (`User` in 25 of 29 modules in LaravelEcomm, `Student` in 21 of 37 in Advising App), order status constants read straight from the `Order` model, and storefront code reaching into sales internals.
- **Cycles are the norm.** In all three apps, most modules belong to one large group that depends on itself: 30 of 42 modules in Bagisto, 29 of 37 in Advising App, 25 of 29 in LaravelEcomm. Counting direct back-and-forth pairs (`A` uses `B` and `B` uses `A`): 26, 54 and 34.
- **Fast enough for CI.** Between 3 and 6 seconds and under 160 MB of memory for 1,000 to 2,500 files, on a developer machine (8 cores, Docker on WSL2).

These are snapshots of how the code is organised, not judgments. Many of these dependencies are deliberate trade-offs, and none of these projects claims to enforce module boundaries. They show what happens when boundaries are only a folder convention.

## How we declared the public API

For each app we read the code and declared as public only what is clearly meant to be shared:

- an `open` shared kernel when every module extends its base classes ([configuration](../guide/configuration)),
- extra `public_namespaces` for layers the app uses as its cross-module API (repositories, facades, DTOs),
- individual classes through the per-module `public` list.

This is our reading of each codebase, not a statement from its maintainers. Both numbers are on each page, so you can judge the difference yourself.

## Reproducing

Every run used the same steps:

```bash
git clone --depth 1 https://github.com/<owner>/<app>.git && cd <app>
composer install
composer require --dev chrisabner/cordon-modulith
php artisan cordon:modules
php artisan cordon:verify --no-baseline --format=json > report.json
```

The configuration used for each app is on its page.
72 changes: 72 additions & 0 deletions docs-site/case-studies/laravelecomm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# LaravelEcomm

[LaravelEcomm](https://github.com/KalimeroMK/LaravelEcomm) is an open source e-commerce application built with [nwidart/laravel-modules](https://github.com/nWidart/laravel-modules). Its 29 modules under `Modules/` cover the catalogue (`Product`, `Category`, `Brand`, `Attribute`), sales (`Cart`, `Order`, `Billing`, `Coupon`, `Shipping`), content (`Post`, `Page`, `Banner`) and a `Front` module for the storefront.

**Analysed:** commit [`02d48b8`](https://github.com/KalimeroMK/LaravelEcomm/tree/02d48b8170a91a86b01d486b776fc679057d2112), October 2026, Laravel 13.39, nwidart/laravel-modules 13.0, PHP 8.4. Cordon read the modules from their `module.json` files; no resolver configuration was needed.

## Results

| | Default config | Public API declared |
|---|---|---|
| Modules | 29 | 29 |
| Files analysed | 1,098 | 1,098 |
| Cross-module references | 1,142 | 1,142 |
| `internal_access` | 777 | 395 |
| `cycles` | 1 group of 25 modules | 1 group of 25 modules |
| Time / memory | 2.6 s / 94 MB | 2.7 s / 92 MB |

## A shared kernel called Core

Almost half of the first report (379 of 777) pointed at one module. `Core` holds the base classes every module builds on:

| Class | References |
|---|---|
| `Core\Http\Requests\CoreRequest` | 51 |
| `Core\Http\Controllers\Api\CoreController` | 44 |
| `Core\Http\Requests\Api\CoreRequest` | 44 |
| `Core\Models\Core` | 43 |
| `Core\Http\Controllers\CoreController` | 30 |
| `Core\Repositories\EloquentRepository` | 28 |

That is a shared kernel, and Cordon expects it to be declared:

```php
'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions', 'Interfaces', 'DTOs'],

'modules' => [
'Core' => ['open' => true],
],
```

Marking `Core` as `open` removed 379 reports and `DTOs` another 3. (`Interfaces` made no difference here: all of them live in `Core`.)

## What remains

The remaining 395 reports are mostly models used across modules:

| Class | References | From modules |
|---|---|---|
| `User\Models\User` | 87 | 25 |
| `Product\Models\Product` | 54 | 15 |
| `Order\Models\Order` | 25 | 11 |
| `Category\Models\Category` | 18 | 9 |

`User` is a special case: 25 of its 87 references are policies, because Laravel passes the authenticated user to every policy method, and 11 are factories. Many teams would declare the user model public, or depend on `Illuminate\Contracts\Auth\Authenticatable` instead.

The storefront is the biggest consumer: `Front` uses `Product` internals 30 times, plus `Category`, `Brand` and `Post`. A storefront that reads every catalogue model directly is hard to change without touching the catalogue, which is where a small read API per module pays off (see [expose a service](../recipes/expose-a-service)).

27 reports come from or point at model factories (`Database/Factories`). Factories only support tests and seeders, so adding `Factories` to `exclude` is a reasonable choice for this app.

## Cycles through Eloquent relations

25 of the 29 modules belong to one group of mutually dependent modules, with 34 direct back-and-forth pairs. Many of them are Eloquent relations declared on both sides:

```php
// Modules/Product/Models/Product.php
return $this->hasMany(Cart::class);

// Modules/Cart/Models/Cart.php
return $this->belongsTo(Product::class);
```

`Product` knows about `Cart`, and `Cart` knows about `Product`. Relations are the most natural way to create a cycle in Laravel without noticing; the [cross-module relations](../recipes/cross-module-relations) recipe shows how to keep only one direction.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading