This guide shows how modules work in the Marwa Framework as it is implemented in this repository.
Modules are discovered from configured directories, registered through marwa-module, and then integrated into the framework bootstrap so their providers, routes, views, commands, migrations, and seeders can participate in the application runtime.
A module can contribute:
- a
manifest.phpormanifest.json - one or more module service providers
- main navigation menu items
- HTTP and API route files
- Twig views
- console commands
- database migrations
- database seeders
The framework reads those pieces from module manifests and configured conventions. It does not require manual module registration in config/app.php.
Generate a new module:
php marwa make:module BlogThe generated structure matches the framework stubs:
modules/
└── Blog/
├── BlogServiceProvider.php
├── Console/
│ └── Commands/
├── database/
│ └── migrations/
├── manifest.php
├── resources/
│ └── views/
│ └── index.twig
└── routes/
└── http.php
Map your application namespace to modules/ in the host app composer.json if you use the default generated namespace:
{
"autoload": {
"psr-4": {
"App\\Modules\\": "modules/"
}
}
}Then refresh autoloading:
composer dump-autoloadModules are disabled by default. Enable them in config/module.php:
<?php
return [
'enabled' => true,
'paths' => [
base_path('modules'),
],
'cache' => bootstrap_path('cache/modules.php'),
'forceRefresh' => false,
'commandPaths' => [
'commands',
],
'commandConventions' => [
'Console/Commands',
'src/Console/Commands',
],
'migrationsPath' => [
'database/migrations',
],
'seedersPath' => [
'database/seeders',
],
];Important keys:
enabled: turns module integration on or offpaths: module root directories to scancache: manifest cache file path used bymodule:cacheforceRefresh: ignore cached module manifests and rescancommandPaths: manifestpathskeys treated as command directoriescommandConventions: module-relative fallback command directoriesmigrationsPath: manifestpathskeys checked for migrations if the manifest does not list migration files directlyseedersPath: manifestpathskeys treated as seeder directories
Each module needs exactly one manifest file: manifest.php or manifest.json.
Typical manifest.php:
<?php
declare(strict_types=1);
return [
'name' => 'Blog Module',
'slug' => 'blog',
'version' => '1.0.0',
'providers' => [
App\Modules\Blog\BlogServiceProvider::class,
],
'paths' => [
'views' => 'resources/views',
'commands' => 'Console/Commands',
'migrations' => 'database/migrations',
'seeders' => 'database/seeders',
],
'routes' => [
'http' => 'routes/http.php',
'api' => 'routes/api.php',
],
'migrations' => [
'database/migrations/2026_01_01_000000_create_posts_table.php',
],
];Standard manifest fields that the runtime exposes are:
nameslugversionproviderspathsroutesmigrations
The framework also reads requires and dependencies from the raw manifest for dependency validation during bootstrap.
Generated module providers implement Marwa\Module\Contracts\ModuleServiceProviderInterface:
<?php
declare(strict_types=1);
namespace App\Modules\Blog;
use Marwa\Module\Contracts\ModuleServiceProviderInterface;
final class BlogServiceProvider implements ModuleServiceProviderInterface
{
public function register($app): void
{
$app->set('module.blog.registered', true);
}
public function boot($app): void
{
$app->set('module.blog.booted', true);
}
}Use register() for bindings and service setup. Use boot() for runtime behavior that depends on registered services.
The framework boots module providers automatically after discovery. You do not need to add them to config/app.php.
Modules can contribute to the shared main navigation through Marwa\Framework\Navigation\MenuRegistry.
Typical module usage inside boot($app):
<?php
declare(strict_types=1);
namespace App\Modules\Blog;
use Marwa\Framework\Navigation\MenuRegistry;
use Marwa\Module\Contracts\ModuleServiceProviderInterface;
final class BlogServiceProvider implements ModuleServiceProviderInterface
{
public function register($app): void
{
}
public function boot($app): void
{
/** @var MenuRegistry $menu */
$menu = $app->make(MenuRegistry::class);
$menu->add([
'name' => 'blog',
'label' => 'Blog',
'url' => '/blog',
'order' => 20,
'visible' => static fn (): bool => user()?->hasPermission('blog.post.view') === true,
]);
$menu->add([
'name' => 'blog.posts',
'label' => 'Posts',
'url' => '/blog/posts',
'parent' => 'blog',
'order' => 10,
'visible' => static fn (): bool => user()?->hasPermission('blog.post.view') === true,
]);
}
}Supported menu item fields:
name: required stable identifierlabel: required text shown in the menuurl: required target URLparent: optional parent item name for nestingorder: optional integer sort ordericon: optional icon token or class namevisible: optional boolean or callable visibility rulepermission: optional permission string for access controlroles: optional array of allowed roles
Menu items can be filtered based on user permissions or roles:
$menu->add([
'name' => 'users',
'label' => 'Users',
'url' => '/users',
'permission' => 'users.view', // Requires this permission
]);
$menu->add([
'name' => 'admin',
'label' => 'Admin',
'url' => '/admin',
'roles' => ['admin', 'superadmin'], // Requires one of these roles
]);How it works:
permission- usesGate::allows()to check user abilityroles- usesuser->hasRole()to check role membership- Items without these fields are visible to everyone
- If auth is not available, items are visible
Use visible for menu presentation only. Backend access should still be enforced by your controller, policy, or route authorization layer.
Behavior:
- duplicate
namevalues throwMarwa\Framework\Exceptions\MenuConfigurationException - child items are nested under
parent - items are sorted by
order, thenlabel - items whose parent does not exist are skipped from the built menu tree
The framework shares the final menu tree to views as mainMenu, and you can also resolve it manually with menu()->tree().
Use NavigationRenderer to render the menu in your views:
// In controller
$renderer = app(\Marwa\Framework\Navigation\NavigationRenderer::class);
$renderer->setCurrentUrl(request()->getUri()->getPath());
$menuHtml = $renderer->renderMainMenu();
return view('layout', ['mainMenu' => $menuHtml]);Or get the structured menu data for custom rendering:
$menuData = $renderer->tree();
// Returns:
// [
// [
// 'name' => 'blog',
// 'label' => 'Blog',
// 'url' => '/blog',
// 'icon' => 'bi bi-book',
// 'isActive' => true,
// 'children' => [...],
// ],
// ]The renderer provides:
renderMainMenu()- Full menu HTMLrenderMenu(array $items)- Custom menu itemsrenderSections(array $sections)- Sidebar sectionsrenderDropdown(array $item)- Dropdown menurenderMenuItem(array $item)- Single menu item
The module bootstrapper automatically loads route files declared in the manifest under routes.http and routes.api.
Example routes/http.php:
<?php
declare(strict_types=1);
use Marwa\Framework\Facades\Router;
use Marwa\Router\Response;
Router::get('/blog', fn () => Response::json([
'module' => 'Blog Module',
'ok' => true,
]))->register();Module routes are loaded only when:
- modules are enabled
- the app is not running in console mode
- no compiled route cache file already exists
If a module manifest defines paths.views, the framework automatically registers that directory as a Twig namespace using the module slug.
For a module with slug blog, render templates with the @blog/... convention:
return view('@blog/index.twig', [
'title' => 'Blog',
]);With this manifest entry:
'paths' => [
'views' => 'resources/views',
],the template path resolves to:
modules/Blog/resources/views/index.twig
The generator stub already includes the paths.views entry in new module manifests, so this works out of the box.
Previously, module templates using manifest-registered namespaces might lose access to custom Twig functions (e.g., csrf_field(), session()). This is now fixed by lazy-loading the view engine. The framework now loads Twig extensions after module namespaces are registered, so custom Twig functions work seamlessly in module templates.
You no longer need manual addNamespace() in service providers - just use the manifest:
'paths' => [
'views' => 'resources/views',
],Modules can have isolated config that doesn't pollute global app config. The config is stored under modules.{slug}.* key.
Modules can define config files in modules/{Module}/config/:
modules/Users/
config/
settings.php
permissions.php
- Manifest config (highest priority)
- Module config files -
modules/{Module}/config/*.php
// Get module config
module_config('users.theme'); // from manifest or config file
module_config('users.settings.per_page'); // nested config
// Via global config
config('modules.users.theme');In manifest.php:
return [
'name' => 'Users Module',
'slug' => 'users',
'config' => [
'theme' => 'dark',
'per_page' => 20,
],
];Module service providers can implement lifecycle hooks:
class UsersServiceProvider extends ServiceProviderAdapter
{
// Called after provider is registered (before boot)
public function registered(): void
{
// Add transient services here
}
// Called right before boot() for all providers
public function booting(): void
{
// Configure before boot runs
}
// Called after provider boot() completes
public function booted(): void
{
// Final setup here
}
}Hook Execution Order:
registered()- each provider after register()booting()- all providers before any boot()boot()- each provider's boot methodbooted()- each provider after boot()
The framework dispatches events during module bootstrap:
Dispatched for each module during bootstrap:
use Marwa\Framework\Adapters\Event\ModuleLoaded;
$event = new ModuleLoaded(slug: 'users', name: 'Users Module');
// Listen to specific module
event()->listen(ModuleLoaded::class, function($event) {
if ($event->slug === 'users') {
// Handle users module loaded
}
});
// Listen to all modules
event()->listen(ModuleLoaded::class, function($event) {
\Log::info("Module loaded: " . $event->slug);
});Register event listeners via manifest:
'listeners' => [
'boot' => [\App\Listeners\ModuleBootListener::class],
'loaded' => [\App\Listeners\ModuleLoadedListener::class],
],Module code can still define policy classes in a Policies/ folder, but the framework does not auto-discover them in Gate.
Register them explicitly:
use Marwa\Framework\Authorization\PolicyRegistry;
$registry = app(PolicyRegistry::class);
$registry->register(App\Modules\Users\Models\User::class, App\Modules\Users\Policies\UserPolicy::class);gate()->policy(User::class, CustomUserPolicy::class);Publish module assets to public directory:
# Publish all module assets
php marwa module:publish
# Publish specific module
php marwa module:publish users
# Preview without copying
php marwa module:publish --dry-runmodules/Users/
public/
js/app.js
css/style.css
Assets are copied to public/assets/users/.
<script src="{{ asset('users/js/app.js') }}"></script>
<link href="{{ asset('users/css/style.css') }}">Module command discovery runs through:
- manifest-defined directories referenced by keys listed in
commandPaths - default conventions such as
Console/Commandsandsrc/Console/Commands
Generated modules already include Console/Commands, and the generator adds paths.commands to the manifest.
Example command:
<?php
declare(strict_types=1);
namespace App\Modules\Blog\Console\Commands;
use Marwa\Framework\Console\AbstractCommand;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'blog:hello', description: 'Example module command')]
final class BlogHelloCommand extends AbstractCommand
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Hello from the Blog module.');
return self::SUCCESS;
}
}Run module migrations:
php marwa module:migrateRun module seeders:
php marwa module:seed- Conventional path:
modules/{Module}/database/migrations/ - Manifest migrations:
migrationskey - Manifest paths: entries matched by
database/migrationspath
Seeder discovery uses seedersPath config against manifest paths entries, typically database/seeders.
Example migration file:
<?php
use Marwa\DB\CLI\AbstractMigration;
use Marwa\DB\Schema\Schema;
return new class extends AbstractMigration {
public function up(): void
{
Schema::create('blog_posts', function ($table): void {
$table->increments('id');
$table->string('title');
$table->timestamps();
});
}
public function down(): void
{
Schema::drop('blog_posts');
}
};Modules can declare other required modules in the manifest using requires or dependencies:
return [
'name' => 'Auth Module',
'slug' => 'auth',
'providers' => [
App\Modules\Auth\AuthServiceProvider::class,
],
'requires' => [
'user',
],
];During bootstrap, the framework validates those dependencies before module providers are booted.
Behavior:
- dependency names are matched case-insensitively
- missing dependencies fail fast
- the framework throws
Marwa\Framework\Exceptions\ModuleDependencyException
Example failure:
Module [auth] requires missing module(s): user.
Use lowercase slugs in examples and manifests even though the dependency check is case-insensitive.
Use the helper APIs to inspect loaded modules:
if (has_module('blog')) {
$blog = module('blog');
$name = $blog->name();
$slug = $blog->slug();
$manifest = $blog->manifest();
}You can also access the application-level module registry:
$modules = app()->modules();
$hasBlog = app()->hasModule('blog');
$blog = app()->module('blog');Important detail about metadata:
module('blog')->manifest()returns the normalized manifest thatmarwa-modulekeeps at runtime- standard manifest fields are available
- arbitrary custom manifest keys are not exposed through
manifest()today
That means this works:
$manifest = module('blog')->manifest();
$version = $manifest['version'] ?? null;But custom keys should not be relied on through that API unless the package is extended to preserve them.
You can also access the shared menu registry at runtime:
$menuTree = menu()->tree();
$flatMenu = menu()->all();Build the module manifest cache:
php marwa module:cacheClear the module manifest cache:
php marwa module:clearThe cache file path is controlled by config/module.php and is also used by bootstrap cache commands.
If a module is not loading:
- Confirm
config/module.phphas'enabled' => true. - Confirm the module directory is inside one of
module.paths. - Confirm the module has exactly one valid manifest file.
- Confirm provider classes exist and implement
ModuleServiceProviderInterface. - Confirm the host app autoload maps
App\\Modules\\tomodules/. - Clear and rebuild the module cache with
module:clearandmodule:cache.
If module('slug') fails, the module was not discovered or bootstrapped.
If dependency validation fails, add the missing module or remove the declared dependency.
| Command | Description |
|---|---|
make:module |
Generate a module scaffold |
module:cache |
Build the module manifest cache |
module:clear |
Remove the module manifest cache |
module:migrate |
Run discovered module migrations |
module:seed |
Run discovered module seeders |