Skip to content
Viames Marino edited this page Mar 26, 2026 · 4 revisions

Pair framework: Model

Pair\Core\Model is the base class for Pair MVC models.

It provides:

  • framework/application access ($app)
  • DB access (Database singleton)
  • reusable pagination + ordering helpers
  • common item listing/counting for ActiveRecord
  • internal error list helpers

Constructor and lifecycle

final public function __construct()

Constructor workflow:

  1. injects Application::getInstance() into $app
  2. injects Database::getInstance() into $db
  3. executes _init() hook inside try/catch

_init() is optional and meant for model setup:

protected function _init(): void {}

Important: exceptions thrown by _init() are swallowed in current implementation. For this reason _init() should be used for lightweight setup, not for critical validation that must stop the request.

Typical override:

protected function _init(): void
{
    // Initializes default runtime state for later queries.
    $this->defaultStatus = 'active';
}

Magic methods

  • __get(string $name): mixed
  • __set(string $name, mixed $value): void
  • __call(string $name, array $arguments): void

Behavior:

  • unknown property access throws Exception with ErrorCodes::PROPERTY_NOT_FOUND
  • unknown method calls throw Exception with ErrorCodes::METHOD_NOT_FOUND
  • __set() allows injecting optional runtime dependencies (for example pagination)

Error tracking helpers

  • addError(string $message): void
  • getLastError(): array|bool
  • getErrors(): array

Although the signature currently says array|bool, the implementation effectively returns the latest error message string or false.

Usage:

// Registers a human-readable error for the controller/view layer.
$model->addError('Invalid filter');

// Returns all tracked errors in insertion order.
$all = $model->getErrors();

// Returns the latest message or false if no errors were stored.
$last = $model->getLastError();

Pagination and list helpers

getActiveRecordObjects(string $class, ?string $orderBy = null, bool $descOrder = false): Collection

Legacy helper that:

  • validates class is an ActiveRecord subclass
  • sets $this->pagination->count = $class::countAllObjects()
  • builds SQL with ORDER BY and LIMIT
  • returns Collection

Requires a non-null pagination object (Pair\Html\Pagination) with start and limit. In modern Pair modules this object is usually injected automatically by View::__construct().

getOrderOptions(): array

Default implementation returns []. Override in child models to expose sortable columns.

getOrderLimitSql(array $orderOptions = []): string

Builds ORDER BY + LIMIT using:

  • router sort value (Router::getInstance()->order)
  • provided options or getOrderOptions()
  • pagination->start and pagination->limit

This is the method that keeps your model aligned with sortable table headers rendered by View::sortable().

Example:

protected function getOrderOptions(): array
{
    return [
        1 => '`createdAt` DESC',
        2 => '`total` DESC',
        3 => '`customerName` ASC',
    ];
}

Main query methods

getItems(string $class, Query|string|null $optionalQuery = null): Collection

Generic item fetch for ActiveRecord classes.

Behavior:

  • invalid class returns empty Collection
  • query source is $optionalQuery if provided, otherwise getQuery($class)
  • if query is Query, bindings are extracted and SQL generated
  • ORDER/LIMIT from getOrderLimitSql() are appended only when $optionalQuery is null

This is the main method you usually call from a view. In the common Pair flow:

  1. the view creates and injects pagination into the model
  2. getItems() loads the current page of rows
  3. countItems() loads the total for the pagination bar

Example in a view:

protected function render(): void
{
    // Loads the current page using the model default query.
    $orders = $this->model->getItems(\App\Orm\Order::class);

    // Loads the total count for pagination.
    $this->pagination->count = $this->model->countItems(\App\Orm\Order::class);

    $this->assign('orders', $orders);
}

countItems(string $class, Query|string|null $optionalQuery = null): int

Count helper aligned with query type:

  • Query -> uses optimized $query->count()
  • string -> wraps SQL in SELECT COUNT(1) FROM (...)
  • fallback -> 0

Use it together with getItems() whenever the page can paginate or sort a dataset.

getQuery(string $class): Query|string

Default list query:

return Query::table($class::TABLE_NAME);

Override this in child models to define module-specific default datasets.

This is usually the best place to centralize list filters shared by the whole module.

Example:

protected function getQuery(string $class): Query|string
{
    return Query::table($class::TABLE_NAME)
        ->where('deleted', '=', 0)
        ->where('tenantId', '=', $this->app->currentUser->tenantId);
}

Example: model with sortable paginated list

<?php

namespace App\Modules\orders;

use Pair\Core\Model;
use Pair\Orm\Query;

class OrdersModel extends Model {

    protected function getOrderOptions(): array
    {
        // Maps View::sortable() order values to SQL fragments.
        return [
            1 => '`createdAt` DESC',
            2 => '`total` DESC',
            3 => '`customerName` ASC',
        ];
    }

    protected function getQuery(string $class): Query|string
    {
        // Defines the default dataset used by getItems()/countItems().
        return Query::table($class::TABLE_NAME)
            ->where('deleted', '=', 0);
    }

}

Controller/service usage:

// The view usually injects pagination automatically.
$model->pagination = $pagination;

// Loads the current page of rows.
$items = $model->getItems(\App\Orm\Order::class);

// Loads the full count for the pagination bar.
$total = $model->countItems(\App\Orm\Order::class);
$model->pagination->count = $total;

Example: custom query with bindings

// Builds an ad-hoc filtered query.
$query = \Pair\Orm\Query::table('orders')
    ->where('status', '=', 'paid');

// getItems()/countItems() reuse the bindings from Query automatically.
$rows = $model->getItems(\App\Orm\Order::class, $query);
$total = $model->countItems(\App\Orm\Order::class, $query);

Example: standalone model outside a view

If you use a model in a service or CLI script, remember that pagination is not injected automatically.

use Pair\Html\Pagination;

$model = new OrdersModel();
$model->pagination = new Pagination();
$model->pagination->page = 1;
$model->pagination->perPage = 20;

// Loads the first 20 rows using the model default query.
$rows = $model->getItems(\App\Orm\Order::class);

Secondary methods worth knowing

These methods are used less often directly, but they explain how Model fits into the Pair MVC stack:

  • __get(string $name): mixed Reads internal properties such as pagination and throws on unknown properties.
  • __set(string $name, mixed $value): void Commonly used by View to inject pagination.
  • __call(string $name, array $arguments): void Throws METHOD_NOT_FOUND for undefined methods.
  • getActiveRecordObjects(string $class, ?string $orderBy = null, bool $descOrder = false): Collection Legacy helper that still works well for simple paginated lists.
  • getOrderLimitSql(array $orderOptions = []): string Internal helper for child models that support sortable paginated listings.

Notes and caveats

  • Model expects Pair ORM classes (ActiveRecord, Collection, Query) for best integration.
  • When using getItems() with custom $optionalQuery, ordering/limit are not auto-appended.
  • getActiveRecordObjects() and getOrderLimitSql() assume pagination is configured.
  • If _init() throws, the exception is silently swallowed by the constructor.

See also: Controller, ActiveRecord, Query, Pagination, Database.

Clone this wiki locally