Pop PHP
The Basics

Models

A model is where the work happens. Routing picks a controller, the controller reads the request and sends a response, and everything between — the query, the calculation, the call to a third-party API — belongs in a class of your own.

What a Model Is in Pop#

Nothing in the framework treats a model specially. There's no model registry, no lifecycle, no base class the router looks for. A model is a plain PHP class in app/src/Model/, and the reason to give the directory that name is so a reader of your application knows where to look.

What Pop does supply is a shared ancestor. Pop\Utils\AbstractModel is an empty abstract class extending Pop\Utils\ArrayObject, so every model in an application has one type in common — useful for a type hint, a instanceof check or a collection of mixed models — and inherits array and object access to whatever data it holds without you writing accessors.

Defining a Model#

The simplest useful model is a class with methods:

PHPapp/src/Model/Report.php
<?php

namespace App\Model;

use Pop\Utils\AbstractModel;

class Report extends AbstractModel
{

    public function generate(string $from, string $to): array
    {
        return [
            'from'  => $from,
            'to'    => $to,
            'total' => 0
        ];
    }

}

That's a complete model. It extends AbstractModel for the shared type rather than for behavior, and a class that has no data to hold loses nothing by doing so.

A model that does hold data takes it through the constructor, and then behaves as both an array and an object:

PHP
$report = new App\Model\Report(['id' => 1, 'title' => 'Q3', 'rows' => 42]);

echo $report->title;        // Q3
echo $report['title'];      // Q3
echo count($report);        // 3

foreach ($report as $key => $value) {
    // id, title, rows
}

echo $report->jsonSerialize();   // {"id":1,"title":"Q3","rows":42}

Reading a key that is not set returns null rather than raising a notice, and isset() works on both notations. toArray() returns the underlying array, converting any nested object that has a toArray() of its own as it goes — so a model holding records comes back as plain nested arrays in one call.

The constructor takes an array or any array-like object — another ArrayObject, anything implementing ArrayAccess, Countable or IteratorAggregate. Passing a scalar throws Pop\Utils\Exception with the message Error: The data passed must be an array or an array-like object.

Call $report->jsonSerialize() to get a model's JSON, or $report->toArray() when the model is one value inside a larger structure you are encoding yourself.

Pass [] to any model you intend to count or iterate — new Report() leaves the underlying data unset, and assigning a first property settles it too.

Using a Model from a Controller#

A controller constructs the model it needs and hands it the values it took off the request. That's the whole contract — no container, no injection, no registration:

PHPapp/src/Http/Controller/ReportsController.php
<?php

namespace App\Http\Controller;

use App\Model\Report;
use Pop\View\View;

class ReportsController extends AbstractController
{

    public function index(): void
    {
        $report = new Report();

        $view = new View(__DIR__ . '/../../../view/reports.phtml');
        $view->title  = 'Reports';
        $view->result = $report->generate(
            $this->request->getQuery('from') ?? '2026-01-01',
            $this->request->getQuery('to') ?? '2026-12-31'
        );

        $this->response->setBody($view->render());
        $this->response->send();
    }

}

The controller's job stops at translating the request into arguments and the result into a response. Keeping it that thin is what lets the model be called from somewhere with no request at all — a console command, a queued job, a test.

When a model is expensive to build, or when several controllers need the same configured instance, register it as a service in the application's load() instead and resolve it by name:

PHP
$app = new Pop\Application();

$app->setService('reports', ['call' => 'App\Model\Report']);

$this->application()->getService('reports') then returns the same instance for the rest of the request. See Services for defining one, and Applications & Bootstrap for where the registration goes.

Models and Records#

A model and a table class are different layers and it's worth keeping them apart. Pop\Db\Record subclasses in app/src/Table/ are the persistence layer: one class per table, mapping rows to objects. A model is the layer above, holding the logic that decides which rows to ask for and what to do with them.

PHPapp/src/Model/Registration.php
<?php

namespace App\Model;

use App\Table\Users;
use Pop\Utils\AbstractModel;

class Registration extends AbstractModel
{

    public function register(string $username, string $email): Users
    {
        $user = new Users([
            'username' => $username,
            'email'    => $email
        ]);

        $user->save();

        return $user;
    }

}

The rule of thumb: a table class knows about columns, a model knows about the operation. Password hashing, sending the welcome mail, deciding whether a duplicate is an error or a no-op — none of that belongs on the record. Records & the ORM covers the table classes themselves.

Three base classes turn up in an application's app/src/, and it's worth being able to name which is which:

Base class Lives in Represents
Pop\Db\Record app/src/Table/ one database table, one row per instance
Pop\Db\Model\AbstractDataModel app/src/Model/ the CRUD surface of one table, for a controller
Pop\Utils\AbstractModel app/src/Model/ anything else — logic that spans tables, or none

The two model bases share an ancestor, so AbstractDataModel passes an instanceof Pop\Utils\AbstractModel check. A Record does not — it's a different hierarchy entirely.

Data Models#

For a model whose entire job is CRUD against one table, pop-db provides a base class that writes the boilerplate for you. Pop\Db\Model\AbstractDataModel extends Pop\Utils\AbstractModel, so it's the same family, and links itself to a table class by naming convention — App\Model\User resolves to App\Table\Users with nothing wiring them together.

PHPapp/src/Model/User.php
<?php

namespace App\Model;

use Pop\Db\Model\AbstractDataModel;

class User extends AbstractDataModel
{

    protected array $requirements = ['username', 'email'];

}

That class already answers User::fetchAll(), User::fetch(1), User::createNew($data), update(), replace(), delete(), count() and paginated, sorted, filtered reads — with $requirements validated on the way in. The full API, the errors-array return shape and the filter syntax are covered in Records & the ORM.

Reach for AbstractDataModel when the model is a table's CRUD surface and for AbstractModel when it is anything else. A model that spans two tables, calls an API, or has no persistence at all is the second kind.

Models Beyond the Web Request#

The payoff for keeping logic out of the controller is that the same class answers a console command, a queued job and a test with no adaptation. A console controller constructs the model exactly as an HTTP one does:

PHPapp/src/Console/Controller/ReportsController.php
<?php

namespace App\Console\Controller;

use App\Model\Report;
use Pop\Controller\AbstractController;
use Pop\Dispatch\ConsoleTrait;

class ReportsController extends AbstractController
{

    use ConsoleTrait;

    public function generate(string $from, string $to): void
    {
        $result = (new Report())->generate($from, $to);

        $this->console->write('Total: ' . $result['total']);
        $this->console->send();
    }

}

That's the test for whether a model is carrying the right amount. If it needs $this->request to do its job, the request parsing has leaked down a layer; take the values as arguments instead. If it needs to send a response, the response building has leaked down; return a value and let the caller decide what to do with it.

Generating a Model#

kettle writes either shape:

BASH
./kettle create:model Report
./kettle create:model -d User

The first writes app/src/Model/Report.php as an empty subclass of Pop\Utils\AbstractModel, creating the directory if it does not exist. The second adds -d for a data model: it writes app/src/Model/User.php extending Pop\Db\Model\AbstractDataModel and app/src/Table/Users.php extending Pop\Db\Record, so the naming convention that links them is satisfied from the start. The table name is the model name with an s appended unless it already ends in one.

A slash in the name nests both the namespace and the directory, so ./kettle create:model Billing/Invoice produces App\Model\Billing\Invoice in app/src/Model/Billing/.

Pop\Utils\ArrayObject — the class AbstractModel inherits everything from — has more surface than this page uses, including sorting, joining and serialization helpers; see the pop-utils README.

See Also#