Skip to content
Nibiru docsv0.9.2

Modules

Building Nibiru modules — the second M in MMVC. Traits, plugins, interfaces, settings, observers.

Stable Reading time ~ 4 min Edit on GitHub

A module is a self-contained domain unit. It owns its config, its plugins (services), its traits (reusable methods), its interfaces, and optionally an MVC slice of its own. Modules are how Nibiru avoids the fat-controller problem without a service container.

Botanical anatomy of a Nibiru module, drawn as the layered petals of a lotus — outer petals, middle petals, inner petals, sepal layer, receptacle.
A module, layer by layer.
application/module/<name>/
├── <name>.php # main class (implements IModule, optionally SplSubject)
├── interfaces/ # contracts for plugins / external consumers
│ └── <name>.php
├── plugins/ # stateless services usable from controllers
│ ├── <thing>.php
│ └── <other>.php
├── settings/ # auto-discovered .ini files
│ ├── <name>.ini
│ └── <name>.production.ini
└── traits/ # reusable method groups
└── <name>.php

The Registry walks application/module/ at boot, discovers each module’s settings/*.ini, parses the section matching the module name (uppercased), and caches it for lookup via Registry::getInstance()->loadModuleConfigByName('users').

<?php
namespace Nibiru\Module\Users;
use Nibiru\Module as ModuleAdapter;
use Nibiru\Interfaces\IModule;
use Nibiru\Registry;
class Users extends ModuleAdapter implements IModule, \SplSubject
{
use Traits\Users;
const CONFIG_MODULE_NAME = 'users';
protected static \stdClass $usersRegistry;
protected \SplObjectStorage $observers;
public function __construct() {
$this->setUsersRegistry();
$this->observers = new \SplObjectStorage();
}
public function attach(\SplObserver $o): void { $this->observers->attach($o); }
public function detach(\SplObserver $o): void { $this->observers->detach($o); }
public function notify(): void {
foreach ($this->observers as $o) { $o->update($this); }
}
protected function setUsersRegistry(): void {
self::$usersRegistry = Registry::getInstance()
->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
}
}

The IModule interface is intentionally a marker — actual behaviour is all in your traits and plugins.

A plugin is a class controllers can instantiate to access module functionality:

application/module/users/plugins/user.php
<?php
namespace Nibiru\Module\Users\Plugin;
use Nibiru\Module\Users\Users;
use Nibiru\Pdo;
class User extends Users
{
public function isAuthorized(): bool {
return isset($_SESSION['auth']['user_id']);
}
public function checkForStandardUser(): bool {
return $this->isAuthorized()
&& ($_SESSION['auth']['role'] ?? '') === 'standard';
}
}

In a controller:

$this->user = new \Nibiru\Module\Users\Plugin\User();
if (!$this->user->isAuthorized()) {
View::forwardTo('/login');
}

Plugins inherit from the module class, so they share access to the registry, settings, and observer machinery.

A trait carries reusable method bodies the module class wants. Common pattern: form factories.

application/module/users/traits/users.php
<?php
namespace Nibiru\Module\Users\Traits;
use Nibiru\Form;
trait Users
{
public function loginForm(): string {
Form::create();
Form::addOpenDiv(['class' => 'form-group']);
Form::addInputTypeText([
'class' => 'form-control', 'name' => 'login',
'placeholder' => 'Username',
]);
Form::addCloseDiv();
Form::addOpenDiv(['class' => 'form-group']);
Form::addInputTypePassword([
'class' => 'form-control', 'name' => 'password',
'placeholder' => 'Password',
]);
Form::addCloseDiv();
return Form::addForm([
'method' => 'POST',
'action' => '/login',
'name' => 'loginForm',
]);
}
}

The main Users class brings it in via use Traits\Users;.

Each module can carry its own INI files. The Registry parses every *.ini in settings/ and looks for a section named after the module (uppercased):

; application/module/users/settings/users.ini
[USERS]
session.lifetime = 7200
password.min.length = 12
allowed.roles[] = "admin"
allowed.roles[] = "editor"
allowed.roles[] = "standard"

Read it back from anywhere:

$cfg = \Nibiru\Registry::getInstance()->loadModuleConfigByName('users');
$cfg->session_lifetime; / 7200
$cfg->password_min_length; / 12
$cfg->allowed_roles; / [admin, editor, standard]

Environment overlays: a file named users.production.ini is preferred over users.ini when APPLICATION_ENV=production.

Modules implementing SplSubject can broadcast events to attached observers without coupling to them. From the showcase, the analytics module notifies any attached tracker on every page-view:

// in a controller
$analytics = new \Nibiru\Module\Analytics\Analytics();
$analytics->attach(new \Nibiru\Module\Analytics\Plugin\Matomo());
$analytics->attach(new \Nibiru\Module\Analytics\Plugin\Plausible());
$analytics->trackPageView(); / internally calls notify()

Each observer’s update($subject) receives the analytics instance and pulls the event data it cares about.

From the showcase apps:

  • auth (TPMS) — session management, QR-code login, role-based access. Implements SplSubject so login/logout events can fan out to logging and audit modules.
  • cms (prod.maschinen-stockert.de) — content store keyed by controller path + language. Lets non-developers update site copy.
  • graph_mail (TPMS) — Microsoft Graph API wrapper for transactional email.
  • pdfgenerator (prod.maschinen-stockert.de) — generates machine catalogs from DB-driven templates.
  • machineryscout — Elasticsearch index management with traits for indexing, querying, and re-indexing.
  • assetmanager — central CSS/JS pipeline, used to swap themes per language.
  • analytics — observer-driven tracker fan-out (Matomo, etc.).

Module registration: how the framework finds your module

Section titled “Module registration: how the framework finds your module”

A module folder on disk is necessary but not sufficient. The framework also has to know to load it — that’s done with three position arrays in your [AUTOLOADER] config:

; application/settings/config/settings.development.ini
[AUTOLOADER]
iface.pos[] = "users" ; load application/module/users/interfaces/
iface.pos[] = "billing" ; load application/module/billing/interfaces/
trait.pos[] = "users" ; load application/module/users/traits/
trait.pos[] = "billing"
class.pos[] = "users" ; load application/module/users/users.php
class.pos[] = "billing"
class.plugin.pos[] = "" ; reserved

The names are lowercase folder names, exactly as they appear under application/module/. The framework’s Auto::loader() (called from the Dispatcher) walks each module in order, requiring its files.

Plugin classes live under the plural namespace Plugins:

application/module/billing/plugins/invoice.php
namespace Nibiru\Module\Billing\Plugins; / plural
class Invoice extends \Nibiru\Module\Billing\Billing { /* ... */ }

Mismatch the namespace and you’ll get autoloader misses. The CLI scaffold (./nibiru -m billing) generates the correct namespace for you.

You don’t register your module’s INI files — the Registry discovers them automatically by walking application/module/<name>/settings/*.ini after [AUTOLOADER] loads the module class. Each INI’s [<MODULE>] (uppercased) section becomes available as:

$cfg = \Nibiru\Registry::getInstance()->loadModuleConfigByName('billing');
$cfg->invoice_prefix; / [BILLING] invoice.prefix property

The Registry prefers <module>.<env>.ini (e.g. billing.production.ini) when APPLICATION_ENV matches.

If your module has database tables, drop SQL files in application/settings/config/database/ numbered after the existing range. Convention used by the AI module:

200-ai_rag_collection.sql
201-ai_rag_chunk.sql
202-ai_conversation.sql
203-ai_message.sql

Run with ./nibiru -mi local. The framework auto-generates models in application/model/<table>.php when [GENERATOR] database = true is set, ready to use via \Nibiru\Pdo::fetchAll(…).

Terminal window
./nibiru -m billing

Scaffolds:

application/module/billing/
├── billing.php
├── interfaces/billing.php
├── plugins/
├── settings/billing.ini
└── traits/

Add the -g switch (./nibiru -m billing -g) when you want Graylog logging hooks pre-wired into the scaffold.

Don’t promote every controller into a module. The threshold is: do you have at least one trait, one plugin, and one INI key? If yes, it earns its own module folder. If not, a shared trait file or a small application/lib/ helper is plenty.