Module System
ION Guard uses a modular architecture where features are encapsulated in independent modules. Each module is self-contained — it has its own models, migrations, routes, controllers, frontend pages, alert handlers, report handlers, and workers.
Overview
Core (app/) Module (app/Modules/IndoorLocation/)
├── ModuleRegistry ├── module.json
├── ModuleServiceProvider ├── IndoorLocationServiceProvider
├── AlertHandlerRegistry ├── IndoorLocationModule (implements ModuleContract)
├── ReportServiceProvider ├── Models/, Actions/, Services/
├── EnsureModuleEnabled middleware ├── Http/Controllers/, routes/
└── module-loader.ts (frontend) ├── Alerts/Handlers/, Reports/Handlers/
├── database/migrations/, factories/
└── resources/js/ (pages, locales)
Module lifecycle
1. Discovery
The ModuleServiceProvider (core) scans app/Modules/*/ looking for service providers:
app/Modules/{ModuleName}/{ModuleName}ServiceProvider.php
Modules listed in config/modules.php → disabled are ignored.
2. Register
The module's ServiceProvider is registered by Laravel. In the register() method:
- Creates the module instance (
new IndoorLocationModule()) - Registers it in the
ModuleRegistryvia$registry->register($module) - Merges configs (
$this->mergeConfigFrom(...)) - Registers singletons and bindings
3. Boot
In the ServiceProvider's boot() method:
- Loads routes (web and API) with the
module:{identifier}middleware - Loads migrations from the module's directory
- Registers morph maps for polymorphic relations
- Registers policies and gates
- Listens to domain events
- Registers dynamic relationships on core models
4. Schedule
The routes/console.php file (core) iterates over all registered modules and calls $module->schedule($schedule), allowing each module to register its scheduled jobs.
ModuleContract
Every module implements App\Contracts\ModuleContract. In practice, modules extend App\Contracts\BaseModule, which already implements the entire contract with empty defaults — leaving only the four identity methods required (identifier, name, description, version). Full reference in Module Contract.
Identity
| Method | Return | Example |
|---|---|---|
identifier() | string | 'indoor-location' |
name() | string | 'Synapsys' |
description() | string | 'BLE-based indoor location tracking...' |
version() | string | '1.0.0' |
scope() | string | 'site' or 'global' |
Extensions
| Method | Return | Purpose |
|---|---|---|
alertHandlers() | list<class-string> | Alert handler classes to register |
alertTypes() | list<string> | Alert types the module defines |
eventTypes() | list<string> | Event types the module emits |
reportHandlers() | list<class-string> | Report handler classes (implement ReportContract) |
features() | list<ModuleFeature> | Sub-features enablable/disablable per site |
morphMap() | array<string, class-string> | Morph map for polymorphic relations |
policies() | array<class-string, class-string> | Model → Policy bindings |
gates() | array<string, Closure> | Authorization gates |
seeders() | list<class-string> | Seeders for demo data |
workers() | list<WorkerDefinition> | Long-running processes (MQTT listeners, etc.) |
schedule(Schedule) | void | Register scheduled tasks |
Scope: site vs global
| Scope | Behavior |
|---|---|
site | Enabled per site via the site_modules table. The module:{id} middleware checks whether it is active for the current site |
global | Always active for all sites. Does not require individual enablement |
The ModuleRegistry offers scope-aware methods:
$registry->isEnabledForSite('indoor-location', $siteId); // bool
$registry->enabledModuleIdentifiersForSite($siteId); // Collection
$registry->alertTypesForSite($siteId); // array (enabled modules only)
$registry->isFeatureEnabledForSite('indoor-location', 'cold-room', $siteId); // bool
Sub-features (features)
Beyond enabling/disabling the entire module per site, a module can expose sub-features that are individually enablable via features(). Each sub-feature is a ModuleFeature (identifier, name, description, defaultEnabled), persisted in site_modules.settings.features.{identifier}. This is how Synapsys exposes panic-button, asset-tracking, and cold-room (Cold Room). Details in Module Contract.
module.json
Each module has a module.json at its root with metadata:
{
"identifier": "indoor-location",
"name": "Synapsys",
"version": "1.0.0",
"description": "BLE-based indoor location tracking with panic alerts...",
"scope": "site",
"authors": [
{ "name": "Iongrade", "email": "dev@iongrade.com" }
],
"requires": [],
"laravel": "^12.0"
}
Directory structure of a module
app/Modules/IndoorLocation/
├── module.json # Metadata
├── IndoorLocationServiceProvider.php # Registration + Boot
├── IndoorLocationModule.php # Implements ModuleContract
├── Actions/ # Business logic
├── Alerts/Handlers/ # Alert handler implementations
├── Reports/Handlers/ # Report handler implementations
├── Console/Commands/ # Artisan commands
├── Contracts/ # Module interfaces
├── Drivers/ # Hardware drivers
├── Enums/ # Specific enums
├── Http/
│ ├── Controllers/Web/ # Inertia controllers
│ ├── Controllers/Api/V1/ # API controllers
│ ├── Middleware/ # Module middleware
│ └── Requests/ # Form Requests
├── Jobs/ # Queued jobs
├── Models/ # Eloquent models
├── Policies/ # Authorization policies
├── Services/ # Business services
├── config/ # Config files
├── database/
│ ├── migrations/ # Migrations
│ ├── factories/ # Model factories
│ └── seeders/ # Demo seeders
├── routes/
│ ├── web.php # Web routes
│ └── api.php # API routes
├── resources/js/
│ ├── entry.ts # Frontend entry point
│ ├── pages/ # Inertia pages
│ └── locales/ # i18n translations
└── docs/ # Module documentation
Modular frontend
Each module has an entry.ts that exports:
- Pages — Inertia pages auto-discovered via
import.meta.glob registerLocales()— Registers i18n translations at runtimeregisterSlots()— Registers components in the core's extensible slots
The core loads the entry points via module-loader.ts, integrating the pages and translations automatically.
module:{identifier} middleware
All module routes use the EnsureModuleEnabled middleware:
Route::middleware(['auth', 'module:indoor-location'])->group(function () {
// Routes only accessible if the module is enabled for the site
});
For scope: 'global' modules, the middleware always passes. For scope: 'site', it checks the site_modules table.
Next steps
- Module Contract — Full reference of all methods
- Creating a Module — Step-by-step guide
- Modular Frontend — How the frontend loads modules
- Alert Handlers — Creating alert handlers
- Report Handlers — Creating report handlers