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¶
<?php
namespace App\Models;
use Pramnos\Application\OrmModel;
class User extends Model
{
// Optional: specify table name (default is plural of class name)
protected static $table = 'users';
// Fillable attributes — fields that can be mass-assigned
protected $fillable = ['username', 'email', 'password'];
// Hidden from serialization
protected $hidden = ['password'];
// Type casting
protected $casts = [
'active' => 'boolean',
'created_at' => 'timestamp',
'metadata' => 'json',
];
// Relationships
public function posts()
{
return $this->hasMany(Post::class, 'userid', 'userid');
}
public function profile()
{
return $this->hasOne(Profile::class, 'userid', 'userid');
}
}
CRUD Operations¶
Create¶
// Using mass assignment
$user = User::create([
'username' => 'john_doe',
'email' => 'john@example.com',
'password' => hash('sha256', 'secret'),
]);
// Using new() and save()
$user = new User();
$user->username = 'jane_doe';
$user->email = 'jane@example.com';
$user->password = hash('sha256', 'secret');
$user->save();
Read¶
// Get by primary key
$user = User::find(42);
// Get first matching
$user = User::where('email', 'john@example.com')->first();
// Get all
$users = User::all();
// With conditions
$activeUsers = User::where('active', 1)->orderBy('username')->get();
Update¶
// Update via model instance
$user = User::find(42);
$user->email = 'newemail@example.com';
$user->save();
// Bulk update
User::where('active', 0)->update(['active' => 1]);
// Update with increment/decrement
$user->increment('login_count');
$user->decrement('credits', 5);
Delete¶
// Delete specific record
$user = User::find(42);
$user->delete();
// Bulk delete
User::where('active', 0)->delete();
// Force delete (bypasses soft deletes)
$user->forceDelete();
Relationships¶
One-to-Many (hasMany)¶
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class, 'userid', 'userid');
}
}
// Usage
$user = User::find(42);
$posts = $user->posts(); // lazy load
$posts = $user->posts; // eager load (via magic property)
One-to-One (hasOne)¶
class User extends Model
{
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 Model
{
public function author()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
}
$post = Post::find(1);
$user = $post->author; // the user who authored this post
Many-to-Many (through pivot table)¶
class User extends Model
{
public function roles()
{
return $this->belongsToMany(Role::class, 'user_roles', 'userid', 'roleid');
}
}
// Usage
$user = User::find(42);
$roles = $user->roles; // array of Role objects
// Attach a role
$user->roles()->attach(5); // attach role 5
$user->roles()->sync([1, 2, 3]); // sync to roles 1, 2, 3
Relationship Eager Loading¶
// Reduce N+1 queries
$users = User::with('posts', 'profile')->get();
foreach ($users as $user) {
echo count($user->posts); // no additional queries
}
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:
Post::addGlobalScope('tenant', fn(string $f): string =>
($f === '' ? '' : "({$f}) AND ") . 'tenant_id = ' . Auth::tenantId()
);
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 $casts = [
'active' => 'boolean', // 0/1 ↔ true/false
'created_at' => 'timestamp', // String → DateTime
'metadata' => 'json', // JSON string ↔ array/object
'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.
Until 2026-08-14 this section documented Pramnos\Database\Casts\Castable, which has never
existed. For a transformation the list above cannot express, use an
accessor and mutator below — they are called for exactly this, and
they are real.
Accessors & Mutators¶
Computed properties and automatic value transformation:
class User extends Model
{
// Accessor (transform on read)
public function getFullNameAttribute()
{
return $this->first_name . ' ' . $this->last_name;
}
// Mutator (transform on write)
public function setPasswordAttribute($value)
{
$this->attributes['password'] = hash('sha256', $value);
}
}
// Usage
$user->full_name; // calls getFullNameAttribute()
$user->password = 'secret'; // calls setPasswordAttribute()
Soft Deletes¶
Mark records as deleted without removing them from the database:
class User extends Model
{
use SoftDeletes;
protected $dates = ['deleted_at'];
}
// Usage
$user->delete(); // sets deleted_at to now
// Query active records (excludes soft-deleted)
$users = User::active()->get();
// Include soft-deleted records
$users = User::withTrashed()->get();
// Only soft-deleted records
$users = User::onlyTrashed()->get();
// Force delete
$user->forceDelete();
Timestamps¶
Automatic tracking of creation and update times:
class User extends Model
{
// Timestamps are enabled by default
public $timestamps = true;
// Customize column names
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
}
The model automatically sets created_at on insert and updated_at on every change.
A legacy Model leaves a NOT NULL date to the column¶
Pramnos\Application\Model — the older base, without $timestamps — writes every column it
finds, and a NOT NULL column the model has nothing to say about used to be written as ''.
Fine for a string; impossible for a date.
So a table declaring
could not be inserted into by a model that never set created_at: strict MySQL and PostgreSQL
both refuse '' as a datetime, and the intent — use the column's own default — became a
request for timestamp zero.
A NOT NULL date, time or timestamp holding null is now omitted from the write instead:
on an insert the default fills it, and on an update the stored value stays. Everything else
still coerces to '', because models have relied on that since long before this.
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:
class User extends Model
{
protected static function booting()
{
static::creating(function ($model) {
$model->uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
});
static::updating(function ($model) {
$model->updated_by = auth()->id();
});
static::deleted(function ($model) {
Log::info("User {$model->userid} deleted");
});
}
}
Available events: creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored
Complete Example¶
// Define model
class Post extends Model
{
protected $table = 'posts';
protected $fillable = ['title', 'content', 'published'];
protected $casts = [
'published' => 'boolean',
'created_at' => 'timestamp',
];
public function author()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
public function scopePublished($query)
{
return $query->where('published', 1);
}
}
// Usage
$recentPosts = Post::published()->latest('created_at')->limit(10)->get();
foreach ($recentPosts as $post) {
echo $post->title . " by " . $post->author->username . "\n";
}
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
Topics covered: - Complete Model API with all CRUD methods - Relationship types (hasMany, hasOne, belongsTo) - Query scopes and eager loading - Soft deletes and timestamp handling - Model factories and seeders