laravel-modular maintained by mehediishere
mehediishere/laravel-modular
A native, zero-dependency modular architecture package for Laravel modular systems.
No magic traits. No JSON state files. Just pure Laravel.
Features
- Module discovery — enable/disable modules from a single config file
- Zero dependencies — built entirely on Laravel's own service container, routing, and filesystem
- Sidebar management with group merging — modules declare a
group_id; any modules sharing the samegroup_idare automatically merged into one dropdown in the admin panel - Permission-filtered sidebar — items invisible to the current user are stripped before rendering
- Artisan scaffolding —
php artisan module:make POSgenerates the full folder structure with stubs - Publishable stubs — customise the scaffolding output to match your team's conventions
- Per-module config, migrations, views, routes, translations, and commands — all self-registering
- Sidebar caching — per-user cache with configurable TTL
- Laravel auto-discovery — no manual provider registration needed
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^11.0 or ^12.0 |
Installation
composer require mehediishere/laravel-modular
Laravel's package auto-discovery registers the service provider automatically.
Publish the config files:
php artisan vendor:publish --tag=modular-config
This creates two files in your project:
config/modular.php— package settings (sidebar cache, TTL)config/modules.php— your enabled modules list and base path
Add the Modules namespace to your project's composer.json:
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "Modules/"
}
}
Then run composer dump-autoload.
Quick start
1. Scaffold a module
php artisan module:make POS
php artisan module:make Ecommerce
php artisan module:make Account
php artisan module:make Payroll
Each command creates a full module folder at Modules/{Name}/:
Modules/POS/
├── app/
│ ├── Http/Controllers/
│ ├── Http/Requests/
│ ├── Models/
│ ├── Services/
│ ├── Contracts/
│ ├── Providers/
│ │ └── POSServiceProvider.php
│ ├── Console/Commands/
│ ├── Events/
│ └── Listeners/
├── config/
│ ├── config.php
│ └── sidebar.php ← define group_id here
├── database/
│ ├── migrations/
│ ├── seeders/
│ └── factories/
├── resources/views/
├── resources/lang/en/
├── routes/
│ ├── web.php
│ └── api.php
└── tests/
├── Feature/
├── Unit/
└── TestCase.php
2. Enable the module
Open config/modules.php and add your module:
'enabled' => [
'POS',
'Ecommerce',
'Account',
'Payroll',
],
3. Autoload and migrate
composer dump-autoload
php artisan migrate
Sidebar management
The group_id concept
The sidebar is built from each module's config/sidebar.php. The key field is group_id — a short snake_case string that identifies which dropdown group this module's items belong to.
Modules with the same group_id are merged into one dropdown.
This means you can have Account and Payroll as separate modules but group them both under a single "Finance" dropdown in the sidebar — neither module needs to know about the other.
Account module Payroll module
group_id: 'finance' + group_id: 'finance'
─────────────────────────────────────────────
Merged result
▼ Finance ← one dropdown
Chart of Accounts ← from Account
Journal Entries ← from Account
Payroll Runs ← from Payroll
Tax Reports ← from Payroll
Sidebar config schema
<?php
// Modules/Account/config/sidebar.php
return [
'group_id' => 'finance', // shared with Payroll — they merge into one dropdown
'group' => 'Finance', // dropdown label (use the same across sharing modules)
'icon' => 'bar-chart', // group header icon
'order' => 20, // sidebar position (lower = higher)
'items' => [
[
'label' => 'Chart of Accounts',
'route' => 'account.coa.index',
'icon' => 'list',
'order' => 1,
'permission' => 'account.coa.view', // Laravel Gate ability; empty = all users
],
[
'label' => 'Journal Entries',
'route' => 'account.journal.index',
'icon' => 'book',
'order' => 2,
'permission' => 'account.journal.view',
],
],
];
<?php
// Modules/Payroll/config/sidebar.php
return [
'group_id' => 'finance', // same group_id → items merge with Account above
'group' => 'Finance',
'icon' => 'bar-chart',
'order' => 20,
'items' => [
[
'label' => 'Payroll Runs',
'route' => 'payroll.runs.index',
'icon' => 'dollar-sign',
'order' => 3,
'permission' => 'payroll.runs.view',
],
[
'label' => 'Tax Reports',
'route' => 'payroll.tax.index',
'icon' => 'file-text',
'order' => 4,
'permission' => 'payroll.tax.view',
],
],
];
Standalone groups
If a module should appear as its own top-level dropdown with no sharing, just use its own unique group_id:
return [
'group_id' => 'pos', // unique — no other module uses this
'group' => 'Point of Sale',
'icon' => 'shopping-cart',
'order' => 10,
'items' => [...],
];
Using the sidebar in your admin layout
@php
$sidebarGroups = app(\Mehediishere\LaravelModular\Services\SidebarManager::class)->build();
@endphp
@foreach($sidebarGroups as $group)
<div class="sidebar-group" data-group="{{ $group['group_id'] }}">
<button class="sidebar-group-toggle">
{{ $group['group'] }}
</button>
<div id="sidebar-group-{{ $group['group_id'] }}">
@foreach($group['items'] as $item)
<a href="{{ route($item['route']) }}"
class="{{ request()->routeIs($item['route'] . '*') ? 'active' : '' }}">
{{ $item['label'] }}
</a>
@endforeach
</div>
</div>
@endforeach
Or publish and use the bundled layout:
php artisan vendor:publish --tag=modular-views
The built sidebar array structure
[
[
'group_id' => 'finance',
'group' => 'Finance',
'icon' => 'bar-chart',
'order' => 20,
'items' => [
['label' => 'Chart of Accounts', 'route' => 'account.coa.index', 'icon' => 'list', 'order' => 1, 'permission' => 'account.coa.view'],
['label' => 'Journal Entries', 'route' => 'account.journal.index', 'icon' => 'book', 'order' => 2, 'permission' => 'account.journal.view'],
['label' => 'Payroll Runs', 'route' => 'payroll.runs.index', 'icon' => 'dollar-sign', 'order' => 3, 'permission' => 'payroll.runs.view'],
['label' => 'Tax Reports', 'route' => 'payroll.tax.index', 'icon' => 'file-text', 'order' => 4, 'permission' => 'payroll.tax.view'],
],
],
[
'group_id' => 'pos',
'group' => 'Point of Sale',
'icon' => 'shopping-cart',
'order' => 10,
'items' => [...],
],
]
Flushing the sidebar cache
use Mehediishere\LaravelModular\Services\SidebarManager;
// Flush for the current user (call after role/permission changes)
app(SidebarManager::class)->flush();
// Flush for a specific user
app(SidebarManager::class)->flush($userId);
// Flush for all users
app(SidebarManager::class)->flushAll();
Disable caching during development:
MODULAR_SIDEBAR_CACHE=false
Module service provider
The generated {Name}ServiceProvider extends BaseModuleServiceProvider:
<?php
namespace Modules\Account\app\Providers;
use Mehediishere\LaravelModular\BaseModuleServiceProvider;
class AccountServiceProvider extends BaseModuleServiceProvider
{
protected string $moduleName = 'Account';
protected array $bindings = [
\Modules\Account\app\Contracts\LedgerRepositoryInterface::class =>
\Modules\Account\app\Repositories\LedgerRepository::class,
];
protected array $singletons = [
'account.currency' => \Modules\Account\app\Services\CurrencyService::class,
];
protected array $commands = [
\Modules\Account\app\Console\Commands\ReconcileCommand::class,
];
}
BaseModuleServiceProvider automatically handles:
| What | From where |
|---|---|
| Migrations | database/migrations/ |
| Views | resources/views/ → account:: |
| Translations | resources/lang/ → account:: |
| Web routes | routes/web.php |
| API routes | routes/api.php |
| Module config | config/config.php → account.* |
Accessing module config
// Modules/POS/config/config.php
return ['per_page' => 25, 'currency' => 'BDT'];
// Anywhere in the app
config('pos.per_page'); // 25
config('pos.currency'); // BDT
Inter-module communication
Modules should never import classes from each other directly. Use one of:
Events (zero coupling):
// Fire from Order module
event(new \Modules\Order\app\Events\OrderPlaced($order));
// Listen in Inventory module's ServiceProvider boot()
\Illuminate\Support\Facades\Event::listen(
\Modules\Order\app\Events\OrderPlaced::class,
\Modules\Inventory\app\Listeners\ReserveStock::class,
);
Contracts (return values needed):
// Define interface in the consuming module
// Modules/Order/app/Contracts/ProductStockInterface.php
// Implement in Product module
// Modules/Product/app/Services/ProductStockService.php
// Bind in Product's ServiceProvider
protected array $bindings = [
\Modules\Order\app\Contracts\ProductStockInterface::class =>
\Modules\Product\app\Services\ProductStockService::class,
];
Customising stubs
Publish the stubs to your project:
php artisan vendor:publish --tag=modular-stubs
Stubs are written to stubs/modular/. The module:make command checks for your custom stubs first before falling back to package defaults.
| Stub file | Generates |
|---|---|
service-provider.stub |
app/Providers/{Name}ServiceProvider.php |
sidebar-config.stub |
config/sidebar.php |
module-config.stub |
config/config.php |
routes-web.stub |
routes/web.php |
routes-api.stub |
routes/api.php |
test-case.stub |
tests/TestCase.php |
PHPUnit test suites
Add a testsuite entry per module in phpunit.xml:
<testsuites>
<testsuite name="Application">
<directory>tests/Feature</directory>
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Account">
<directory>Modules/Account/tests</directory>
</testsuite>
<testsuite name="POS">
<directory>Modules/POS/tests</directory>
</testsuite>
</testsuites>
Run a single module's tests:
php artisan test --testsuite=Account
Configuration reference
config/modules.php
return [
'enabled' => ['POS', 'Ecommerce', 'Account', 'Payroll'],
'path' => base_path('Modules'),
];
config/modular.php
return [
'sidebar' => [
'cache' => true, // env: MODULAR_SIDEBAR_CACHE
'cache_ttl' => 3600, // env: MODULAR_SIDEBAR_TTL (seconds)
],
];
Changelog
v1.0.0
- Initial release
- Module discovery via
config/modules.php BaseModuleServiceProviderwith auto-registration of migrations, views, routes, config, translations, commandsSidebarManagerwithgroup_idmerging, per-user permission filtering, and cachingphp artisan module:makescaffold command with--forceflag- Publishable config, views, and stubs
License
MIT — see LICENSE
Author
Mehedi Hassan — @mehediishere