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:
manifest.php or manifest.jsonThe 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 Blog
The 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-autoload
Modules 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 by module:cacheforceRefresh: ignore cached module manifests and rescancommandPaths: manifest paths keys treated as command directoriescommandConventions: module-relative fallback command directoriesmigrationsPath: manifest paths keys checked for migrations if the manifest does not list migration files directlyseedersPath: manifest paths keys treated as seeder directoriesEach 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:
nameslugversionproviderspathsroutesmigrationsThe 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 ruleUse visible for menu presentation only. Backend access should still be enforced by your controller, policy, or route authorization layer.
Behavior:
name values throw Marwa\Framework\Exceptions\MenuConfigurationExceptionparentorder, then labelThe 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 itemThe 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:
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',
],
Module command discovery runs through:
commandPathsConsole/Commands and src/Console/CommandsGenerated 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:migrate
Run module seeders:
php marwa module:seed
Migration discovery works in two ways:
migrationspaths entries matched by migrationsPath config, typically database/migrationsSeeder 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:
Marwa\Framework\Exceptions\ModuleDependencyExceptionExample 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 that marwa-module keeps at runtimemanifest() todayThat 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:cache
Clear the module manifest cache:
php marwa module:clear
The cache file path is controlled by config/module.php and is also used by bootstrap cache commands.
If a module is not loading:
config/module.php has 'enabled' => true.module.paths.ModuleServiceProviderInterface.App\\Modules\\ to modules/.module:clear and module: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 |