Skip to content
Nibiru docsv0.9.2

Patterns from Production

Concrete patterns used across Nibiru apps in production — copy-paste-ready.

Stable Reading time ~ 2 min Edit on GitHub

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.

// thin
class erpController extends Controller {
public function syncAction(): void {
View::forwardToJsonHeader();
$result = \Nibiru\Module\Erp\Plugin\Sync::run();
View::assign(['data' => $result]);
}
}
// fat
class 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.

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>

Multiple trackers without controller coupling.

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

Each observer’s update($subject) pulls only the fields it cares about. Adding a tracker is a one-line change.

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').

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.

  • 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. Use View::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.