Modules
Building Nibiru modules — the second M in MMVC. Traits, plugins, interfaces, settings, observers.
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.
Anatomy
Section titled “Anatomy”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>.phpThe 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').
A minimal module
Section titled “A minimal module”<?phpnamespace 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.
Plugins: stateless services
Section titled “Plugins: stateless services”A plugin is a class controllers can instantiate to access module functionality:
<?phpnamespace 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.
Traits: reusable methods
Section titled “Traits: reusable methods”A trait carries reusable method bodies the module class wants. Common pattern: form factories.
<?phpnamespace 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;.
Module settings (INI)
Section titled “Module settings (INI)”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 = 7200password.min.length = 12allowed.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.
The observer pattern
Section titled “The observer pattern”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.
Real production modules
Section titled “Real production modules”From the showcase apps:
auth(TPMS) — session management, QR-code login, role-based access. ImplementsSplSubjectso 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.phpclass.pos[] = "billing"class.plugin.pos[] = "" ; reservedThe 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 namespace convention
Section titled “Plugin namespace convention”Plugin classes live under the plural namespace Plugins:
namespace Nibiru\Module\Billing\Plugins; / ← pluralclass 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.
Settings discovery
Section titled “Settings discovery”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 → propertyThe Registry prefers <module>.<env>.ini (e.g. billing.production.ini) when APPLICATION_ENV matches.
Migrations
Section titled “Migrations”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.sql201-ai_rag_chunk.sql202-ai_conversation.sql203-ai_message.sqlRun 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(…).
Generating a module
Section titled “Generating a module”./nibiru -m billingScaffolds:
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.
When not to make a module
Section titled “When not to make a module”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.