Patterns from Production
Concrete patterns used across Nibiru apps in production — copy-paste-ready.
Seven patterns that show up repeatedly across Nibiru production apps. Each is small, copy-paste-ready, and rooted in a real codebase.
1. Thin controller → module-plugin delegation
Section titled “1. Thin controller → module-plugin delegation”Keep controllers tiny. Push logic into module plugins.
// thinclass erpController extends Controller { public function syncAction(): void { View::forwardToJsonHeader(); $result = \Nibiru\Module\Erp\Plugin\Sync::run(); View::assign(['data' => $result]); }}// fatclass Sync extends Erp { public static function run(): array { $svc = AlphaplanSyncService::getInstance(); try { return ['success' => true, 'changes' => $svc->syncAbDocuments()]; } catch (\Throwable $e) { return ['success' => false, 'error' => $e->getMessage()]; } }}The controller is reviewable in 5 seconds; the plugin is unit-testable.
2. CMS as content source
Section titled “2. CMS as content source”Decouple text from layout. Editors update copy via the CMS module’s UI; templates stay developer-owned.
public function pageAction() { $path = $this->getController() . '/' . $this->getRequest('_action', 'page'); foreach (Cms::init($this->getController()) ->loadCmsTemplateTextsByControllerPath($path, $this->language) as $t) { View::assign([ $t['cms_template_texts_text_identifier'] => $t['cms_template_texts_text_content'] ]); }}Templates reference the identifiers as if they were ordinary variables:
<h1>{$hero_title}</h1><p>{$hero_intro}</p>3. Observer-driven analytics
Section titled “3. Observer-driven analytics”Multiple trackers without controller coupling.
$analytics = new Analytics();$analytics->attach(new Plugin\Matomo());$analytics->attach(new Plugin\Plausible());$analytics->trackPageView(); / calls notify() internallyEach observer’s update($subject) pulls only the fields it cares about. Adding a tracker is a one-line change.
4. Multi-navigation composition
Section titled “4. Multi-navigation composition”Build pages with several named nav arrays instead of one monolithic structure.
public function navigationAction() { foreach (['head', 'main', 'social', 'footer'] as $name) { JsonNavigation::getInstance()->loadJsonNavigationArray($name); }}<header>{include file="navigation.tpl" array=$head}</header><aside>{include file="navigation.tpl" array=$main}</aside><footer>{include file="navigation.tpl" array=$footer}</footer>Each JSON file is small, scoped, easy to edit, and conflict-free in PRs.
5. JSON endpoints with forwardToJsonHeader
Section titled “5. JSON endpoints with forwardToJsonHeader”Standard contract for AJAX:
public function searchAction() { View::forwardToJsonHeader(); $q = trim($_REQUEST['q'] ?? ''); if (strlen($q) < 2) { View::assign(['data' => ['results' => []]]); return; } View::assign(['data' => [ 'results' => MachineryScout::index()->search($q), ]]);}Headers are set automatically; no manual header('Content-Type: application/json').
6. Multi-stage workflow via actions
Section titled “6. Multi-stage workflow via actions”State machines mapped onto controller actions:
class quotesController extends Controller { public function pageAction() { /* list view */ } public function detailAction() { /* one quote */ } public function acceptAction() { /* state transition: open → accepted */ } public function rejectAction() { /* state transition: open → rejected */ } public function archiveAction() { /* state transition: any → archived */ }}/quotes/accept/42 runs acceptAction() with $_REQUEST['id'] = 42. Each transition is a tiny action; persistence and notification go through a QuotesService plugin.
7. Schema-first models with one custom method per intent
Section titled “7. Schema-first models with one custom method per intent”Generate the model from schema, then add intent-named methods that wrap your queries:
class users extends Db{ const TABLE = ['table' => 'users', 'field' => [/* … */]]; public function __construct() { self::initTable(self::TABLE); }
public function findByLogin(string $login): ?array { return Pdo::fetchRow('SELECT * FROM users WHERE user_login = :l', [':l' => $login]) ?: null; }
public function activeStandardUsers(): array { return Pdo::fetchAll( 'SELECT * FROM users WHERE user_account_active = 1 AND user_role = :r', [':r' => 'standard'] ); }}Future-you reading the call site sees findByLogin($login) — the intent — not raw SQL.
Anti-patterns to avoid
Section titled “Anti-patterns to avoid”- Static-buffer leakage in forms. Always
Form::create()before building. - Logic in
navigationAction(). It runs on every request, including JSON endpoints. - Mass
View::assign()without a structured array. UseView::assign(['…'])once. - Custom routes for what the SEO URL already does.
/products/<slug>/<id>is free. - Editing generated models. They get overwritten. Custom methods → child class or
database.overwrite = false.