---
title: Controllers
description: Writing Nibiru controllers — the action lifecycle, View::assign, and patterns from production code.
---

A Nibiru controller is a class that extends `Nibiru\Adapter\Controller`, lives in `application/controller/<name>Controller.php`, and is loaded automatically by the [Dispatcher](/core/dispatcher/) when a URL like `/<name>/...` arrives.

## Anatomy of a controller

```php
<?php
namespace Nibiru;
use Nibiru\Adapter\Controller;

class productsController extends Controller
{
    public function pageAction() {
        View::assign([
            'title'    => 'Products',
            'products' => $this->loadProducts(),
        ]);
    }

    public function navigationAction() {
        JsonNavigation::getInstance()->loadJsonNavigationArray();
    }

    public function detailAction() {
        $id = (int) ($_REQUEST['id'] ?? 0);
        View::assign(['product' => $this->loadProduct($id)]);
    }

    private function loadProducts(): array { /* ... */ return []; }
    private function loadProduct(int $id): array { /* ... */ return []; }
}
```

## The action lifecycle

When the dispatcher invokes a controller, it calls methods in this fixed order:

1. **`navigationAction()`** — populate global menus, breadcrumbs, role-aware nav.
2. **`<verb>Action()`** — only if `?_action=<verb>` is set or the URL has a second segment that names an action.
3. **`pageAction()`** — last call before render.

Both `navigationAction()` and `pageAction()` are **always called**, even for unknown actions. This is convenient (you never need to check) but can surprise you if you assume actions are exclusive.

## Talking to the view

`View::assign(['key' => $value, ...])` is how data reaches templates. It's static and can be called as many times as you want — later calls overwrite earlier ones.

```php
View::assign(['title' => 'Products']);
View::assign(['products' => $list]);

// In templates/products.tpl:
//   {$title}     →  "Products"
//   {$products}  →  the array
```

Convenience helpers from the base controller:

```php
$this->getRequest('id', false);   // $_REQUEST['id'] ?? false
$this->getPost('email', '');      // $_POST['email']  ?? ''
$this->getGet('page', 1);         // $_GET['page']    ?? 1
$this->getServer('REQUEST_URI');  // $_SERVER['REQUEST_URI']
$this->getFiles('upload');        // $_FILES['upload']
$this->getSession('auth');        // $_SESSION['auth']
```

These exist because `Controller` is `final`-friendly: you can mock them in tests by substituting a child class.

## Forwarding

To redirect inside an action:

```php
View::forwardTo('/login');             // 302 to the URL, exits
View::forwardToJsonHeader();           // sets Content-Type: application/json
```

`forwardToJsonHeader()` is the canonical pattern for JSON endpoints — set the header, assign `data`, return. The view layer does the rest.

## Multiple actions per controller

Nibiru is happy to host any number of actions per controller. The TPMS `erpController` from production has `pageAction`, `navigationAction`, plus `syncAction`, `statusAction`, `dryRunAction`, `cancelAction`, etc. — each invoked via `?_action=sync` or `/erp/sync`.

```php
// /erp/sync  →  $_REQUEST['_action'] = 'sync'
public function syncAction(): void
{
    View::forwardToJsonHeader();
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        View::assign(['data' => ['success' => false, 'error' => 'POST method required']]);
        return;
    }
    $result = AlphaplanSyncService::getInstance()->syncAbDocuments();
    View::assign(['data' => $result]);
}
```

## Working with modules

Controllers are thin. Most logic should live in modules and their plugins:

```php
namespace Nibiru;
use Nibiru\Adapter\Controller;
use Nibiru\Module\Users\Plugin\User;

class loginController extends Controller
{
    private User $user;

    public function __construct() {
        parent::__construct();
        $this->user = new User();
    }

    public function authAction() {
        if (!$this->user->isAuthorized()) {
            View::assign(['loginForm' => $this->user->loginForm()]);
        } else {
            View::forwardTo('/index');
        }
    }
}
```

This *delegation* pattern is consistent across the showcase apps: controllers orchestrate, modules do the work.

## Multi-language / CMS-driven content

A pattern from `prod.maschinen-stockert.de` — load all on-page text from a CMS table keyed by controller path:

```php
public function pageAction() {
    $controllerPath = $this->getController()
        . '/' . $this->getRequest('_action', 'page');

    $texts = Cms::init($this->getController())
        ->loadCmsTemplateTextsByControllerPath($controllerPath, $this->language);

    foreach ($texts as $t) {
        View::assign([
            $t['cms_template_texts_text_identifier'] =>
            $t['cms_template_texts_text_content']
        ]);
    }
}
```

Result: non-developers can change copy without touching code. The CMS module owns the table and the editor UI; the controller just loads strings.

## Generating controllers

The CLI scaffolds a controller and its template in one shot:

```bash
./nibiru -c products
```

→ `application/controller/productsController.php`
→ `application/view/templates/products.tpl`

Both are populated with the canonical skeleton. You're ready to write.
