Essentials

ORM Models

[object Object]

Glueful ships an active-record ORM in Glueful\Database\ORM. A model is a class that extends Glueful\Database\ORM\Model and maps to one table. Under the hood every model query runs through the same query builder, so typed exceptions, soft deletes and connection handling behave identically.

The one rule to remember: every static entry point takes the ApplicationContext first. That is how the model finds its database connection.

$post  = Post::find($context, 42);
$posts = Post::query($context)->where('status', 'published')->get();
$post  = Post::create($context, ['title' => 'Hello', 'body' => '...']);
$draft = new Post(['title' => 'Draft'], $context);   // context is the 2nd constructor argument

Inside a controller the context is $this->context; in a service, inject ApplicationContext.

Defining a Model

namespace App\Models;

use Glueful\Database\ORM\Model;

class Post extends Model
{
    protected string $table = 'posts';
    protected string $primaryKey = 'id';

    protected array $fillable = ['user_id', 'title', 'body', 'status', 'meta'];

    protected array $hidden = ['internal_notes'];

    protected array $casts = [
        'user_id'    => 'int',
        'meta'       => 'array',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',   // optional: read them back as DateTimeImmutable
    ];
}
PropertyDefaultPurpose
$tablesnake_case plural of the class name (OrderItem → order_items)Table name
$primaryKey'id'Primary key column
$keyType'int'Key type; set 'string' for UUID keys
$incrementing (public)trueRead the generated id back after insert
$timestamps (public)trueMaintain created_at / updated_at
$fillable / $guarded[] / ['*']Mass-assignment rules
$hidden / $visible[]Filter toArray() / JSON output
$appends[]Accessor values added to toArray()
$perPage15Default page size

$incrementing and $timestamps are public properties on the base class, so redeclare them as public (public bool $timestamps = false;). A protected redeclaration is a fatal error.

For a string key you generate yourself:

class ApiClient extends Model
{
    protected string $primaryKey = 'uuid';
    protected string $keyType = 'string';
    public bool $incrementing = false;
    protected array $fillable = ['uuid', 'name'];
}

Timestamps

With $timestamps = true, save() sets updated_at on every write and created_at on insert, formatted with the model's date format (Y-m-d H:i:s). No cast is required; add a datetime cast (as above) to read them back as DateTimeImmutable. Any other DateTimeInterface you assign to an attribute is formatted the same way on save. If the table has no timestamp columns, set public bool $timestamps = false;.

Mass Assignment

fill(), create(), update() and the constructor respect the mass-assignment rules:

  • With a non-empty $fillable, only listed keys are kept. Other keys are silently dropped.
  • With an empty $fillable and the default $guarded = ['*'], any key throws InvalidArgumentException ("Add x to fillable property…").
  • Set $guarded = [] (and leave $fillable empty) to allow every column.

To bypass the rules deliberately:

$post->forceFill(['status' => 'published', 'reviewed_by' => $userId]);

Post::unguarded(fn () => Post::create($context, $trustedRow));

Casts

Casts convert values when you read an attribute and again when you write it.

CastRead asWritten as
int / integer, float / double / real, string, bool / booleanPHP scalaras given
array / jsondecoded arrayJSON string
objectstdClassJSON string
datetime / dateDateTimeImmutableY-m-d H:i:s string
timestampUnix integeras given

Class-based casts live in Glueful\Database\ORM\Casts. Pass constructor arguments after a colon:

use App\Enums\PostStatus;
use Glueful\Database\ORM\Casts\{AsArrayObject, AsCollection, AsDateTime, AsEncryptedString, AsEnum, AsJson};

protected array $casts = [
    'status'       => AsEnum::class . ':' . PostStatus::class,   // backed enum <-> its value
    'published_on' => AsDateTime::class . ':Y-m-d',             // custom storage format
    'settings'     => AsArrayObject::class,
    'tags'         => AsCollection::class,
    'payload'      => AsJson::class,
    'ssn'          => AsEncryptedString::class,                 // AES-256-GCM under APP_KEY
];

AsEncryptedString encrypts through the framework EncryptionService with <table>.<column> as additional authenticated data, so php glueful encryption:rotate --table=... --columns=... can re-key it. It resolves the service from the model's context, so build such models with one (new User([...], $context), User::create($context, ...)).

Since 1.91: the cast used AES-256-CBC under ENCRYPTION_KEY. Set APP_KEY, and keep ENCRYPTION_KEY until every old value has been re-saved: legacy values still decrypt and are re-encrypted with GCM on their next save.

Write your own by implementing Glueful\Database\ORM\Contracts\CastsAttributes (get() and set(), each receiving the model, key, value and raw attributes).

Accessors and Mutators

Return a Glueful\Database\ORM\Casts\Attribute from a method named after the attribute in camelCase:

use Glueful\Database\ORM\Casts\Attribute;

protected function title(): Attribute
{
    return Attribute::make(
        get: fn (?string $value) => $value === null ? null : ucfirst($value),
        set: fn (string $value) => trim($value),
    );
}

The classic method form also works: getFullNameAttribute($value) and setFullNameAttribute($value). Only the classic get…Attribute form is used for $appends.

Querying

Model::query($context) returns an ORM Builder. It has the familiar filters (where, orWhere, whereIn, whereNull, orderBy, latest, limit, offset, join, groupBy, …), and any other query builder method is forwarded to the underlying query builder.

// Collections of models
$posts = Post::query($context)
    ->where('status', 'published')
    ->whereIn('user_id', [1, 2, 3])
    ->latest()                       // ORDER BY created_at DESC
    ->limit(20)
    ->get();                         // Glueful\Database\ORM\Collection

// Single models
$post  = Post::find($context, 42);                 // ?Post
$post  = Post::findOrFail($context, 42);           // throws ModelNotFoundException -> 404
$first = Post::query($context)->where('slug', $slug)->first();

// Shortcuts
$all   = Post::all($context);
$count = Post::query($context)->where('status', 'draft')->count();
$any   = Post::query($context)->where('user_id', 7)->exists();

The ORM Collection is iterable and array-accessible, and adds helpers such as first(), map(), filter(), where(), pluck(), keyBy(), groupBy(), sortBy(), sum(), toArray() and toJson().

For large tables, process rows in batches with chunk(500, fn ($posts, $page) => ...) or iterate them with each(fn ($post) => ...).

Pagination

paginate() returns the query builder's pagination array, with data holding a Collection of models. with() and withCount() are applied to the page:

$page = Post::query($context)
    ->where('status', 'published')
    ->with('author')
    ->paginate(1, 20);
// ['data' => Collection<Post>, 'current_page' => 1, 'per_page' => 20, 'total' => 135, 'last_page' => 7, ...]

$page['data']->first()->author->name;

Since 1.91: data used to hold plain row arrays. Change $row['title'] to $row->title, or call $page['data']->toArray(). JsonResource::collection() / ModelResource::collection() accept the collection as-is.

Return the page through a typed PaginatedResponse (see Typed Requests & Responses) or Response::paginated(). To page manually, combine forPage($page, 20)->get() with a separate count().

Creating, Updating, Deleting

// Create
$post = Post::create($context, ['user_id' => 1, 'title' => 'Hello', 'body' => '...']);
$post->id;                     // generated key is read back

$post = new Post(['title' => 'Draft'], $context);
$post->user_id = 1;
$post->save();

$tag = Tag::firstOrCreate($context, ['slug' => 'php'], ['name' => 'PHP']);
$setting = Setting::updateOrCreate($context, ['key' => 'theme'], ['value' => 'dark']);

// Update one model (only dirty columns are written)
$post = Post::findOrFail($context, 42);
$post->title = 'Updated';
$post->save();

$post->update(['status' => 'published']);   // fill + save

$post->isDirty('title');   // before saving
$post->getChanges();

// Update many rows at once (no model events)
Post::query($context)->where('status', 'draft')->update(['status' => 'archived']);

// Delete
$post->delete();
Post::destroy($context, [1, 2, 3]);         // loads and deletes each model

refresh() reloads the current instance from the database, fresh() returns a new copy, and replicate() clones the attributes without the primary key.

Soft Deletes

Soft deletes follow the table, exactly as in the query builder: if the table has a deleted_at column, the model's delete() sets it instead of removing the row, and queries skip soft-deleted rows. No configuration is needed.

$post->delete();                                        // UPDATE posts SET deleted_at = ...

Post::find($context, $id);                              // null once soft-deleted
Post::query($context)->withTrashed()->get();            // include soft-deleted rows
Post::query($context)->onlyTrashed()->get();            // only soft-deleted rows
Post::query($context)->where('id', $id)->restore();     // clear deleted_at on trashed rows
Post::query($context)->where('id', $id)->forceDelete(); // permanent DELETE

Add the Glueful\Database\ORM\Concerns\SoftDeletes trait for the instance methods and their events:

use Glueful\Database\ORM\Concerns\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

$post->delete();        // soft delete; the instance still exists
$post->trashed();       // true
$post->restore();       // clears deleted_at and saves
$post->forceDelete();   // permanent DELETE, trashed or not

Since 1.91: forceDelete() (instance and builder) issues a real DELETE; it used to soft-delete. Check that existing callers mean to remove rows permanently.

Relationships

Declare a relationship as a method with a relation return type. Defaults are derived from class names (Post → foreign key post_id; pivot table = the two snake_case class names sorted alphabetically, e.g. role_user).

use Glueful\Database\ORM\Relations\{BelongsTo, BelongsToMany, HasMany, HasOne};

class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);                  // profiles.user_id
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);                    // posts.user_id
    }

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)               // role_user (user_id, role_id)
            ->withPivot('granted_at');
    }
}

class Post extends Model
{
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');       // posts.user_id -> users.id
    }
}
MethodSignature (all keys optional)
hasOne / hasMany(Related::class, $foreignKey, $localKey)
belongsTo(Related::class, $foreignKey, $ownerKey, $relation)
belongsToMany(Related::class, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relation)
hasOneThrough / hasManyThrough(Related::class, Through::class, $firstKey, $secondKey, $localKey, $secondLocalKey)

belongsTo guesses its foreign key from the method name (author() → author_id), so pass the key explicitly when the column is named after the related model.

Read a relation as a property to load and cache it, or call the method to keep building the query:

$user->posts;                                    // Collection, loaded once then cached
$user->posts()->where('status', 'draft')->get(); // constrained query
$post->author?->name;

foreach ($user->roles as $role) {
    $role->pivot->granted_at;                    // pivot columns from withPivot()
}

Many-to-many pivot rows are managed through the relation:

$user->roles()->attach($roleId, ['granted_at' => date('Y-m-d H:i:s')]);
$user->roles()->detach($roleId);                 // remove one (or an array of ids)
$user->roles()->detach();                        // remove all of this user's pivot rows
$user->roles()->sync([1, 2, 3]);                 // returns attached / detached / updated ids
$user->roles()->toggle([4]);

BelongsToMany also offers wherePivot(), wherePivotIn(), wherePivotNull(), orderByPivot() and withTimestamps(). BelongsTo has associate() and dissociate().

Querying Relationship Existence

$authors = User::query($context)->has('posts')->get();            // at least one post
$prolific = User::query($context)->has('posts', '>=', 10)->get();
$silent = User::query($context)->doesntHave('posts')->get();

$active = User::query($context)
    ->whereHas('posts', fn ($q) => $q->where('status', 'published'))
    ->get();
$commenters = User::query($context)->has('posts.comments')->get();  // nested

The whereHas() / orWhereHas() / whereDoesntHave() closure receives the related model's ORM Builder, so its scopes work there. A self-referencing relation, or a relation type that can't be correlated, throws rather than returning wrong rows.

Eager Loading

Accessing a relation on each model of a collection runs one query per model (the N+1 problem). with() loads the relation for the whole result in one extra query:

$posts = Post::query($context)->with('author')->get();       // 2 queries total

$users = User::with($context, ['posts', 'profile'])->get();  // static form, context first

// Nested (every post gets its comments)
$users = User::query($context)->with('posts.comments')->get();

// belongsToMany and hasOneThrough / hasManyThrough relations eager-load too
$users = User::query($context)->with('roles')->get();

// Constrained
$users = User::query($context)
    ->with(['posts' => fn ($posts) => $posts->where('status', 'published')])
    ->get();

Load relations onto models you already have:

$user->load('posts');                 // always reloads
$user->loadMissing('profile');        // only if not loaded yet
$users->load('profile');              // on a whole Collection

$user->loadCount('posts');
$user->posts_count;                   // int

Count relations in the query itself with withCount(). It runs one grouped COUNT query per relation and adds {relation}_count; a closure constrains the count and receives the relation, like with():

$users = User::query($context)
    ->withCount(['posts', 'comments' => fn ($q) => $q->where('approved', true)])
    ->get();
$users->first()->comments_count;

Counts from withCount() and loadCount() are never written back by a later save().

N+1 Detection

The ORM flags lazy loads that happen on models hydrated as part of a multi-row result: exactly the pattern with() exists to fix. Set the mode with DB_LAZY_LOADING_MODE (read from database.orm.lazy_loading_mode):

DB_LAZY_LOADING_MODE=auto
ModeBehaviour
auto (default)warn when APP_ENV=development, otherwise off
warnWrites [GLUEFUL-N+1] Lazy-load detected on collection-loaded model: App\Models\User::posts. Add ->with('posts') to the query. to the error log, once per model/relation pair
strictThrows Glueful\Database\ORM\Exceptions\LazyLoadingViolationException
offDisabled
$users = User::query($context)->get();
foreach ($users as $user) {
    $user->posts;          // flagged: lazy load on a collection-loaded model
}

User::find($context, 1)->posts;                     // not flagged: single model
User::query($context)->with('posts')->get();        // not flagged: eager loaded

Opt a single model out with the instance property:

class LegacyReport extends Model
{
    protected ?string $instanceLazyLoadingMode = 'off';
}

Enforce it in tests, or route violations to your own logger:

use Glueful\Database\ORM\Model;

Model::preventLazyLoading('strict');

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    // replaces the default warn/throw behaviour; pass null to restore it
});

On long-running runtimes, call Model::clearLazyLoadingWarnings() between requests so warn mode reports again.

Scopes

A local scope is a scope-prefixed method, called without the prefix:

use Glueful\Database\ORM\Builder;

class Post extends Model
{
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published');
    }

    public function scopeBy(Builder $query, int $userId): Builder
    {
        return $query->where('user_id', $userId);
    }
}

$posts = Post::query($context)->published()->by(7)->get();

A global scope applies to every query of the model. Register it in boot(), as a closure or a class implementing Glueful\Database\ORM\Contracts\Scope (apply(Builder $builder, object $model): void):

protected static function boot(): void
{
    static::addGlobalScope('tenant', function (Builder $query) {
        $query->where('tenant_id', current_tenant_id());
    });
}

Post::query($context)->withoutGlobalScope('tenant')->get();
Post::query($context)->withoutGlobalScopes()->get();

Model Events

Saving a model fires lifecycle events: creating, created, updating, updated, saving, saved, deleting, deleted, plus retrieved for each hydrated row. Models using SoftDeletes also fire trashed, restoring, restored, forceDeleting and forceDeleted (register them with onTrashed(), restoring(), restored(), forceDeleting(), forceDeleted()). Builder-level update() and delete() bypass them.

Register callbacks in boot():

protected static function boot(): void
{
    static::creating(function (Post $post) {
        $post->slug ??= strtolower(preg_replace('/\W+/', '-', $post->title));
    });

    static::deleted(function (Post $post) {
        // clean up related files, caches, ...
    });
}

Returning false from a saving, creating, updating, deleting, restoring or forceDeleting callback cancels the write, and save() / delete() / restore() / forceDelete() return false. Since 1.91: the return value used to be ignored, so check that no existing callback returns false by accident.

Each event is also dispatched through the event system as a class in Glueful\Database\ORM\Events (ModelCreating, ModelCreated, ModelUpdating, …, ModelRetrieved), all extending BaseEvent. $event->getModel() returns the model. A listener on a …ing event can also cancel the write by calling $event->stopPropagation().

Silence events for a single instance with $post->withoutEvents()->save().

Serialization

toArray(), toJson() and json_encode($model) include attributes (with casts applied and $hidden removed) plus loaded relations. Casted dates come out as DateTimeImmutable objects. For a public API contract, map models into response DTOs: their serializer formats dates as ISO-8601 and the shape feeds the OpenAPI spec.

Next Steps