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:
<?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:
$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:
<?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:
$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.
<?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.
<?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:
<?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:
./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#
- Controllers — the layer that calls into a model
- Records & the ORM — table classes, and the data model API in full
- Application Structure — where
app/src/Model/sits in the skeleton - Services — registering a model so it's built once per request
- Utilities & Helpers —
ArrayObject,Collectionand the rest of pop-utils - pop-utils README — the
AbstractModelreference