marwa-framework

Modules Guide

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.

What a Module Provides

A module can contribute:

The framework reads those pieces from module manifests and configured conventions. It does not require manual module registration in config/app.php.

Quick Start

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

Enable Modules

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:

Module Manifest

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:

The framework also reads requires and dependencies from the raw manifest for dependency validation during bootstrap.

Module Service Provider

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:

Use visible for menu presentation only. Backend access should still be enforced by your controller, policy, or route authorization layer.

Behavior:

The framework shares the final menu tree to views as mainMenu, and you can also resolve it manually with menu()->tree().

Rendering the Menu

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:

Routes

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:

Views

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.

Module View Extensions (Fixed)

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',
],

Commands

Module command discovery runs through:

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;
    }
}

Migrations and Seeders

Run module migrations:

php marwa module:migrate

Run module seeders:

php marwa module:seed

Migration discovery works in two ways:

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');
    }
};

Module Dependencies

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:

Example failure:

Module [auth] requires missing module(s): user.

Use lowercase slugs in examples and manifests even though the dependency check is case-insensitive.

Reading Module Information at Runtime

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:

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();

Caching

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.

Troubleshooting

If a module is not loading:

  1. Confirm config/module.php has 'enabled' => true.
  2. Confirm the module directory is inside one of module.paths.
  3. Confirm the module has exactly one valid manifest file.
  4. Confirm provider classes exist and implement ModuleServiceProviderInterface.
  5. Confirm the host app autoload maps App\\Modules\\ to modules/.
  6. Clear and rebuild the module cache with 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.

Console Commands

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