OpenMES can be extended with modules that plug into the core without editing it. A module hooks into three kinds of extension point:
- Domain events — react to production activity (an order is saved, a step completes, any resource changes, a work order is scheduled).
- Menu hooks — add links and dropdowns to the sidebar.
- Dashboard widget hooks — add cards to the admin dashboard.
New to modules? Start with the step-by-step tutorial —
backend/modules/README.md— which builds a working module from an empty folder and maps every core piece. This file is the reference (every hook + payload); the tutorial is the how-to.
Three reference modules ship in the repo (all disabled by default):
backend/modules/ExampleShowcase— exercises every hook; copy it as a starting point.backend/modules/ExampleHooks— a smaller "hello world".backend/modules/OrderPinger— the output of the step-by-step tutorial, kept as its tested proof.
- How modules work
- Module structure
- 1. Domain event hooks
- 2. Menu hooks
- 3. Dashboard widget hooks
- 4. Page display hooks and filters
- Enabling a module
- Best practices
- Complete hook reference
- A module lives in
backend/modules/<Name>/and is described by amodule.jsonmanifest. TheModules\namespace is PSR-4-mapped to that directory, so module classes autoload with no extra config. App\Services\ModuleManagerdiscovers modules (discover()) and, for the ones that are enabled, registers their service provider on every boot (loadEnabled()inAppServiceProvider). A disabled module's provider never boots — zero listeners, zero menu entries, zero widgets, zero runtime cost.- All wiring happens in the module's
ServiceProvider::boot().
There is no config/app.php editing and no composer.json provider entry — a
module is dropped into backend/modules/ and toggled in the UI.
backend/modules/YourModule/
├── module.json # manifest (name, provider, declared hooks)
├── Hooks.php # your event-handler methods (optional convention)
├── Providers/
│ └── YourModuleServiceProvider.php # boot(): wires events + menu + widgets
├── routes.php # your own page routes (optional)
├── views/ # your own Blade pages/partials (optional)
└── README.md
{
"name": "YourModule",
"display_name": "Your Module",
"version": "1.0.0",
"description": "What it does.",
"author": "You",
"provider": "Modules\\YourModule\\Providers\\YourModuleServiceProvider",
"hooks": [
"WorkOrder\\WorkOrderCreated",
"Resource\\ResourceChanged",
"Menu\\addItem"
],
"requires": []
}provider is a single provider class string (must start with Modules\).
hooks is documentation only — the actual wiring is in the provider.
namespace Modules\YourModule\Providers;
use App\Events\WorkOrder\WorkOrderCreated;
use App\Services\MenuRegistry;
use App\Services\WidgetRegistry;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\YourModule\Hooks;
class YourModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Own routes/views first (menu links below may point at them).
$this->loadViewsFrom(__DIR__.'/../views', 'your-module');
$this->loadRoutesFrom(__DIR__.'/../routes.php');
// 1) Domain events
Event::listen(WorkOrderCreated::class, [Hooks::class, 'onWorkOrderCreated']);
// 2) Menu hooks
$menu = app(MenuRegistry::class);
$menu->addItem('production', 'My Page', url('/modules/your-module'));
// 3) Widget hooks
app(WidgetRegistry::class)->register('kpi', [
'title' => 'My metric', 'metric' => '42',
]);
}
}Hooks are plain Laravel events. Listen to them in your provider's boot().
Handlers may be a class with a handle() method, an invokable, a closure, or a
[Class::class, 'method'] pair (the PrestaShop-style "one method per hook" that
ExampleShowcase/Hooks.php uses).
use App\Events\WorkOrder\WorkOrderCompleted;
Event::listen(WorkOrderCompleted::class, function (WorkOrderCompleted $e) {
ExternalErp::notifyCompletion($e->workOrder); // read + act, never mutate the order
});The lifecycle events — WorkOrderCreated/Updated/Completed, BatchCreated,
StepStarted/Completed — fire from the model layer (observers /
$dispatchesEvents), so they trigger on every save path (admin UI, CSV import,
ERP API, services) without each caller opting in. The others fire from a specific
place: ResourceChanged from a wildcard Eloquent listener, WorkOrderScheduled
from the planner controller, UserAssignedToLine from line assignment, and the
machine events from the signal pipeline.
| Event | Fires when | Payload |
|---|---|---|
WorkOrder\WorkOrderCreated |
a work order is created | workOrder |
WorkOrder\WorkOrderUpdated |
a work order is updated | workOrder, changes (array of changed attributes) |
WorkOrder\WorkOrderCompleted |
a work order first enters DONE |
workOrder |
Batch\BatchCreated |
a batch is created | batch |
BatchStep\StepStarted |
a step enters IN_PROGRESS |
batchStep |
BatchStep\StepCompleted |
a step enters DONE |
batchStep |
Machine\WorkstationStateChanged |
the signal pipeline changes a workstation state | workstation, from, to, state |
MachineMessageReceived |
an inbound MQTT message is parsed | message |
User\UserAssignedToLine |
a user is assigned to a line | user, line |
Resource\ResourceChanged |
any curated resource is created/updated/deleted | model, action |
Schedule\WorkOrderScheduled |
a work order is (re)placed on the planner | workOrder, changes |
See Machine Connectivity for the signal-pipeline
events (WorkstationStateChanged, MachineMessageReceived).
Instead of a separate event per entity, one event covers create/update/delete
for every user-CRUD-able resource — work orders, customers, materials, lines,
product types, and every other entry in App\Support\SoftDeleteRegistry::MODELS.
A single wildcard Eloquent listener re-dispatches it, so you hook any resource
save without wiring each model.
use App\Events\Resource\ResourceChanged;
use App\Models\Customer;
Event::listen(ResourceChanged::class, function (ResourceChanged $e) {
// $e->action is 'created' | 'updated' | 'deleted'
// $e->model is the affected model; $e->type() is its short class name
if ($e->model instanceof Customer && $e->action === 'created') {
Crm::push($e->model);
}
});The typed events (WorkOrderCreated, BatchCreated, …) still fire too — use
those when you care about one specific entity, and ResourceChanged when you want
"any resource".
Fired from the planner whenever a work order's placement changes — assigned to a line, moved to another day/shift, resized, or unassigned.
use App\Events\Schedule\WorkOrderScheduled;
Event::listen(WorkOrderScheduled::class, function (WorkOrderScheduled $e) {
// $e->changes = the placement fields that were written
Calendar::sync($e->workOrder, $e->changes);
});App\Services\MenuRegistry lets a module add entries to the sidebar. They are
bridged to the React frontend as the moduleNav Inertia prop and rendered by
AppLayout. Because module pages are server-rendered, menu links do a full page
load (not an Inertia visit).
$menu = app(MenuRegistry::class);
// (a) inject a link into a built-in dropdown
// keys: orders | production | structure | hr | maintenance | admin
$menu->addItem('production', 'My Page', url('/modules/your-module'), order: 90);
// (b) declare your own top-level dropdown …
$menu->addGroup('yourmod', 'Your Module', order: 55);
// (c) … and add links to it
$menu->addGroupItem('yourmod', 'Overview', url('/modules/your-module'), order: 10);Resolve URLs with url() (not route()) so registration never depends on route
load order or a cached route table.
Every entry a module contributes is tinted with the accent colour in the sidebar, and lights up as active on its own pages (the registered URL is matched by path).
A module that ships an operator screen registers it as a tab on the operator top bar, next to Queue / Workstation:
// label, url, order (built-in tabs are 10 and 20), optional path prefix that
// keeps the tab highlighted (defaults to the link's own path).
$menu->addOperatorItem('Team', url('/operator/team'), order: 30);Operator tabs are Inertia links — the page behind them is a React page under the
module's resources/js/Pages/. They arrive in the browser as moduleNav.operator
and render tinted like sidebar entries.
A module's own strings live in modules/<Name>/lang/<locale>.json (the same
source-string-keyed shape as core's lang/*.json). The frontend merges them
under the core file at bootstrap — a module can add strings, never redefine a
core one — and the provider loads the same directory for PHP:
$this->loadJsonTranslationsFrom(__DIR__.'/../lang');ImportRegistry (Admin → Import) accepts importers from modules through the
import.entities filter:
app(FilterRegistry::class)->addFilter('import.entities', fn ($e) => [...$e, MyImporter::class]);MyImporter extends App\Import\AbstractEntityImporter; the screen, the queued
job, the sample file and the routes pick it up.
Tests\Support\ModuleTestCase registers the module's provider on each test's
fresh application and migrates the module's directory inside the test
transaction (the suite's migrate:fresh runs before any provider boots, so a
module's tables are never part of that schema):
class MyModuleTest extends \Tests\Support\ModuleTestCase
{
protected string $module = 'MyModule';
protected string $provider = \Modules\MyModule\Providers\MyModuleServiceProvider::class;
protected string $probeTable = 'my_module_things';
}App\Services\WidgetRegistry lets a module add cards to the admin dashboard.
A widget is structured data (not a Blade view): the React dashboard renders a
standard card and escapes every field, so a module never ships raw HTML. It is
exposed as the moduleWidgets prop by DashboardController.
$widgets = app(WidgetRegistry::class);
$widgets->register('kpi', [
'title' => 'Open jobs',
'metric' => (string) $count, // optional big number
'body' => 'Awaiting start', // optional caption
'href' => url('/modules/your-module'), // optional link
'external' => true, // optional: full page load
], order: 50);Zones: kpi and sidebar render as compact cards in the grid under the core
KPIs; main renders as a full-width card at the bottom of the dashboard column.
App\Extension\HookRegistry carries contributions to named points on a page; the
controller that owns the page resolves its points with renderMany() and hands them
over as the hooks prop, and the page renders <Hook name=… hooks={hooks} …context />
from resources/js/lib/hooks.jsx. App\Extension\FilterRegistry lets a module
change a value core computed. With no module listening, a page is sent {} and every
filter returns its default — a community install renders exactly what it did before.
A point that replaces a core control (marked below) checks hasHook() first and
skips its own control when a module contributed.
app(HookRegistry::class)->listen('display.operator.work_order.sections', fn (array $ctx) => [
'title' => 'Recipe vs weighed',
'body' => Recipe::summary($ctx['workOrderId']),
]);
app(FilterRegistry::class)->addFilter('operator.can_logout', fn (bool $can, array $ctx) => $can && ! Panel::isShared($ctx['user']));A
component(ext:<Dir>/<Name>, resolved undermodules/<Name>/resources/js/Components/) only exists in a build that contained the module. A module installed from a ZIP into a released install must contribute the plain card fields instead.
display.operator.workstation.shift_cell context: entry, workOrder, shift, canCorrect (replaces the whole shift cell)
display.operator.work_order.sections context: workOrder (rendered after the BOM section)
display.operator.quantity_field props: value, onChange, variant (replaces the operator's number input, page-wide)
display.operator.layout (no context) (rendered at the top of every operator screen)
display.settings.system.tabs contribution: slot, title, component (one tab each on Settings → System)
The PHP-side context a listener receives:
| Hook | Resolved by | PHP context |
|---|---|---|
display.operator.workstation.shift_cell |
Operator\WorkstationController::index |
line, workstation, lineId, workstationId |
display.operator.work_order.sections |
Operator\WorkOrderController::show |
workOrderId, workstationId |
display.operator.quantity_field |
WorkstationController::index, WorkOrderController::show + ::queue |
same as the page's other points |
display.operator.layout |
HandleInertiaRequests (shared operatorHooks) |
user |
display.settings.system.tabs |
SettingsController::showSystemSettings |
— |
Notes:
quantity_field—QuantityField.jsxrenders the first contribution's component withvalue,onChange(value)(the value, not an event),variant(bigfor a modal's single field,compactinline) and every other input prop (min,max,step,aria-label, …) passed through.settings.system.tabs— each contribution becomes a tab with valueext-<slot>(linkable as?tab=ext-<slot>) labelledtitle. Its component renders outside the core settings form, so core's Save button is not shown there: the module saves through its own route.operator.layoutis resolved on every Inertia response, like any shared prop — a listener should returnnullcheaply where it has nothing to show.
operator.tabs list of {key, label, url, prefixes} the operator top bar's core tabs
operator.module_tabs list of {label, url, order, prefix} module tabs, per request
operator.can_logout bool (default true) whether the operator chrome shows its logout button
operator.tabs and operator.can_logout receive ['user' => $user] as context.
operator.module_tabs runs over what addOperatorItem() registered, on every
request, so a module can show a tab only where it applies without re-registering.
operator.can_logout hides the button only — a module that forbids logout must
still refuse the POST /logout itself.
- Admin → Modules → your module → Enable (or add its name to the
system_settings.modules_enabledset). - On a production install with cached routes, run
php artisan route:cacheafter enabling — module routes are registered at boot, not baked into the core route file. (Under Octane, reload workers so the newly enabled provider boots:php artisan octane:reload.)
Disable it again and every hook detaches — back to zero runtime cost.
- Never mutate core state from an event handler. Handlers observe; they must not save/update the core models they are notified about, or you can cause double counts, re-entrant events and broken transactions. Do your side effects (notify, export, write your own tables) instead.
- Keep listeners focused — one listener per concern. Register several rather than one giant closure.
- Queue heavy work — implement
ShouldQueueon listeners that call slow external systems, so they run in the background. - Handle your own errors — wrap external calls in try/catch and log; don't let your failure break the user's save or other listeners.
- Don't rely on listener order across modules.
- Version and document your module (
README.md, semanticversion).
use Illuminate\Contracts\Queue\ShouldQueue;
class SyncToErp implements ShouldQueue
{
public function handle(\App\Events\WorkOrder\WorkOrderCompleted $e): void
{
try {
Erp::sync($e->workOrder);
} catch (\Throwable $ex) {
\Log::error('ERP sync failed', ['wo' => $e->workOrder->id, 'err' => $ex->getMessage()]);
}
}
}Domain events (App\Events\…): WorkOrder\WorkOrderCreated,
WorkOrder\WorkOrderUpdated, WorkOrder\WorkOrderCompleted, Batch\BatchCreated,
BatchStep\StepStarted, BatchStep\StepCompleted,
Machine\WorkstationStateChanged, MachineMessageReceived,
User\UserAssignedToLine, Resource\ResourceChanged (generic CRUD),
Schedule\WorkOrderScheduled.
Menu (App\Services\MenuRegistry): addItem, addGroup, addGroupItem.
Widgets (App\Services\WidgetRegistry): register — zones kpi, main,
sidebar.
Display hooks (App\Extension\HookRegistry): display.operator.workstation.actor,
display.operator.workstation.shift_cell, display.operator.work_order.sections,
display.operator.quantity_field, display.operator.layout,
display.settings.system.tabs.
Filters (App\Extension\FilterRegistry): operator.tabs,
operator.module_tabs, operator.can_logout, import.entities.
- Issues: https://github.com/Mes-Open/OpenMes/issues
- Want a new hook? Open an issue with the name, when it should fire, its payload, and your use case.