Creating a Module
Step-by-step guide to creating a new module for the ION Guard platform.
Prerequisites
- Familiarity with Module System
- Development environment with Laravel Sail configured
- DevTools module enabled (for package generation)
Quick start
1. Create the directory structure
Create the module directory at app/Modules/{ModuleName}/:
app/Modules/MeuModulo/
├── module.json
├── MeuModuloModule.php
├── MeuModuloServiceProvider.php
├── routes/
│ ├── web.php
│ └── api.php
├── database/
│ └── migrations/
└── resources/
└── js/
├── entry.ts
└── pages/
2. Create the module.json
{
"identifier": "meu-modulo",
"name": "Meu Módulo",
"version": "1.0.0",
"description": "Descrição do que o módulo faz.",
"scope": "site",
"authors": [
{ "name": "Sua Empresa", "email": "dev@empresa.com" }
],
"requires": [],
"laravel": "^12.0"
}
| Field | Description |
|---|---|
identifier | Unique module slug (kebab-case). Used in routes, cache, middleware |
scope | "site" = enabled per site. "global" = always active |
requires | Dependencies on other modules (array of identifiers) |
3. Create the module class
MeuModuloModule.php — implements ModuleContract (or extends BaseModule):
<?php
declare(strict_types=1);
namespace App\Modules\MeuModulo;
use App\Contracts\BaseModule;
final class MeuModuloModule extends BaseModule
{
public function identifier(): string
{
return 'meu-modulo';
}
public function name(): string
{
return 'Meu Módulo';
}
public function description(): string
{
return 'Descrição do módulo.';
}
public function version(): string
{
return '1.0.0';
}
public function scope(): string
{
return 'site';
}
}
BaseModule provides default implementations (empty arrays) for all optional methods. Override as needed.
4. Create the ServiceProvider
MeuModuloServiceProvider.php:
<?php
declare(strict_types=1);
namespace App\Modules\MeuModulo;
use App\Modules\ModuleRegistry;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
final class MeuModuloServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->make(ModuleRegistry::class)
->register(new MeuModuloModule());
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/database/migrations');
Route::middleware(['web', 'auth', 'module:meu-modulo'])
->group(__DIR__.'/routes/web.php');
Route::middleware(['api', 'auth:sanctum', 'module:meu-modulo'])
->prefix('api/v1')
->group(__DIR__.'/routes/api.php');
}
}
5. Create routes
routes/web.php:
<?php
use App\Modules\MeuModulo\Http\Controllers\Web\MeuController;
use Illuminate\Support\Facades\Route;
Route::get('/meu-modulo', [MeuController::class, 'index'])
->name('meu-modulo.index');
The module:meu-modulo middleware ensures that the routes are only accessible when the module is enabled for the current site.
6. Create the frontend entry point
resources/js/entry.ts:
// Auto-discovery of Inertia pages
const rawPages = import.meta.glob('./pages/**/*.tsx');
export const pages: Record<string, () => Promise<unknown>> = {};
for (const [filePath, resolver] of Object.entries(rawPages)) {
const name = filePath.replace(/^\.\/pages\//, '').replace(/\.tsx$/, '');
pages[name] = resolver;
}
// i18n translation registration
export function registerLocales(): void {
// Import and register translation bundles here
}
// Extensible slot registration
export function registerSlots(): void {
// Register components in core slots here
}
Folders inside pages/ must follow kebab-case and match the module identifier. Example: pages/meu-modulo/index.tsx.
Extension points
After the basic structure, add functionality by overriding methods from ModuleContract:
Alert Handlers
public function alertHandlers(): array
{
return [
MeuAlertHandler::class,
];
}
public function alertTypes(): array
{
return ['meu-alerta'];
}
See Alert Handlers for detailed implementation.
Report Handlers
public function reportHandlers(): array
{
return [
MeuRelatorioHandler::class,
];
}
See Report Handlers for detailed implementation.
Navigation
Add items to the side menu in the ServiceProvider's boot(), resolving the core's NavigationRegistry and registering a NavigationGroup with its NavigationItems. Tie the group/items to the module via the module: parameter — this way the menu only appears on the sites where the module is enabled (the NavigationRegistry checks isEnabledForSite, and isFeatureEnabledForSite when feature: is provided):
use App\Navigation\NavigationGroup;
use App\Navigation\NavigationItem;
use App\Navigation\NavigationRegistry;
public function boot(): void
{
// ... (routes, migrations)
$nav = $this->app->make(NavigationRegistry::class);
$group = $nav->group(new NavigationGroup(
id: 'meu-modulo',
title: 'Meu Módulo',
order: 50,
requiresSite: true,
module: 'meu-modulo',
translationKey: 'meu_modulo:nav.group',
));
$group->addItem(new NavigationItem(
title: 'Dashboard',
href: '/meu-modulo',
icon: 'layout-dashboard',
order: 10,
requiresSite: true,
module: 'meu-modulo',
translationKey: 'meu_modulo:nav.dashboard',
));
}
NavigationItem and NavigationGroup also accept minimumRole:, gate:, and feature: to control visibility by profile, permission, or sub-resource.
navigationGroups() does not existNavigation is registered imperatively in the NavigationRegistry (as above), not by a navigationGroups() method on the contract — that method does not exist on the platform.
Dynamic relationships
Extend core models without modifying them:
// In the ServiceProvider's boot()
Site::resolveRelationUsing('meuModuloConfig', function (Site $site) {
return $site->hasOne(MeuModuloConfig::class);
});
Scheduled tasks
public function schedule(Schedule $schedule): void
{
$schedule->call(function (): void {
// Check whether the module is enabled for each site
foreach (Site::query()->get() as $site) {
if (app(ModuleRegistry::class)->isEnabledForSite('meu-modulo', $site->id)) {
dispatch(new MeuJob($site->id));
}
}
})->everyFiveMinutes();
}
Scheduled jobs must check whether the module is enabled for the site before executing. The central scheduler calls schedule() for all registered modules, regardless of their enablement state.
Workers (long-running processes)
public function workers(): array
{
return [
new WorkerDefinition(
name: 'meu-listener',
command: 'meu-modulo:listen',
),
];
}
Workers are converted into Docker containers by the RegenerateModuleRuntimeCompose action.
Tests
Structure the tests inside the module:
app/Modules/MeuModulo/
└── tests/
├── Feature/
│ └── MeuControllerTest.php
└── Unit/
└── MeuServiceTest.php
Tests are automatically discovered by Pest/PHPUnit. Run with:
sail artisan test app/Modules/MeuModulo/tests/
Packaging
To distribute the module as an installable package:
- Go to DevTools → Module Generator in the interface
- Select the module
- The system builds the frontend (if applicable), packages it into a ZIP, and encrypts it
The .enc package is installed via the Modules interface by a Platform Admin: the core (App\Actions\InstallModule + App\Services\ModuleReleaseEncryption) decrypts the package, validates the ZIP structure, and publishes the assets. The workers declared by the module become containers via RegenerateModuleRuntimeCompose.
The package generation flow (build + packaging + encryption, e.g., via a DevTools module) is a development tool and may vary depending on the environment. What the core guarantees is the installation side of the .enc described above.
Pre-distribution checklist
-
module.jsonwith correct metadata - ServiceProvider registers the module in
ModuleRegistry -
module:{identifier}middleware on all routes - Migrations create and remove tables correctly
- Morph map registered (if using polymorphic relationships)
- Policies registered for all models
- Alert handlers fully implement
AlertHandlerContract - Jobs check module enablement (guard clause)
- Frontend entry.ts exports pages, registerLocales, registerSlots
- i18n translations in pt_BR and en
- Tests covering happy path and edge cases
-
README.mdwith description and instructions - Documentation in
docs/(user-guide.md, api.md, architecture.md)