Skip to content

Latest commit

 

History

History
756 lines (557 loc) · 17.8 KB

File metadata and controls

756 lines (557 loc) · 17.8 KB

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:

  • a manifest.php or manifest.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.

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:

  • enabled: turns module integration on or off
  • paths: module root directories to scan
  • cache: manifest cache file path used by module:cache
  • forceRefresh: ignore cached module manifests and rescan
  • commandPaths: manifest paths keys treated as command directories
  • commandConventions: module-relative fallback command directories
  • migrationsPath: manifest paths keys checked for migrations if the manifest does not list migration files directly
  • seedersPath: manifest paths keys treated as seeder directories

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:

  • name
  • slug
  • version
  • providers
  • paths
  • routes
  • migrations

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.

Menus

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 identifier
  • label: required text shown in the menu
  • url: required target URL
  • parent: optional parent item name for nesting
  • order: optional integer sort order
  • icon: optional icon token or class name
  • visible: optional boolean or callable visibility rule
  • permission: optional permission string for access control
  • roles: optional array of allowed roles

Role-Based Menu Visibility

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 - uses Gate::allows() to check user ability
  • roles - uses user->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 name values throw Marwa\Framework\Exceptions\MenuConfigurationException
  • child items are nested under parent
  • items are sorted by order, then label
  • 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().

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:

  • renderMainMenu() - Full menu HTML
  • renderMenu(array $items) - Custom menu items
  • renderSections(array $sections) - Sidebar sections
  • renderDropdown(array $item) - Dropdown menu
  • renderMenuItem(array $item) - Single menu item

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:

  • modules are enabled
  • the app is not running in console mode
  • no compiled route cache file already exists

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

Module Config

Modules can have isolated config that doesn't pollute global app config. The config is stored under modules.{slug}.* key.

Config Directory

Modules can define config files in modules/{Module}/config/:

modules/Users/
  config/
    settings.php
    permissions.php

Config Loading Order

  1. Manifest config (highest priority)
  2. Module config files - modules/{Module}/config/*.php

Usage

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

Manifest Config

In manifest.php:

return [
    'name' => 'Users Module',
    'slug' => 'users',
    'config' => [
        'theme' => 'dark',
        'per_page' => 20,
    ],
];

Module Service Provider Hooks

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:

  1. registered() - each provider after register()
  2. booting() - all providers before any boot()
  3. boot() - each provider's boot method
  4. booted() - each provider after boot()

Module Events

The framework dispatches events during module bootstrap:

ModuleLoaded Event

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

Manifest Listeners

Register event listeners via manifest:

'listeners' => [
    'boot' => [\App\Listeners\ModuleBootListener::class],
    'loaded' => [\App\Listeners\ModuleLoadedListener::class],
],

Module Policies

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

Explicit Registration

gate()->policy(User::class, CustomUserPolicy::class);

Asset Publishing

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-run

Module Structure

modules/Users/
  public/
    js/app.js
    css/style.css

Assets are copied to public/assets/users/.

Using in Views

<script src="{{ asset('users/js/app.js') }}"></script>
<link href="{{ asset('users/css/style.css') }}">

Commands

Module command discovery runs through:

  • manifest-defined directories referenced by keys listed in commandPaths
  • default conventions such as Console/Commands and src/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;
    }
}

Migrations and Seeders

Run module migrations:

php marwa module:migrate

Run module seeders:

php marwa module:seed

Migration Discovery Order

  1. Conventional path: modules/{Module}/database/migrations/
  2. Manifest migrations: migrations key
  3. Manifest paths: entries matched by database/migrations path

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:

  • 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.

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:

  • module('blog')->manifest() returns the normalized manifest that marwa-module keeps 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();

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

Related