Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Bug report
description: A crash, a missed violation or wrong output.
labels: ["bug"]
body:
- type: textarea
id: what
attributes:
label: What happened
description: What you ran, what you expected and what you got instead.
validations:
required: true
- type: textarea
id: output
attributes:
label: Output
description: Full command output or stack trace.
render: text
- type: textarea
id: reproduce
attributes:
label: How to reproduce
description: The smallest code or project layout that shows the problem. A failing test or fixture is even better.
validations:
required: true
- type: input
id: versions
attributes:
label: Versions
placeholder: Cordon Modulith 0.1.0, PHP 8.4, Laravel 13, nwidart/laravel-modules 13
validations:
required: true
11 changes: 11 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and ideas
url: https://github.com/ChrisAbner/Cordon-Modulith/discussions
about: Ask how to model your modules or propose a new rule.
- name: Documentation
url: https://chrisabner.github.io/Cordon-Modulith/
about: Installation, configuration, rules and recipes.
- name: Security vulnerability
url: https://github.com/ChrisAbner/Cordon-Modulith/security/advisories/new
about: Report security issues privately, not as a public issue.
41 changes: 41 additions & 0 deletions .github/ISSUE_TEMPLATE/false_positive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: False positive
description: Cordon Modulith reports something that is not a real boundary violation.
labels: ["bug", "false positive"]
body:
- type: markdown
attributes:
value: A rule that reports correct code is a bug. The fastest way to get it fixed is the smallest code that reproduces it.
- type: textarea
id: report
attributes:
label: Reported violation
description: Paste the output of `php artisan cordon:verify` for this case.
render: text
validations:
required: true
- type: textarea
id: code
attributes:
label: Code that triggers it
description: The relevant lines of the file, including its `namespace` and `use` statements.
render: php
validations:
required: true
- type: textarea
id: why
attributes:
label: Why it is not a violation
validations:
required: true
- type: input
id: layout
attributes:
label: Module layout
description: "Output of `php artisan cordon:modules` (first lines are enough), e.g. nwidart, InterNACHI or app/Modules."
- type: input
id: versions
attributes:
label: Versions
placeholder: Cordon Modulith 0.1.0, PHP 8.4, Laravel 13
validations:
required: true
23 changes: 23 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Feature request
description: Suggest a rule, an option or an integration.
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What boundary problem in your codebase does this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: How it could work, with an example of the output or config if possible.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives
description: Workarounds you use today, or other tools that do something similar.
11 changes: 11 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## What and why

<!-- One topic per pull request. Link the issue or discussion if there is one. -->

## Checklist

- [ ] Tests added or updated; a bug fix starts with a failing test
- [ ] New detection behaviour has a fixture in `tests/Fixtures`
- [ ] `composer check` passes (Pint, PHPStan, Pest)
- [ ] No change to `src/Contracts`, the config shape, the baseline format or command options without an ADR in `docs/adr`
- [ ] `CHANGELOG.md` updated if users will notice the change
Binary file added .github/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All notable changes to Cordon Modulith are documented here. The project follows [Semantic Versioning](https://semver.org) from 1.0.0; before that, minor versions may contain breaking changes.

## [Unreleased] - 0.1.0
## [0.1.0] - 2026-09-29

### Added
- `cordon:verify` command with `text`, `json` and `github` output formats.
Expand All @@ -11,6 +11,7 @@ All notable changes to Cordon Modulith are documented here. The project follows
- Static dependency extraction with nikic/php-parser (the analysed code is never loaded).
- Rules: `internal_access`, `undeclared_dependency`, `cycles`.
- Public API model: public namespaces, `#[PublicApi]`, `#[Internal]`, per-module `public` list and `open` modules.
- Global `public_namespaces` entries match at any depth of the class namespace (e.g. `Invoices\Enums\Status` is public), see ADR 0010; the per-module `public` list stays anchored at the module root.
- Baseline file for gradual adoption (`--generate-baseline`, `--no-baseline`).
- Laravel Boost guideline for AI coding agents.
- `--module` option for `cordon:verify` to report the violations of one or more modules.
Expand Down
7 changes: 7 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Code of conduct

This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).

In short: be respectful and constructive, assume good intent, and keep discussions about the code and the ideas, not the people. Harassment, insults and personal attacks are not accepted in issues, pull requests, discussions or any other project space.

To report unacceptable behaviour, contact the maintainer, [@ChrisAbner](https://github.com/ChrisAbner), privately through GitHub. Reports are handled confidentially.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ Please open a pull request with a failing test or fixture, even if you don't hav

## Security

Please report security issues privately to the maintainers instead of opening a public issue.
Please report security issues privately; see [SECURITY.md](SECURITY.md). Everyone taking part in the project follows the [code of conduct](CODE_OF_CONDUCT.md).
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Cordon Modulith

[![Tests](https://github.com/ChrisAbner/Cordon-Modulith/actions/workflows/tests.yml/badge.svg)](https://github.com/ChrisAbner/Cordon-Modulith/actions/workflows/tests.yml)
[![Latest version](https://img.shields.io/packagist/v/chrisabner/cordon-modulith.svg)](https://packagist.org/packages/chrisabner/cordon-modulith)
[![Downloads](https://img.shields.io/packagist/dt/chrisabner/cordon-modulith.svg)](https://packagist.org/packages/chrisabner/cordon-modulith)
[![PHP](https://img.shields.io/packagist/dependency-v/chrisabner/cordon-modulith/php.svg)](composer.json)
[![License](https://img.shields.io/github/license/ChrisAbner/Cordon-Modulith.svg)](LICENSE.md)

**Cordon off your modules.** Verifiable boundaries between the modules of a Laravel application, checked in CI.

> **Status: 0.1 (alpha).** The public API may change before 1.0.
> **Status: 0.1 (alpha).** The public API may change before 1.0. [Documentation](https://chrisabner.github.io/Cordon-Modulith/)

Modular monoliths in Laravel usually rely on nwidart/laravel-modules, InterNACHI/modular or a hand-made `app/Modules` folder. All of them organise code, but none of them stops the `Billing` module from reaching into `Catalog`'s models. Six months later the "modules" are just folders.

Expand All @@ -12,6 +18,8 @@ Cordon Modulith reads your code statically, builds the dependency graph between
- depends on a module it **did not declare**, or
- takes part in a **dependency cycle**.

It also keeps AI coding agents honest: Cordon Modulith ships Laravel Boost resources that teach agents to go through a module's public API and to fix the violations `cordon:verify` reports.

It is inspired by [Spring Modulith](https://spring.io/projects/spring-modulith) and Shopify's Packwerk, adapted to Laravel conventions.

```text
Expand Down Expand Up @@ -79,11 +87,7 @@ Other modules may only use classes that belong to a module's public API. A class
4. it lives under one of the public namespaces, relative to the module: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default, plus the module's own `public` list → public;
5. otherwise → **internal**.

`public_namespaces` match the **first namespace segment relative to the module root** only. `Modules\Billing\Contracts\Gateway` is public, but `Modules\Billing\Invoices\Enums\Status` is **not**: its relative name starts with `Invoices`, not `Enums`. Field tests showed that nested enums and events cause a large share of violations in some apps. To expose them, do one of these:

- list the nested namespace in the module's `public` list, e.g. `'Billing' => ['public' => ['Invoices\\Enums', 'Invoices\\Events']]`;
- mark the class with `#[PublicApi]`;
- move it to a top-level public namespace.
`public_namespaces` match at **any depth** relative to the module: an entry counts when it appears as a whole segment (or a contiguous run of segments, such as `Http\Resources`) in the class's namespace. `Modules\Billing\Contracts\Gateway`, `Modules\Billing\Invoices\Enums\Status` and `Modules\Billing\Invoices\Events\Sub\InvoicePaid` are public; `Modules\Billing\Invoices\MyEnums\Status` and `Modules\Billing\EnumsHelper\Foo` are not (whole segments only). The module's own `public` list is different: its entries stay anchored at the module root, e.g. `Services\BillingService`. If a nested `Enums` or `Events` class must stay internal, mark it with `#[Internal]`.

`DTOs` is not in the defaults (only `Data`). If you use it, add it to `public_namespaces`, e.g. `['Contracts', 'Events', 'Data', 'DTOs', 'Enums', 'Exceptions']` (setting the option replaces the default list).

Expand Down
19 changes: 19 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Security policy

## Supported versions

Cordon Modulith is in its 0.x series. Security fixes are released for the latest minor version only.

| Version | Supported |
|---|---|
| 0.1.x | Yes |

## Reporting a vulnerability

Please do not open a public issue. Report it privately through GitHub instead: go to the [Security tab](https://github.com/ChrisAbner/Cordon-Modulith/security) and click **Report a vulnerability**.

Include the Cordon Modulith version, PHP and Laravel versions, and the smallest project or file that reproduces the problem. You will get an answer within 7 days. Once a fix is released, the advisory is published with credit to you unless you prefer otherwise.

## Scope

Cordon Modulith is a development dependency. It parses your code statically and never loads or executes the classes it analyses. It does evaluate your project's config files (`config/cordon.php`, `config/modules.php`, `config/app-modules.php`) to find your modules, so treat those files as code.
3 changes: 2 additions & 1 deletion config/cordon.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
|--------------------------------------------------------------------------
|
| Other modules may only use classes that belong to a module's public API:
| classes under one of these namespaces (relative to the module namespace)
| classes under one of these namespaces, matched as whole segments at any
| depth relative to the module namespace (Enums also covers Invoices\Enums)
| or classes marked with #[Cordon\Attributes\PublicApi].
| #[Cordon\Attributes\Internal] always wins.
|
Expand Down
7 changes: 2 additions & 5 deletions docs-site/guide/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ Other modules may only use classes that belong to a module's public API. A class
4. it lives under one of the public namespaces, relative to the module: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default, plus the module's own `public` list → public;
5. otherwise → **internal**.

`public_namespaces` match the **first namespace segment relative to the module root** only. `Modules\Billing\Contracts\Gateway` is public, but `Modules\Billing\Invoices\Enums\Status` is **not**: its relative name starts with `Invoices`, not `Enums`. Field tests showed that nested enums and events cause a large share of violations in some apps. To expose them, do one of these:

- list the nested namespace in the module's `public` list, e.g. `'Billing' => ['public' => ['Invoices\\Enums', 'Invoices\\Events']]`;
- mark the class with `#[PublicApi]`;
- move it to a top-level public namespace.
`public_namespaces` match at **any depth** relative to the module: an entry counts when it appears as a whole segment (or a contiguous run of segments, such as `Http\Resources`) in the class's namespace. `Modules\Billing\Contracts\Gateway`, `Modules\Billing\Invoices\Enums\Status` and `Modules\Billing\Invoices\Events\Sub\InvoicePaid` are public; `Modules\Billing\Invoices\MyEnums\Status` and `Modules\Billing\EnumsHelper\Foo` are not (whole segments only). The module's own `public` list is different: its entries stay anchored at the module root, e.g. `Services\BillingService`. If a nested `Enums` or `Events` class must stay internal, mark it with `#[Internal]`.

`DTOs` is not in the defaults (only `Data`). If you use it, add it to `public_namespaces`, e.g. `['Contracts', 'Events', 'Data', 'DTOs', 'Enums', 'Exceptions']` (setting the option replaces the default list).

Expand All @@ -23,6 +19,7 @@ For a module `Modules\Catalog`:
| `Modules\Catalog\Contracts\ProductCatalog` | yes | `Contracts` namespace |
| `Modules\Catalog\Data\ProductData` | yes | `Data` namespace |
| `Modules\Catalog\Events\ProductDiscontinued` | yes | `Events` namespace |
| `Modules\Catalog\Products\Enums\Status` | yes | `Enums` at any depth |
| `Modules\Catalog\Models\Product` | no | internal by default |
| `Modules\Catalog\Support\PriceFormatter` with `#[PublicApi]` | yes | attribute |
| `Modules\Catalog\Contracts\LegacyCatalog` with `#[Internal]` | no | `#[Internal]` always wins |
Expand Down
22 changes: 22 additions & 0 deletions docs/adr/0010-nested-public-namespaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# ADR 0010: Nested public namespaces

**Status:** accepted
**Date:** 2026-09-29

## Context
ADR 0003 defines the public API by namespaces relative to the module (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default). The first implementation matched only the first segment relative to the module root.

Field tests on real applications showed that about 20% of the `internal_access` reports in some apps were classes such as `He4rt\Recruitment\Requisitions\Enums\RequisitionStatusEnum` or `...\Requisitions\Events\JobRequisitionGenerationEvent`. Their relative name starts with a feature folder (`Requisitions`), so they were reported, but users see enums and events as public API wherever they live.

## Decision
- An entry of the global `public_namespaces` list matches at any depth: a class is public when the entry appears as a contiguous run of whole segments in its namespace relative to the module (the short class name is excluded). Multi-segment entries such as `Http\Resources` match the same way.
- With module root `Modules\Billing`: `Contracts\Charges`, `Invoices\Enums\Status` and `Invoices\Events\Sub\InvoicePaid` are public; `EnumsHelper\Foo`, `Invoices\MyEnums\Status` and `Invoices\Status` are internal.
- A class whose relative name is exactly an entry at the module root (`Modules\Billing\Enums`) stays public, as before.
- The per-module `public` list (`modules.X.public`) keeps its root-anchored behaviour: it names specific classes or namespaces such as `Services\BillingService`.
- Precedence is unchanged: `#[Internal]` > `#[PublicApi]` > open module > public namespaces > internal. `#[Internal]` remains the way to keep a nested `Enums` or `Events` class internal.
- No change to the config shape, the contracts or the baseline format.

## Consequences
- Fewer reports, in line with the zero-false-positives rule: nested enums, events, data objects, contracts and exceptions are no longer flagged.
- Possible under-reporting when a team meant a nested `Events` (or similar) namespace to be internal. Mark those classes with `#[Internal]`.
- Existing baselines may contain entries for classes that are now public. They are harmless: `Baseline::filter()` only suppresses violations that are actually found, and unmatched entries are ignored. They can be dropped by regenerating the baseline with `--generate-baseline`.
2 changes: 1 addition & 1 deletion resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This application uses Cordon Modulith to enforce boundaries between its modules. Follow these rules when writing or changing code:

- A module may only use another module's public API: classes under its public namespaces (by default `Contracts`, `Events`, `Data`, `Enums` and `Exceptions`) or classes marked with `#[\Cordon\Attributes\PublicApi]`.
- A module may only use another module's public API: classes under its public namespaces at any depth (by default `Contracts`, `Events`, `Data`, `Enums` and `Exceptions`) or classes marked with `#[\Cordon\Attributes\PublicApi]`.
- Never use models, services, jobs, actions or other internal classes from a different module. Add a contract, event or data object to the target module's public API instead, and depend on that.
- To react to something that happens in another module without depending on it, listen to one of its public events.
- If a module declares `depends_on` in `config/cordon.php`, only add a new module dependency there when it is intended, and never add it just to silence a violation.
Expand Down
2 changes: 1 addition & 1 deletion resources/boost/skills/cordon-fix-violations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Run `php artisan cordon:modules` to see each module's namespace, path and declar
- **Never add a violation to the baseline** (`--generate-baseline`) to make the build pass. The baseline is only for adopting Cordon Modulith in an existing project, and the maintainers decide when to regenerate it.
- **Never add a module to `depends_on`** in `config/cordon.php` only to silence `undeclared_dependency`. Ask the user if the dependency is intended.
- **Never mark a class `#[PublicApi]` just to silence `internal_access`.** Only do it when the class was designed to be used by other modules (stable, no persistence details). Prefer a contract or a data object.
- Do not move classes into a public namespace (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions`) unless they really are contracts, events, data objects, enums or exceptions.
- Do not move classes into a public namespace (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions`, at any depth) unless they really are contracts, events, data objects, enums or exceptions.
- Re-run `php artisan cordon:verify` after each fix.

## 3. internal_access
Expand Down
33 changes: 30 additions & 3 deletions src/Analysis/PublicApiPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
* Decides whether a class belongs to its module's public API.
*
* Precedence: #[Internal] > #[PublicApi] > open module > public namespaces
* (global config + module "public" list) > internal by default.
* (global config, matched at any depth; module "public" list, anchored at the
* module root) > internal by default.
*/
final readonly class PublicApiPolicy
{
/**
* @param list<string> $publicNamespaces Namespaces relative to each module, e.g. "Contracts".
* @param list<string> $publicNamespaces Namespaces matched at any depth relative to each module, e.g. "Contracts".
*/
public function __construct(
private array $publicNamespaces = ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'],
Expand All @@ -33,7 +34,13 @@ public function isPublic(string $fqcn, Module $module, ?ClassDeclaration $declar

$relative = $module->relativeName($fqcn);

foreach ([...$this->publicNamespaces, ...$module->publicApi] as $prefix) {
foreach ($this->publicNamespaces as $entry) {
if ($this->matchesAnywhere($relative, trim($entry, '\\'))) {
return true;
}
}

foreach ($module->publicApi as $prefix) {
$prefix = trim($prefix, '\\');

if ($prefix !== '' && ($relative === $prefix || str_starts_with($relative, $prefix.'\\'))) {
Expand All @@ -43,4 +50,24 @@ public function isPublic(string $fqcn, Module $module, ?ClassDeclaration $declar

return false;
}

/**
* Whether the entry appears as a contiguous run of whole segments in the
* namespace of the relative class name (the short class name is excluded),
* or is the relative name itself.
*/
private function matchesAnywhere(string $relative, string $entry): bool
{
if ($entry === '') {
return false;
}

if ($relative === $entry) {
return true;
}

$namespace = substr($relative, 0, (int) strrpos($relative, '\\'));

return str_contains('\\'.$namespace.'\\', '\\'.$entry.'\\');
}
}
Loading
Loading