happenv-com / laravel-true-modular
Package info
github.com/happenv-com/laravel-true-modular
pkg:composer/happenv-com/laravel-true-modular
Requires
- php: ^8.3
- laravel/framework: ^12.0 || ^13.0
- thecodingmachine/safe: ^3.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.48
- happenv-com/laravel-access-control: ^2.0
- larastan/larastan: ^3.10
- laravel/pint: ^1.29
- livewire/livewire: ^3.0
- orchestra/testbench: ^11.1
- pestphp/pest: ^4.0
- rector/rector: ^2.5
- thecodingmachine/phpstan-safe-rule: ^1.2
Suggests
- happenv-com/laravel-access-control: Laravel Access Control for managing permissions and roles in modular applications
- happenv-com/laravel-true-modular-phpstan: PHPStan rules for Laravel True Modular
README
Make your Laravel architecture explicit, deterministic, and analyzable.
Modules are first-class Composer packages with deterministic dependency ordering, an extended lifecycle, and a built-in architecture runtime. Instead of an architecture that lives only in your team's heads, you get one you can query, graph, and reason about.
php artisan module:impact acme/catalog
acme/catalog
Direct:
acme/checkout
acme/pricing
Indirect:
acme/storefront
Total affected: 3
Ask the codebase what a change touches before you make it.
Contents
- Why
- Where modules live
- The module graph
- Enforcing boundaries (static analysis)
- Built for agentic coding
- Defining a module
- Extending existing modules
- Extending the framework
- Production notes
- Install
- Documentation
- Development
- License
Why
Large Laravel applications get harder to evolve over time. Modules end up depending on each other silently, boot order becomes implicit, cross-module initialization is fragile, and the real shape of the architecture survives only in the heads of the people who wrote it.
True Modular for Laravel makes that shape explicit, and builds three guarantees on top of it:
- Topological provider ordering - module service providers are sorted by their
composer.jsondependencies, so a module always boots after the modules it depends on. Cycles are detected and reported, not silently mis-ordered. - Enhanced lifecycle -
register() → initialize() → boot(). Theinitialize()phase runs after every provider is registered but before anything boots - including third-party package providers. So your cross-module wiring (morph maps, permissions, drivers, Livewire/Filament hooks) is in place before any package'sboot()reads it. - Architecture runtime -
module:graph,module:impact,module:why,module:list, with--format=jsonso you can wire blast-radius checks into CI, and--format=mermaid/dotto render the graph.
Where modules live
A module is just a Composer package whose composer.json declares type: "true-module". That means
a module can live in either place:
- Local to your app - under the
app-modules/directory, versioned alongside the rest of your code. This is where most modules start. - An external Composer package - pulled in via
composer requireand resolved fromvendor/like any dependency, so a module can be shared across applications or published privately.
Both are discovered the same way and take part in the same dependency ordering and tooling - there's
no difference in how they behave at runtime. The modules directory (default app-modules) and the
module type (default true-module) are configurable in bootstrap/app.php via
Application::modulesDirectory() and Application::moduleComposerType().
The module graph
Modules declare their dependencies in composer.json like any other Composer package. The package
reads those edges and derives both the shape of your system and the exact order things run.
Real graphs aren't a straight line - modules fan out and share dependencies. The number on each node
is its position in the deterministic boot order:
graph TD
core["1 · core"] --> auth["2 · auth"]
core --> product["3 · product"]
product --> inventory["4 · inventory"]
product --> pricing["5 · pricing"]
auth --> sale
inventory --> sale["6 · sale"]
pricing --> sale
sale --> amazon["7 · amazon"]
sale --> ebay["8 · ebay"]
Loading
module:graph renders that as a tree - each module sits under the one it depends on. A module with
two dependencies (here sale) appears under each path that reaches it:
php artisan module:graph
core
├── auth
│ └── sale
│ ├── amazon
│ └── ebay
└── product
├── inventory
│ └── sale
│ ├── amazon
│ └── ebay
└── pricing
└── sale
├── amazon
└── ebay
module:list flattens it into the deterministic execution order - the exact, numbered sequence
in which providers register(), initialize(), and boot(), dependencies first:
php artisan module:list --simple
Modules in order (dependencies first):
1. core
2. auth
3. product
4. inventory
5. pricing
6. sale
7. amazon
8. ebay
No module ever boots before the modules it depends on - and a cycle is a hard error, not a race condition. Other views of the same graph:
php artisan module:graph --format=mermaid # paste straight into a doc php artisan module:graph --format=dot # pipe into Graphviz php artisan module:graph --root=sale # restrict to one subtree php artisan module:why amazon core # shortest path: why does Amazon depend on Core?
Local modules are referenced by their short name (amazon) — the package qualifies them with your
default vendor (derived from modulesNamespace, e.g. happenv/amazon). External packages keep their
full vendor/name (acme/catalog), which signals they aren't part of the local system. Resolution is
case-insensitive.
Output mirrors this: local modules print by short name, external packages by full vendor/name. Pass
--with-vendor to any of these commands to print every module with its full vendor/name; --format=json
always uses full names.
Enforcing boundaries (static analysis)
The runtime discovers and explains the architecture; a companion package
happenv-com/laravel-true-modular-phpstan
enforces it. It ships two zero-config PHPStan extensions:
- Module Boundary Enforcer - fails analysis when a module references a class from another module
that isn't declared in its
composer.jsonrequire, and detects circular dependencies between modules. It reads the samecomposer.jsonedges the framework uses to order providers, so there's nothing to configure. - Dynamic Relation Resolver - types Eloquent relations registered at runtime (e.g. relations one module adds to another module's model via model extensions), which are otherwise invisible to static analysis.
composer require --dev happenv-com/laravel-true-modular-phpstan
With phpstan/extension-installer both
extensions register automatically. See the
package README for details.
Built for agentic coding
Explicit boundaries aren't only good for humans — they're what makes a codebase legible to an AI coding agent, and one of the biggest reasons to adopt this architecture today.
- Smaller context, faster iterations. A module is a self-contained package with an explicit dependency list, so an agent loads just that module and the few it depends on — not the whole app.
- The agent knows where it's allowed to work.
module:impact,module:why, andmodule:graph --root=let it ask "what does this affect, and what does it depend on?" — so it moves inside well-defined boundaries instead of grepping and guessing across the whole system. - Guardrails it gets feedback from. The PHPStan
Module Boundary Enforcer fails the moment generated code
crosses a boundary not declared in
composer.json— immediate, machine-readable feedback, not a reviewer catching it later. - It already knows the conventions. The package ships
Laravel Boost resources auto-installed by
boost:install, so an agent picks up the lifecycle, theModulebuilder, and the rules without docs in the prompt.
See docs/agentic-coding.md for the full agent workflow.
Defining a module
A module is a Composer package (type: "true-module") whose service provider extends
ModuleProvider and declares its features fluently:
class CatalogServiceProvider extends ModuleProvider { public function configureModule(Module $module): void { $module ->name('acme/catalog') ->hasConfig('catalog') ->hasRoutes('web', 'api') ->hasViews() ->hasMigrations() ->runsMigrations(); } }
Extending existing modules
Modules don't only talk to each other through services - they can extend the domain model itself. A downstream module adds attributes and relations to an upstream module's Eloquent model without touching that model's class, so the dependency arrow stays pointed the right way.
The billing module declares the extension:
$module->hasModelExtensions([ Product::class => ProductBillingExtension::class, ]);
/** * @property Product $model */ final class ProductBillingExtension extends ModelExtension { public function invoices(): HasMany { return $this->model->hasMany(Invoice::class); } }
And Product gains the relation as if it were defined on it:
Product::query()->with('invoices');
A sibling, hasModelBuilderExtensions(), does the same for an Eloquent query builder - the key
is the builder class to mix new query methods into, so a downstream module can teach an upstream
model's builder new scopes:
$module->hasModelBuilderExtensions([ ProductBuilder::class => ProductBillingQueries::class, ]); Product::query()->withOutstandingInvoices()->get();
The catalog module that owns Product is never modified - billing contributes new attributes,
relations, and query methods to it. Each module composes the shared domain model instead of forking
or patching it. (Static analysis still sees these runtime additions, thanks to the
PHPStan extension above.)
Extending the framework
The extensions above aren't a custom trick — they ride on Macroable, the trait Eloquent already
uses everywhere (Builder, Collection, Str, Request, …) to add methods to a class from the
outside, without editing it. That's a textbook open/closed, and the natural tool for
cross-module extension: hasModelBuilderExtensions() registers a builder mixin
(Builder::mixin(...)), hasModelExtensions() adds relations via resolveRelationUsing().
Macros are normally invisible to static analysis — but the companion PHPStan extension (with Larastan) types the relations and mixins this package registers, so you extend modules without patching them and keep a green analysis. See docs/model-extensions.md.
Install
composer require happenv-com/laravel-true-modular
The one non-obvious step: the package ships a custom Application that performs the topological sort
and the extra lifecycle phase, so bootstrap/app.php must boot through it. The
php artisan true-modular:setup command rewrites bootstrap/app.php for you - or wire it by hand as
shown in Getting started.
Requires PHP 8.3+ and Laravel 12/13.
Documentation
Full documentation lives in docs/:
| Getting started | Install and create your first module. |
| CLI commands | Graph / impact / why / list, and the --format options. |
| Agentic coding | Working on the codebase with AI agents; the workflow. |
| Architecture & runtime | The lifecycle and the analysis layer. |
| Module dependencies | Discovery, ordering, cycles. |
| Model extensions | Add attributes/relations to another module's model. |
| Module builders | The fluent Module API and every feature. |
| Lifecycle hooks & schemas | Overridable provider hooks; report JSON schema. |
| Config merging | The four config strategies and merge semantics. |
| Best practices · Anti-patterns | Do's and don'ts. |
| Extending the package | Add features, renderers, sources. |
| Testing | Fixtures, helpers, patterns. |
| Laravel Boost | AI guidelines & skills for coding agents. |
Development
composer install vendor/bin/pest # tests (Pest 4) vendor/bin/pint # format vendor/bin/phpstan analyse # static analysis (level 6 + larastan) vendor/bin/rector process # apply refactorings (--dry-run to preview)