Skip to content

Pramnos ORM Guide

The ORM (Object-Relational Mapping) layer provides an elegant way to work with database tables as PHP objects. Models encapsulate table definitions, relationships, scopes, and casting logic.

Base Class: Pramnos\Application\OrmModel

Getting Started

Defining a Model

A controller reaches a model with $this->getModel('User'); which class that finds, and when it does not, is in How a controller finds its model.

<?php
namespace App\Models;

use Pramnos\Application\OrmModel;

class User extends OrmModel
{
    // Table name. Left out, it is '#PREFIX#' . <class short name> . 's'.
    protected $_dbtable = 'users';

    // Primary key column
    protected $_primaryKey = 'userid';

    // Fillable attributes — fields that can be mass-assigned with fill()
    protected array $fillable = ['username', 'email', 'password'];

    // Type casting
    protected array $casts = [
        'active'     => 'boolean',
        'created_at' => 'datetime',
        'metadata'   => 'json',
    ];

    // Relationships
    public function posts()
    {
        return $this->hasMany(Post::class, 'userid', 'userid');
    }

    public function profile()
    {
        return $this->hasOne(Profile::class, 'userid', 'userid');
    }
}

Redeclared properties keep the parent's type

OrmModel declares $fillable, $guarded and $casts as array, $timestamps and $softDelete as bool, and the column-name switches as string. A subclass must redeclare them with the same type — protected $fillable = [...] without array is a fatal error when the class loads ("Type of User::$fillable must be array"). $_dbtable and $_primaryKey come from Model and are untyped.

There is no $hidden property: nothing filters attributes out of toArray() or getData() by name. A column that must not be published is left out of the endpoint's field list, or removed in an overridden getData().

CRUD Operations

There is no static query API

User::find(42), User::where(…), User::all() and User::create([...]) do not exist. OrmModel declares no __callStatic and none of its traits add one, so a method written from an Eloquent-shaped example dies with Error: Call to undefined method App\Models\User::where() — at runtime, in whatever path first reaches it.

Every operation goes through an instance. A model is a row-shaped object: you make one, load it, change its properties, and save or delete it.

A model is constructed with the current controller — new User($this) inside a controller action. Relations pass their parent's controller on to the models they create.

What the base class gives you, and what create:crud adds

OrmModel itself has a public save() and protected _load(), _delete(), _save(). The public load($id) and delete($id) used below are the thin wrappers a model declares — create:crud generates them — forwarding to the protected methods:

public function load($userid, $key = null, $debug = false)
{
    return parent::_load($userid, null, $key, $debug);
}

public function delete($userid)
{
    return parent::_delete($userid, null, null);
}

A model that overrides save() must keep it compatible with OrmModel::save(): static. Timestamps, casts and events happen in _save() / _delete(), so an override calls them.

Create

$user = new \App\Models\User($this);
$user->username = 'jane_doe';
$user->email    = 'jane@example.com';
$user->save();

echo $user->userid;   // the key the insert produced

save() inserts when the object is new and updates when it was loaded.

Read

// One row, by primary key. The object is returned either way; check what came back.
$user = (new \App\Models\User($this))->load(42);

// Many rows, as models. The first argument is a WHERE clause without the keyword.
$active = (new \App\Models\User($this))->_getList(
    'active = 1',
    'username ASC'
);

// The paginated envelope an API endpoint answers with — filtering, sorting,
// searching and the total, from the request.
$page = (new \App\Models\User($this))->_getApiList();

The second argument is the order. Left out — null — the newest come first, by primary key; an empty string '' is no ORDER BY at all, the database's own order.

A paginated list runs two statements, a COUNT for the total and the page itself. A caller that shows no total sets $model->apiListCountTotal = false first: the COUNT is not sent, the envelope's totalitems is null and there is one page. Worth it where the filter is a LIKE '%term%', whose count reads the whole table. User has the same property.

_getList() returns models with every scope applied — a global scope registered with addGlobalScope() is merged into the filter here, which is the reason to reach for it rather than for the query builder.

The query builder does not know about your scopes

queryBuilder()->table('users')->get() is the right tool for a report, a join or an aggregate. It is the wrong one for reading a tenant's rows: it bypasses every global scope, so isolation added later silently does not apply. If you go around the model, say so in the query — an explicit where('organization_id', …) — rather than leaving the reader to notice what is missing.

Update

$user = (new \App\Models\User($this))->load(42);
$user->email = 'newemail@example.com';
$user->save();

There is no bulk update through the model. A statement that touches many rows is query-builder work, and it is worth writing the scope back in by hand:

$db->queryBuilder()->table('#PREFIX#users')
    ->where('organization_id', $tenantId)
    ->where('active', 0)
    ->update(['active' => 1]);

Delete

(new \App\Models\User($this))->delete(42);

With soft deletes enabled the row is stamped rather than removed; see Soft Deletes for what that changes about reads.

What about find(), where() and the rest?

They do not exist. A static entry point forwarding to a query object would make a global scope pleasant to use; without one, the instance methods above are the whole of the read surface, and reaching for the query builder instead is exactly where scopes stop applying.

Relationships

One-to-Many (hasMany)

class User extends OrmModel
{
    public function posts()
    {
        return $this->hasMany(Post::class, 'userid', 'userid');
    }
}

// Usage
$user  = (new User($this))->load(42);
$posts = $user->posts();  // the relation object
$posts = $user->posts;    // the rows, as an Orm\Collection, via the magic property

One-to-One (hasOne)

class User extends OrmModel
{
    public function profile()
    {
        return $this->hasOne(Profile::class, 'userid', 'userid');
    }
}

$profile = $user->profile;  // single profile or null

Belongs-To (inverse of hasMany/hasOne)

class Post extends OrmModel
{
    public function author()
    {
        return $this->belongsTo(User::class, 'userid', 'userid');
    }
}

$post = (new Post($this))->load(1);
$user = $post->author;  // the user who authored this post

Many-to-Many (through pivot table)

class User extends OrmModel
{
    public function roles()
    {
        return $this->belongsToMany(Role::class, 'user_roles', 'userid', 'roleid');
    }
}

// Usage
$user  = (new User($this))->load(42);
$roles = $user->roles;  // Orm\Collection of Role models

The relation reads through the pivot table; it does not write to it. There is no attach() / sync() / detach() — maintain pivot rows with the query builder:

$db->queryBuilder()->table('user_roles')->insert(['userid' => 42, 'roleid' => 5]);

Relations return a Pramnos\Application\Orm\Collection, which is countable, iterable and JSON-serialisable.

Relationship Eager Loading

// Reduce N+1 queries. `with()` takes one name or an array of names, and returns the
// model, so the read that follows is the usual one.
$users = (new User($this))->with(['posts', 'profile'])->_getList();

foreach ($users as $user) {
    echo count($user->posts);  // no additional queries
}

Pass several relations as an array: with('posts', 'profile') loads only posts.

Scopes

A scope is a reusable piece of WHERE logic. Scopes work on filter strings, not on a fluent query object: a scope method receives the filter built so far and returns it with its own condition appended.

Local scopes

Define scopeXxx(string $filter, ...$args): string on the model and reach it with applyScope(). appendCondition() does the ANDing and the parenthesising:

class Post extends OrmModel
{
    public function scopePublished(string $filter): string
    {
        return $this->appendCondition($filter, "status = 'published'");
    }

    public function scopeOlderThan(string $filter, int $days): string
    {
        return $this->appendCondition(
            $filter,
            "created_at < DATE_SUB(NOW(), INTERVAL {$days} DAY)"
        );
    }
}

$posts = $post->applyScope('published')->_getList();
$stale = $post->applyScope('olderThan', 30)->_getList();

applyScope() queues the scope for the next query only and returns $this, so calls chain.

Global scopes

Registered once per model class and applied to every query that model makes:

// currentTenantId() is your application's own lookup.
Post::addGlobalScope('tenant', fn(string $f): string =>
    ($f === '' ? '' : "({$f}) AND ") . 'tenant_id = ' . (int) currentTenantId()
);

Remove one permanently with Post::removeGlobalScope('tenant'), or skip it for a single query with $post->withoutGlobalScope('tenant')->_getList().

Where scopes apply

Global scopes and the soft-delete filter apply to every list path a model has: _getList(), _getPaginated(), the datatable row count, and _getApiList() — which is what the REST endpoints, the generated CRUD controllers and the datatables call.

This matters most when a global scope is what separates one tenant's data from another's, which is the use the example above shows. A scope that held on some paths and not others would not be a partial feature; it would be a leak, and one that shows up only on whichever screen happens to paginate.

A caller's own filter is combined with the scopes, never replaced by them, so an endpoint that accepts a filter cannot be used to shed the tenant condition.

One limit, stated because it is invisible otherwise: a local scope queued with applyScope() is consumed by the first query it reaches, so on a datatables request it applies to the page and not to the total row count. Global scopes and soft deletes are re-derived per call and are unaffected. Put tenant isolation in a global scope.

Casting

Automatic type conversion for model attributes:

protected array $casts = [
    'active'      => 'boolean',      // 0/1 ↔ true/false
    'created_at'  => 'datetime',     // string → DateTimeImmutable
    'metadata'    => 'json',         // JSON string ↔ array
    'login_count' => 'integer',
    'balance'     => 'float',
];

The casts that exist

$casts takes a string type, and this is the whole list:

Cast Turns a stored value into
int, integer int
float, double float
bool, boolean bool
string string
array, json an array, JSON-decoded when the value is a string
datetime, date a DateTimeImmutable
timestamp a Unix timestamp as int

There is no custom-cast interface. A cast class name is not recognised, and — this is the part worth knowing — it does not fail either: an unrecognised cast falls through to the default arm and the value is returned unchanged. So 'address' => AddressCast::class looks like it works, and silently does nothing.

For a transformation the list above cannot express, use an accessor and mutator below — they are called for exactly this.

Accessors & Mutators

Computed properties and automatic value transformation:

class User extends OrmModel
{
    // Accessor (transform on read). Receives the raw stored value, if any.
    public function getFullNameAttribute($value = null)
    {
        return $this->first_name . ' ' . $this->last_name;
    }

    // Mutator (transform on write). What it RETURNS is what gets stored.
    public function setPasswordAttribute($value)
    {
        return password_hash($value, PASSWORD_DEFAULT);
    }
}

// Usage
$user->full_name;            // calls getFullNameAttribute()
$user->password = 'secret';  // calls setPasswordAttribute(), stores its return value

The method name is get/set + the attribute in StudlyCase + Attribute: full_name → getFullNameAttribute. A mutator that returns nothing stores null.

Soft Deletes

Mark records as deleted without removing them from the database:

class Post extends \Pramnos\Application\OrmModel
{
    // The trait is already on OrmModel; this is the switch that turns it on.
    protected bool $softDelete = true;

    // Optional — the default is `deleted_at`.
    protected string $deletedAtColumn = 'deleted_at';
}

// Usage
$post->delete($id);     // the generated delete(): sets deleted_at, not a hard DELETE
$post->softDelete();    // the same, on the loaded row
$post->restore();       // clears it
$post->trashed();       // true / false

// Reads exclude stamped rows automatically. Both of these are *instance* methods
// returning the model, so the read after them is the usual one.
$all     = (new Post($this))->withTrashed()->_getList();
$deleted = (new Post($this))->onlyTrashed()->_getList();

There is no forceDelete(). With $softDelete on, _delete() always stamps; a hard delete of a soft-deletable table is query-builder work ($db->queryBuilder()->table('posts')->where('post_id', $id)->delete()).

Timestamps

Automatic tracking of creation and update times:

class User extends OrmModel
{
    // Timestamps are enabled by default
    protected bool $timestamps = true;

    // Customize column names; '' disables that column
    protected string $createdAtColumn = 'created_at';
    protected string $updatedAtColumn = 'updated_at';
}

The model sets the created column on insert (unless it already holds a value) and the updated column on every save. $model->withoutTimestamps()->save() skips both.

Model and a NOT NULL date column

Pramnos\Application\Model — the base without $timestamps — writes every column it finds, and a NOT NULL column the model has nothing to say about is written as ''. Fine for a string; impossible for a date: strict MySQL and PostgreSQL both refuse '' as a datetime.

So a NOT NULL date, time or timestamp holding null is omitted from the write: on an insert the column's own default fills it, and on an update the stored value stays. A table declaring

created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

therefore accepts a model that never sets created_at. Every other NOT NULL column still coerces null to '', which existing models rely on.

Set the column and it is written as given — the omission applies only when there is nothing to say about it.

Model Events

Hook into model lifecycle events with on() per event, or observe() with an object whose public methods are named after the events:

User::on('creating', function (User $model) {
    $model->uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
});

User::on('deleted', function (User $model) {
    \Pramnos\Logs\Logger::log("User {$model->userid} deleted");
});

// Or one object for several events:
User::observe(new UserObserver());   // UserObserver::creating(), ::updated(), …

Available events: creating, created, updating, updated, deleting, deleted.

A listener on a "before" event (creating, updating, deleting) that returns false cancels the operation. Listeners are registered per model class and live for the process; User::flushEventListeners() removes them, which is what a test's tearDown() wants.

These are per-class hooks. For one stream of every model's saves and deletes, see the Change Feed Guide.

Complete Example

// Define model
class Post extends \Pramnos\Application\OrmModel
{
    protected $_dbtable    = 'posts';
    protected $_primaryKey = 'post_id';

    protected array $casts = [
        'published'  => 'boolean',
        'created_at' => 'datetime',
    ];

    public function author()
    {
        return $this->belongsTo(User::class, 'userid', 'userid');
    }

    /**
     * A local scope takes the filter string and returns it with one more
     * condition on it — it is not a query-builder callback.
     */
    public function scopePublished(string $filter): string
    {
        return $this->appendCondition($filter, 'published = 1');
    }
}

// Usage, inside a controller action
$recentPosts = (new Post($this))
    ->applyScope('published')
    ->_getList(null, 'created_at DESC');

foreach (array_slice($recentPosts, 0, 10) as $post) {
    echo $post->title . ' by ' . $post->author->username . "\n";
}

getData() answers columns, and only columns

$post->getData();   // ['post_id' => 42, 'body' => '…', 'scheduled_at' => null]

Everything this layer declares — fillable, guarded, casts, timestamps, softDelete, loadedRelations, eagerLoad and the rest — is machinery and is filtered out. The list is derived from the class rather than written out: OrmModel declares no columns of its own, so every instance property it or its traits declare is machinery by construction, and a trait that gains one is covered the day it does.

A model that adds its own switch excludes it the same way:

protected static function internalProperties(): array
{
    return parent::internalProperties() + ['myOwnSwitch' => true];
}

A constant would not do: a subclass redefining one shadows the parent's and loses everything in it.

This is why it is filtered rather than allowlisted

Filtering against the base model's list alone would not know this class, so every endpoint answering an ORM model would publish its bookkeeping before the first real column.

Two of those keys are worth more than the noise. fillable and guarded are the write allowlist: publishing them tells a caller exactly which fields an endpoint will accept from a request body, which is otherwise something to guess at.

The alternative shape — a $columns allowlist per model — would be worse here. Several models declare no column list at all, so filtering out the internals is the shape that works for all of them.

Scoping a model to a tenant — refuse the row and empty the model

A model that must never hand out another organisation's row does it by overriding load():

public function load($id, $key = null, $debug = false)
{
    parent::_load($id, null, $key, $debug);

    if ((int) $this->organization_id !== $this->currentOrganizationId()) {
        return $this->forget();     // ← both halves of the refusal
    }

    return $this;
}

forget() is not optional here. _load() has already written every column onto $this by the time the override gets to look, so a refusal that only changes the return value leaves the object holding the row it just refused:

$model = new Thing($this);
$model->load((int) $id);           // return value discarded
if ($model->thing_id == 0) { … }   // populated, so this passes
$model->getData();                 // somebody else's row

An isolation test does not catch this on its own: a test naturally writes $found = (new Thing($this))->load($id) and uses the return value, which is correct — the leak is in callers that discard it.

forget() restores every column the load wrote to what a fresh instance has, empties _initialData and marks the model new, so a later save() would insert rather than update the row that was refused. It clears only the columns that load read — not a list the model declares, because a hand-written list is right until somebody adds a column and then leaks exactly that one.

create:crud generates $model = (new Thing($this))->load($id);, so both halves hold whichever way the caller is written. Use the return value in your own code for the same reason.

Reference

Related Guides: - Pramnos_Database_API_Guide.md — QueryBuilder and low-level database operations - Pramnos_Migration_Guide.md — Schema versioning - Pramnos_Console_Guide.md — Model and CRUD generation wizard - Pramnos_Testing_Guide.md — model factories (OrmModel::factory()) and seeders - Pramnos_Change_Feed_Guide.md — one event stream for every model's saves and deletes

Topics covered: - Model definition and the instance CRUD surface - Relationship types (hasMany, hasOne, belongsTo, belongsToMany) - Query scopes and eager loading - Soft deletes and timestamp handling - Model events