sinemacula / laravel-modules
A lightweight, convention-driven modular architecture package for Laravel - auto-discovers modules as directories with zero manifests, zero boilerplate, and nothing new to learn.
Requires
- php: ^8.3
- laravel/framework: ^12.0 || ^13.0
Requires (Dev)
- brianium/paratest: ^7.20
- doctrine/coding-standard: ^14.0
- friendsofphp/php-cs-fixer: ^3.94
- infection/infection: ^0.33.3
- larastan/larastan: ^3.9
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- phpbench/phpbench: ^1.7
- phpstan/extension-installer: ^1.4
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- sinemacula/coding-standards-laravel: ^1.5.0
- slevomat/coding-standard: ^8.28
- squizlabs/php_codesniffer: ^4.0
This package is auto-updated.
Last update: 2026-08-01 13:42:31 UTC
README
A lightweight, convention-driven modular architecture package for Laravel. Replaces the standard app/ directory with a
modules/ directory where each subdirectory is a self-contained module following standard Laravel conventions.
Modules are auto-discovered at boot time and cached for performance. All standard Laravel conventions work inside each module - there is no new API to learn.
How It Works
Each subdirectory under modules/ is a self-contained module with its own models, controllers, routes, commands,
listeners, events, observers, policies, and more:
modules/
├── Foundation/ # Default module - see "Paths and Helpers"
│ ├── Console/ # Commands and schedule
│ └── Providers/ # Service providers
└── Billing/ # Example domain module
├── Console/
│ ├── Commands/
│ └── schedule.php
├── Events/
├── Http/
│ ├── Controllers/
│ ├── Requests/
│ ├── Resources/ # Eloquent API resources - not discovered
│ └── routes.php
├── Listeners/
├── Models/
├── Observers/
├── Policies/
└── Resources/ # Discovered: views/ and lang/
├── lang/
└── views/
Note the two Resources directories. Http/Resources/ holds Eloquent API resource classes and is loaded by PSR-4 like
any other class. The module-root Resources/ is the one the package discovers, and only its views/ and lang/
subdirectories are read.
What Gets Discovered
| Convention | Module Path | How It's Loaded |
|---|---|---|
| Console commands | Console/Commands/ |
Auto-registered via withCommands() |
| Scheduled tasks | Console/schedule.php |
Auto-registered via withCommands() |
| Event listeners | Listeners/ |
Auto-registered via withEvents() |
| Views | Resources/views/ |
Auto-registered in ModuleServiceProvider |
| Translations | Resources/lang/ |
Auto-registered in ModuleServiceProvider |
| Routes | Http/routes.php |
Discovered; you wire them in bootstrap/app.php |
Everything else - controllers, requests, resources, events, observers, policies, models, jobs, mail, notifications - works via PSR-4 autoloading. No registration required.
Discovery is per-path and tolerant: a module without a Listeners/ directory simply does not appear in the listener
map. Nothing needs to exist beyond the module directory itself.
Service providers work exactly as they do in a standard Laravel app: register them in bootstrap/providers.php. The
package does not auto-discover module providers, so you keep full control over their registration order.
Module Namespaces
A module is addressed by a lowercased form of its directory name. modules/Billing/ registers the namespace billing,
and views and translations are referenced with the {module}::{path} convention:
view('billing::invoices.show'); trans('billing::invoices.paid');
The namespace is always lowercase, whatever the casing on disk. view('Billing::invoices.show') fails with
No hint path defined for [Billing].
Classes resolve through PSR-4 instead, using the directory name exactly as it is cased. Because modules/ takes the
place of app/, the App\ prefix maps straight onto it:
| File | Class |
|---|---|
modules/Billing/Models/Invoice.php |
App\Billing\Models\Invoice |
modules/Billing/Http/Controllers/StatementController.php |
App\Billing\Http\Controllers\StatementController |
Generating Classes
make: generators write into modules/, because app_path() now points there, but they still qualify a bare class
name against the application root namespace rather than a module. make:controller ReportController creates
modules/Http/Controllers/ReportController.php, and a path argument does not help - passing
Billing/Http/Controllers/InvoiceController creates
modules/Http/Controllers/Billing/Http/Controllers/InvoiceController.php.
Pass the fully qualified class name instead:
php artisan make:controller "App\Billing\Http\Controllers\StatementController" php artisan make:model "App\Billing\Models\Invoice"
Those land at modules/Billing/Http/Controllers/StatementController.php and modules/Billing/Models/Invoice.php. This
applies to any generator writing under the application directory; generators targeting database/ (migrations,
factories, seeders) are unaffected and still write to their usual location.
Artisan Commands
| Command | Description |
|---|---|
module:make {name} |
Scaffold a new module with the standard directory structure |
module:list |
List all discovered modules and their paths |
module:cache |
Cache discovered module paths for faster resolution |
module:clear |
Clear the cached module paths |
module:make Billing creates:
modules/Billing/
├── Console/
│ ├── Commands/
│ └── schedule.php
├── Http/
│ ├── Controllers/
│ ├── Requests/
│ └── routes.php
├── Listeners/
├── Models/
└── Resources/
├── lang/
└── views/
The Foundation module is scaffolded without Resources/. It is the default module, so a Resources directory
there becomes the application's resource_path(), and a Resources/lang inside it becomes lang_path() - which
would move your Vite root and orphan the framework's published translations. Create it by hand if that is what you
intend.
Module Caching
Module paths are cached to bootstrap/cache/modules.php and integrated into Laravel's optimize / optimize:clear
lifecycle:
php artisan optimize # Includes module:cache php artisan optimize:clear # Includes module:clear
The cache records the listing of the modules/ directory it was built from, and is ignored once that listing no
longer matches. Laravel caches routes and events before any package command runs during optimize, so without
this a module added since the last module:cache would be missing from the route and event caches while the module
cache itself looked correct.
Validation covers the immediate entries of modules/ only, not the contents of each module. Adding or removing a
module invalidates the cache; editing a file inside one does not. Any entry appearing or disappearing counts, so a
stray file such as .DS_Store also sends the next boot down the discovery path until module:cache runs again.
module:make does not write the cache. It creates a directory under modules/, which the validation above treats as a
change, so the next boot rediscovers and the new module appears without any further step.
optimize also runs view:cache, which fails outright on a configured view path that does not exist. A modular
application has no resources/views directory of its own, and that is where config('view.paths') points by default,
so the package drops every missing entry from view.paths when it registers. Module views are unaffected: they resolve
through their own namespace rather than through view.paths. The pruned list is what config:cache writes too, so an
application that adds resources/views after an optimize run needs the caches rebuilt before it takes effect.
Installation
composer require sinemacula/laravel-modules
1. Edit bootstrap/app.php
Replace the default Laravel application with the modular variant:
<?php use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use SineMacula\Laravel\Modules\Application; use SineMacula\Laravel\Modules\Configuration\Modules; Modules::setBasePath(dirname(__DIR__)); return Application::configure(basePath: dirname(__DIR__)) ->withRouting( api : Modules::routePaths(), health : '/health', apiPrefix: '', ) ->withMiddleware(function (Middleware $middleware): void {}) ->withExceptions(function (Exceptions $exceptions): void {}) ->create();
Modules::setBasePath() must come first. The application constructor resolves module paths while it binds the
container paths, so calling it later - or not at all - fails with ModuleException: No base path has been set.
2. Update your autoload mapping
In your application's composer.json, point the PSR-4 autoload at the modules/ directory:
{
"autoload": {
"psr-4": {
"App\\": "modules/"
}
}
}
Then run composer dump-autoload.
This mapping is load-bearing rather than cosmetic. Application::path() points app_path() at modules/, and Laravel
resolves the application namespace by matching realpath(app_path()) against the PSR-4 entries in composer.json.
Without an entry that resolves to modules/, every make: command fails with Unable to detect application namespace.
3. Create the modules/ directory
Create a modules/ directory at your project root and add your first module:
mkdir -p modules/Foundation/Providers
4. Wire up routing
Each module defines its own routes in Http/routes.php. Modules::routePaths() discovers them for you; you wire the
result into withRouting() in bootstrap/app.php (as shown above). Routing stays explicit, exactly like standard
Laravel - the package just saves you from listing each module's route file by hand.
routePaths() returns a map of module name to route file, so the snippet above registers every module's routes in the
api group. With apiPrefix: '' they are served from the root, but they still receive the api middleware group -
stateless, with no session and no CSRF. Passing the same map to web: as well would register every file twice, so each
module contributes its one route file to exactly one group. Because the map is keyed by module name, it can be split
where an application needs both:
$routes = Modules::routePaths(); return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: Arr::only($routes, ['storefront']), api: Arr::except($routes, ['storefront']), )
Paths and Helpers
modules/ replaces app/, so app_path() resolves there and base_path(), storage_path(), config_path() and
database_path() are unchanged.
A global helper resolves paths inside the modules directory:
module_path(); // <base>/modules module_path('Billing'); // <base>/modules/Billing
The Modules resolver is the public surface behind discovery. The path methods resolve lazily and drop modules that do
not have the directory or file in question:
| Method | Returns |
|---|---|
Modules::getModules() |
Every module name mapped to its absolute path |
Modules::getModule($name) |
One module's path, or null (the name is lowercased) |
Modules::modulesPath() |
The modules/ directory itself |
Modules::routePaths() |
Module name to Http/routes.php |
Modules::viewPaths() |
Module name to Resources/views/ |
Modules::langPaths() |
Module name to Resources/lang/ |
Modules::listenerPaths() |
Module name to Listeners/ |
Modules::commandPaths() |
Module name to Console/Commands/ |
Modules::schedulePaths() |
Module name to Console/schedule.php |
Modules::defaultModule() |
The module unprefixed resources resolve against |
Modules::flush() |
Nothing - discards the memoised maps |
State is process-wide: the base path and every resolved map are static and shared by all callers in the process.
Modules::setBasePath() discards the resolved maps when the path changes, and module:cache / module:clear rebuild
them. Modules::flush() discards them on demand and leaves the cached manifest in place, so a test suite can reset
discovery in its teardown; the base path is left as it is, so set it again when the next application lives elsewhere.
The default module and resource_path()
resource_path() is module-aware. Given a {module}::{path} prefix it resolves inside that module's Resources/;
without one it uses the default module, Foundation:
resource_path('billing::views'); // <base>/modules/Billing/Resources/views
This has a consequence worth knowing before you create modules/Foundation/Resources/. Because Laravel binds the
container paths through the same method, that directory becomes the application's resource root as soon as it exists:
| Path | Without Foundation/Resources |
With it |
|---|---|---|
resource_path() |
<base>/resources |
<base>/modules/Foundation/Resources |
config('view.paths')[0] |
<base>/resources/views |
<base>/modules/Foundation/Resources/views |
The view.paths entry is dropped rather than kept when the directory it names does not exist, so an application with
neither modules/Foundation/Resources/views nor resources/views boots with an empty view.paths and resolves every
view through a module namespace. view_path() reads the first configured path, so it falls back to resource_path()
while that list is empty rather than reading past the end of it.
lang_path() follows the same rule but only once modules/Foundation/Resources/lang/ exists, because Laravel falls
back to <base>/lang while that directory is absent. Creating it therefore relocates the framework's published
translations too.
That is the intended behaviour of a default module, but it moves the Vite root and resources/js with it, so
module:make Foundation deliberately does not create Resources/. Add it by hand when you want the application's own
assets to live inside the module.
A module prefix that does not resolve - a typo, or a module with no Resources/ directory - falls back to the
framework path rather than raising an error.
Requirements
- PHP ^8.3
- Laravel ^12.0 || ^13.0
Testing
composer test # PHPUnit suite in parallel via Paratest composer test:coverage # suite with Clover coverage output composer test:mutation # Infection mutation gate (min MSI 90) composer test:mutation:full # full mutation suite without thresholds composer check # static analysis and lint via qlty composer format # format via qlty composer smells # duplication / complexity smells via qlty composer bench # PHPBench suite for the hot paths composer bench:ci # PHPBench with CI artifact dump composer bench:smoke # single-rev pass to verify every subject runs
Changelog
See CHANGELOG.md for a list of notable changes.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.
Security
If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.
License
Licensed under the Apache License, Version 2.0.