Skip to main content

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 ModuleRegistry via $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

MethodReturnExample
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

MethodReturnPurpose
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)voidRegister scheduled tasks

Scope: site vs global

ScopeBehavior
siteEnabled per site via the site_modules table. The module:{id} middleware checks whether it is active for the current site
globalAlways 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 runtime
  • registerSlots() — 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