Skip to content

Controller

Viames Marino edited this page Mar 26, 2026 · 7 revisions

Pair framework: Controller

Pair\Core\Controller is the base class for Pair MVC controllers.

It is responsible for:

  • loading the model for the current module
  • selecting the correct view for the current action
  • coordinating request flow, redirects, and UI errors
  • keeping controllers thin while models/views do the real work

In Pair, the controller is the orchestration layer. It should decide what happens next, not contain the business logic itself.

Constructor lifecycle

final public function __construct()

Current execution flow:

  1. loads Application, Router, and Translator singletons
  2. derives the controller name from the class name
  3. resolves the module path via reflection
  4. tells Translator which module is active
  5. loads the default model from model.php if $model was not already set
  6. runs _init()
  7. if the app is not headless and no view was selected yet, loads the action view or default

Because the constructor is final, controller setup belongs in _init().

Naming conventions

Given OrdersController:

  • module folder: modules/orders/
  • default model file: modules/orders/model.php
  • default model class: OrdersModel
  • view file for action edit: modules/orders/viewEdit.php
  • view class for action edit: OrdersViewEdit

That convention is what makes Pair able to autoload the MVC stack with very little configuration.

Main methods

_init(): void

Optional setup hook executed after the default model is ready and before the default view is autoloaded.

Use it for:

  • access checks
  • action-specific model swapping
  • preselecting a non-default view
  • headless controller setup

Example:

protected function _init(): void
{
    // Swaps the model only for export actions.
    $this->loadModelForActions('exportModel', ['exportCsv', 'exportXlsx']);

    // Forces a custom landing view when the route is /orders/default.
    if (($this->router->action ?: 'default') === 'default') {
        $this->setView('dashboard');
    }
}

setView(string $viewName): void

Loads and stores a view instance for the controller.

Current behavior:

  • looks for view<ViewName>.php
  • if that file is missing, falls back to viewDefault.php
  • instantiates <ControllerName>View<ViewName>
  • avoids reloading the same view class twice

Example:

public function createAction(): void
{
    // Uses modules/orders/viewEdit.php and OrdersViewEdit.
    $this->setView('edit');
}

renderView(): void

Validates that the current view is a real Pair\Core\View subclass and then calls display().

This is the method the application eventually uses to render HTML output for classic module requests.

loadModel(string $modelName): void

Loads a non-default model from the current module folder.

In OrdersController, this call:

$this->loadModel('exportModel');

expects:

  • file modules/orders/exportModel.php
  • class OrdersExportModel

Use it when one action needs a different query layer or a specialized form/export workflow.

loadModelForActions(string $modelName, array $actions): void

Conditional shortcut around loadModel(). If the current router action is in the provided list, the controller swaps the model automatically.

Example:

protected function _init(): void
{
    // Export actions use a specialized model with streaming queries.
    $this->loadModelForActions('exportModel', ['exportCsv', 'exportXlsx']);
}

getObjectRequestedById(string $class): ?ActiveRecord

Loads an ActiveRecord from the first route parameter (Router::get(0)).

Behavior:

  • throws when the id is missing
  • instantiates new $class($itemId)
  • throws again if the object is not loaded

This is one of the most-used controller helpers for edit/detail actions.

Example:

public function editAction(): void
{
    // Reads the id from /orders/edit/{id}.
    $order = $this->getObjectRequestedById(\App\Orm\Order::class);

    // Continue with the edit flow.
}

raiseError(ActiveRecord $object): void

Converts model/object validation failures into a controller-level exception.

It:

  • collects errors from getErrors()
  • builds one user-facing message
  • logs the failing object class
  • throws an exception for the current action flow

Use it after store() or other write operations when you want one consistent failure path.

redirectWithError(string $message, ?string $url = null): void

Shortcut for:

  • queueing an error toast
  • redirecting the user

It is useful in catch blocks and permission failures.

accessDenied(?string $message = null): void

Protected convenience method for authorization failures. It redirects to the module root (strtolower($this->name)) with a standard translated error title/message.

Full example: edit action with PRG

class OrdersController extends \Pair\Core\Controller {

    protected function _init(): void
    {
        // Export actions use a specialized model.
        $this->loadModelForActions('exportModel', ['exportCsv', 'exportXlsx']);
    }

    public function editAction(): void
    {
        try {
            // Loads the requested object from /orders/edit/{id}.
            $order = $this->getObjectRequestedById(\App\Orm\Order::class);

            if ($_SERVER['REQUEST_METHOD'] === 'POST') {
                // Applies submitted data.
                $order->note = trim($_POST['note'] ?? '');

                if ($order->store()) {
                    // Uses PRG after a successful save.
                    $this->toastRedirect('Saved', 'Order updated', 'orders/list');
                    return;
                }

                // Converts object validation errors into one exception.
                $this->raiseError($order);
            }

            // Exposes the object to the view.
            $this->order = $order;

            // Renders modules/orders/viewEdit.php.
            $this->setView('edit');
        } catch (\Throwable $e) {
            // Keeps the UI flow consistent on any failure.
            $this->redirectWithError($e->getMessage(), 'orders/list');
        }
    }
}

Example: headless or API-style module controller

protected function _init(): void
{
    // Disables automatic HTML view loading for this controller.
    $this->app->headless = true;
}

In this mode the constructor skips the default setView(...) call.

Secondary methods worth knowing

These methods are used less often directly, but they explain how the controller fits into the Pair runtime:

  • getView(): ?View Returns the currently selected view instance.
  • getState(string $name): mixed Proxy to Application::getState() for persistent UI state.
  • lang(string $key, string|array|null $vars = null): string Thin translation proxy, especially useful in AJAX flows.
  • __get(string $name): mixed Exposes controller properties and throws on unknown properties.
  • __set(string $name, mixed $value): void Lets actions assign ad-hoc data such as $this->order = $order.

Notes and caveats

  • Default model loading directly includes model.php, so module naming must stay consistent.
  • setView('missing') does not fail immediately if viewDefault.php exists; it falls back to the default view file.
  • getObjectRequestedById() always reads router position 0; if you need another position, the View helper is more flexible.
  • In headless mode ($app->headless = true), the constructor does not autoload a view.
  • Most controller helper failures throw generic exceptions, not only AppException.

See also: Model, View, Router, Application, Translator, ActiveRecord.

Clone this wiki locally