happenv-com/laravel-true-modular

Maintainers

Package info

github.com/happenv-com/laravel-true-modular

pkg:composer/happenv-com/laravel-true-modular

Statistics

Installs: 1 297

Dependents: 2

Suggesters: 0

Stars: 2

Open Issues: 0

v1.1.0 2026-06-27 17:50 UTC

This package is auto-updated.

Last update: 2026-06-29 16:34:45 UTC


README

Laravel True Modular

Latest Version on Packagist Total Downloads Tests PHPStan Zizmor Code Style

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

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:

  1. Topological provider ordering - module service providers are sorted by their composer.json dependencies, so a module always boots after the modules it depends on. Cycles are detected and reported, not silently mis-ordered.
  2. Enhanced lifecycle - register() → initialize() → boot(). The initialize() 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's boot() reads it.
  3. Architecture runtime - module:graph, module:impact, module:why, module:list, with --format=json so you can wire blast-radius checks into CI, and --format=mermaid/dot to 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 require and resolved from vendor/ 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)

Latest Version on Packagist Total Downloads

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.json require, and detects circular dependencies between modules. It reads the same composer.json edges 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, and module: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, the Module builder, 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)

License

MIT