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
- pestphp/pest: ^4.0
- thecodingmachine/phpstan-safe-rule: ^1.2
README
Extended Laravel Application with topological service provider ordering and enhanced lifecycle.
Overview
The Kernel module provides a custom Application class that extends Laravel's foundation application with two key enhancements:
- Topological Provider Ordering - Service providers from app modules are automatically sorted based on their
composer.jsondependencies - Enhanced Lifecycle - A new
initialize()method betweenregister()andboot()
Application Lifecycle
Laravel Default
flowchart LR
A[register] --> B[boot]
Loading
True Modular Extended
flowchart LR
A[register] --> B[initialize] --> C[boot]
style B fill:#22c55e,color:#fff
Loading
Lifecycle Phases
| Phase | Purpose | Use Cases |
|---|---|---|
register() |
Bind services to container | Singletons, bindings, interfaces |
initialize() |
Configure cross-cutting concerns | Morph maps, permissions, hooks, drivers |
boot() |
Bootstrap services | Routes, views, commands, event listeners |
Why initialize()?
In a modular monolith, modules often need to register things that depend on other modules being registered first, but before the full boot phase:
- Morph Maps -
Relation::morphMap()needs all models registered - Filament Hooks - Schema hooks need Filament resources registered
- Permissions - Permission registration needs auth models ready
- Drivers - Custom drivers for other modules' managers
The initialize() phase runs after all providers are registered and before any provider boots, ensuring cross-module dependencies are satisfied.
Usage
Implementing initialize() in a Service Provider
<?php namespace Myapp\Sale; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; class SaleServiceProvider extends ServiceProvider { public function register(): void { // Bind services to container $this->app->singleton(OrderService::class); } public function initialize(): void { // Register morph maps (all models are now available) Relation::morphMap([ 'order' => Order::class, 'payment' => Payment::class, ]); // Register permissions app(PermissionRegistrar::class)->register([ 'orders.view', 'orders.create', ]); // Register Filament hooks ProductResource::registerSchemaHook('sale', fn () => [ TextColumn::make('orders_count'), ]); } public function boot(): void { // Load routes, views, commands $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); } }
Execution Order
Providers are executed in topological order based on module dependencies:
myapp/kernel (no dependencies)
myapp/core (depends on kernel)
myapp/pim (depends on core)
myapp/inventory (depends on core)
myapp/sale (depends on core, pim, inventory)
myapp/amazon (depends on sale, pim)
This ensures that when SaleServiceProvider::initialize() runs, all its dependencies (core, pim, inventory) have already completed their initialize() phase.
Architecture
ServiceProviderSorter
Sorts service providers according to module dependency order:
use Myapp\Kernel\ServiceProviderSorter; $sorter = app(ServiceProviderSorter::class); // Sort providers - non-modules first, then modules in topological order $sorted = $sorter->sort($providers); // Get module name for a provider $module = $sorter->getModuleName($provider); // Returns: 'myapp/sale' or null // Group providers by module $grouped = $sorter->groupByModule($providers); // Returns: ['myapp/core' => [...], 'myapp/sale' => [...]]
How Sorting Works
- Identify modules by
composer.jsontype field (true-moduleby default) - Separate providers into modules vs others (Laravel, Spatie, etc.)
- Preserve original order for non-module providers
- Sort module providers using Kahn's topological sort algorithm
- Merge - other providers first, then sorted module providers
Input: [Laravel, Filament, Sale, Core, Spatie, Amazon, Pim]
Output: [Laravel, Filament, Spatie, Core, Pim, Sale, Amazon]
↑─── Module sorted ───↑
Configuration
The custom Application is configured in bootstrap/app.php:
<?php use Myapp\Kernel\Application; return Application::configure(basePath: dirname(__DIR__)) ->withRouting(...) ->withMiddleware(...) ->create();
Module Detection
Modules are identified by their composer.json type field. By default, the package looks for packages with type: "true-module".
Module composer.json
Each module must have the correct type in its composer.json:
{
"name": "myapp/sale",
"type": "true-module",
"require": {
"myapp/core": "*",
"myapp/pim": "*"
},
"autoload": {
"psr-4": {
"Myapp\\Sale\\": "src/"
}
}
}
Custom Module Type
If you prefer a different type identifier (e.g., for organization-specific naming), you can customize it in bootstrap/app.php:
<?php use Myapp\Kernel\Application; return Application::moduleComposerType('acme-module') ::configure(basePath: dirname(__DIR__)) ->withRouting(...) ->withMiddleware(...) ->create();
Then your modules would use:
{
"name": "acme/billing",
"type": "acme-module"
}
Retrieving the Current Type
You can programmatically check the configured module type:
$type = Application::getModuleComposerType(); // Returns: 'true-module' (default) or your custom type
Best Practices
DO use initialize() for:
Relation::morphMap()registrationGate::define()and permission registration- Filament schema hooks and customizations
- Driver registration for managers
- Cross-module event listener registration
DON'T use initialize() for:
- Container bindings → use
register() - Loading routes/views/commands → use
boot() - Anything that doesn't need cross-module coordination
Migration from existing code
If you have code in boot() that registers morph maps, permissions, or hooks, move it to initialize():
// Before (in boot) public function boot(): void { Relation::morphMap(['order' => Order::class]); // ❌ Too late $this->loadRoutesFrom(...); } // After (split between initialize and boot) public function initialize(): void { Relation::morphMap(['order' => Order::class]); // ✅ Perfect timing } public function boot(): void { $this->loadRoutesFrom(...); }
Module Setup Checklist
- Set
"type": "true-module"in each module'scomposer.json - Define dependencies in
requiresection - Configure PSR-4 autoload namespace
- (Optional) Customize type via
Application::moduleComposerType()
Config Extension (extendConfigs)
Modules can extend existing config files using configsToExtend. The merge follows these rules:
| Original value | Extending value | Result |
|---|---|---|
| scalar | scalar | keeps original (overwrite: false) or replaces (overwrite: true) |
list [a, b] |
list [b, c] |
merged + deduplicated → [a, b, c] |
| assoc array | assoc array | recursively merged with the same rules |
| new key | any | always added |
Lists (array_is_list) are always extended regardless of the overwrite flag. Scalars and type mismatches (e.g. scalar vs array) respect overwrite.