Skip to content
Ray Fung edited this page Feb 26, 2026 · 3 revisions

ORM (Object-Relational Mapping)

Razy's ORM provides an Active Record implementation with models, relationships, eager loading, pagination, soft deletes, and query scopes. Models map database rows to PHP objects with attribute casting, accessors/mutators, and event hooks.


Table of Contents


Quick Start

use Razy\Database;

use Razy\ORM\Model;



// Define a model

class User extends Model

{

    protected static string $table = 'users';

    protected static array $fillable = ['name', 'email', 'role'];

    protected static array $casts = ['is_active' => 'bool'];

}



// Boot models with a database connection

$db = new Database('mysql:host=127.0.0.1;dbname=app', 'root', 'secret');

Model::boot($db);



// Create

$user = User::create(['name' => 'Alice', 'email' => 'alice@example.com']);



// Query

$admins = User::query()->where('role=:role', ['role' => 'admin'])->get();



// Find

$user = User::find(1);

$user->name = 'Alice Smith';

$user->save();



// Delete

$user->delete();

Model

All models extend Razy\ORM\Model. The base class provides attribute management, persistence, events, and relationship resolution.

Table & Key Convention

By default, the table name is inferred from the class name by converting to snake_case and pluralising:

| Class Name | Inferred Table |

| --- | --- |

| User | users |

| BlogPost | blog_posts |

| OrderItem | order_items |

Override with the $table property:

class User extends Model

{

    protected static string $table = 'app_users';

    protected static string $primaryKey = 'user_id';  // default: 'id'

}

Foreign keys are inferred as {snake_case_class}_id (e.g., Useruser_id).

Mass Assignment

Control which attributes can be set via fill() or create():

class Post extends Model

{

    // Only these fields can be mass-assigned

    protected static array $fillable = ['title', 'body', 'category_id'];



    // OR block specific fields (mutually exclusive with $fillable)

    // protected static array $guarded = ['id', 'is_published'];

}



// Mass assignment

$post = Post::create([

    'title' => 'Hello',

    'body'  => 'World',

    'is_published' => true,  // silently ignored if using $fillable

]);



// Direct assignment always works

$post->is_published = true;

$post->save();

Attribute Casting

Define $casts to automatically convert attributes on read and write:

class Settings extends Model

{

    protected static array $casts = [

        'is_active'  => 'bool',

        'count'      => 'int',

        'rate'       => 'float',

        'name'       => 'string',

        'metadata'   => 'array',     // JSON → array

        'config'     => 'json',      // alias for 'array'

        'created_at' => 'datetime',  // string → DateTime

    ];

}



$s = Settings::find(1);

$s->is_active;   // bool (true/false, not "1"/"0")

$s->metadata;    // array (decoded from JSON column)

$s->created_at;  // \DateTime instance

| Cast Type | PHP Type | Read Transform | Write Transform |

| --- | --- | --- | --- |

| int | int | (int) $val | (int) $val |

| float | float | (float) $val | (float) $val |

| bool | bool | (bool) $val | (bool) $val |

| string | string | (string) $val | (string) $val |

| array / json | array | json_decode($val, true) | json_encode($val) |

| datetime | DateTime | new DateTime($val) | $val->format('Y-m-d H:i:s') |

Accessors & Mutators

Define custom attribute logic with get{Name}Attribute / set{Name}Attribute:

class User extends Model

{

    // Accessor: $user->full_name

    public function getFullNameAttribute(): string

    {

        return $this->first_name . ' ' . $this->last_name;

    }



    // Mutator: $user->password = 'plain'

    public function setPasswordAttribute(string $value): void

    {

        $this->attributes['password'] = password_hash($value, PASSWORD_ARGON2ID);

    }

}



$user = User::find(1);

echo $user->full_name;     // "Alice Smith" (computed)



$user->password = 'secret'; // stored as Argon2id hash

$user->save();

Timestamps

By default, models manage created_at and updated_at columns automatically:

class Post extends Model

{

    protected static bool $timestamps = true; // default



    // Disable:

    // protected static bool $timestamps = false;

}



$post = Post::create(['title' => 'Hello']);

echo $post->created_at;  // '2025-01-15 10:30:00'

echo $post->updated_at;  // '2025-01-15 10:30:00'



$post->title = 'Updated';

$post->save();

echo $post->updated_at;  // '2025-01-15 10:31:00' (auto-updated)



// Touch without changing attributes

$post->touch();

Hidden & Visible

Control serialisation output:

class User extends Model

{

    // Never include in toArray()/toJson()

    protected static array $hidden = ['password', 'remember_token'];



    // OR only include these (mutually exclusive with $hidden)

    // protected static array $visible = ['id', 'name', 'email'];

}



$user = User::find(1);

$data = $user->toArray();

// ['id' => 1, 'name' => 'Alice', 'email' => '...']  (no password)



echo $user->toJson();

// Does not include hidden fields

CRUD Operations

Create

// Static create (mass assign + save)

$user = User::create([

    'name'  => 'Alice',

    'email' => 'alice@example.com',

]);



// Manual instantiation

$user = new User();

$user->name = 'Bob';

$user->email = 'bob@example.com';

$user->save();  // INSERT



// First-or-create (find by attributes, or create with merged values)

$user = User::firstOrCreate(

    ['email' => 'alice@example.com'],           // search

    ['name' => 'Alice', 'role' => 'member'],    // create if not found

);



// First-or-new (like firstOrCreate but does NOT save)

$user = User::firstOrNew(

    ['email' => 'alice@example.com'],

    ['name' => 'Alice'],

);

if (!$user->exists) {

    $user->role = 'member';

    $user->save();

}



// Update-or-create (upsert)

$user = User::updateOrCreate(

    ['email' => 'alice@example.com'],

    ['name' => 'Alice Updated'],

);

Read

// Find by primary key

$user = User::find(1);        // null if not found

$user = User::findOrFail(1);  // throws ModelNotFoundException



// All records

$users = User::all();  // ModelCollection



// Query builder

$users = User::query()

    ->where('role=:role', ['role' => 'admin'])

    ->orderBy('name')

    ->get();  // ModelCollection

Update

$user = User::find(1);

$user->name = 'Updated name';

$user->save();  // UPDATE ... WHERE id = 1



// Increment / decrement

$user->increment('login_count');

$user->increment('balance', 50);

$user->decrement('credits', 10);

Delete

$user = User::find(1);

$user->delete();  // DELETE ... WHERE id = 1



// Bulk delete by primary key

User::destroy(1, 2, 3);

User::destroy([4, 5, 6]);



// Replicate

$clone = $user->replicate();  // new instance, no primary key

$clone->save();

Dirty Tracking

$user = User::find(1);



$user->isDirty();            // false

$user->name = 'Changed';

$user->isDirty();            // true

$user->isDirty('name');      // true

$user->isDirty('email');     // false



$user->getDirty();           // ['name' => 'Changed']

$user->getOriginal('name');  // 'Alice' (before change)

$user->getOriginal();        // all original attributes



$user->save();

$user->isDirty();            // false (synced)



// Refresh from database

$user->refresh();

Model Events

Hook into the model lifecycle:

class User extends Model

{

    protected static function booted(): void

    {

        static::creating(function (User $user) {

            $user->uuid = bin2hex(random_bytes(16));

        });



        static::updating(function (User $user) {

            if ($user->isDirty('email')) {

                $user->email_verified_at = null;

            }

        });



        static::deleting(function (User $user) {

            // Cancel delete by returning false

            if ($user->is_admin) {

                return false;

            }

        });



        static::saved(function (User $user) {

            // Runs after both create and update

            Cache::forget("user:{$user->id}");

        });

    }

}

Available events (in execution order):

| Before | After | Trigger |

| --- | --- | --- |

| creating | created | INSERT |

| updating | updated | UPDATE |

| saving | saved | Both INSERT and UPDATE |

| deleting | deleted | DELETE |

| restoring | restored | SoftDeletes restore() |

Returning false from a before event cancels the operation.

Global Scopes

Apply constraints to every query on a model:

class User extends Model

{

    protected static function booted(): void

    {

        static::addGlobalScope('active', function (ModelQuery $query) {

            $query->where('is_active=:_gs_active', ['_gs_active' => 1]);

        });

    }

}



// All queries automatically include WHERE is_active = 1

User::query()->get();



// Remove scope for a specific query

User::query()->withoutGlobalScope('active')->get();



// Remove all global scopes

User::query()->withoutGlobalScopes()->get();



// Remove global scope permanently from model

User::removeGlobalScope('active');



// Reset all booted models (useful in testing)

Model::clearBootedModels();

ModelQuery

ModelQuery is the query builder for models. Obtain one via Model::query().

Where Clauses

ModelQuery uses Razy's Simple Syntax for WHERE conditions. Placeholders (:name) are bound via the second $params array argument.

User::query()

    ->where('age>=:age', ['age' => 18])

    ->where('country=:country', ['country' => 'US'])

    ->get();

// WHERE age >= 18 AND country = 'US'



// OR conditions

User::query()

    ->where('role=:r1', ['r1' => 'admin'])

    ->orWhere('role=:r2', ['r2' => 'superadmin'])

    ->get();



// WHERE IN / NOT IN

User::query()->whereIn('status', ['active', 'pending'])->get();

User::query()->whereNotIn('role', ['banned', 'suspended'])->get();



// BETWEEN

User::query()->whereBetween('age', 18, 65)->get();

User::query()->whereNotBetween('score', 0, 10)->get();



// NULL checks

User::query()->whereNull('deleted_at')->get();

User::query()->whereNotNull('email_verified_at')->get();

Ordering & Limiting

User::query()

    ->orderBy('created_at', 'DESC')

    ->orderBy('name', 'ASC')

    ->limit(25)

    ->offset(50)

    ->get();

Selecting Columns

$users = User::query()

    ->select('id, name, email')

    ->get();

Aggregates

$total = User::query()->count();

// No need for ->get(), returns int directly

Scopes

Define reusable query constraints as scope{Name} methods on the model:

class Post extends Model

{

    public function scopePublished(ModelQuery $query): ModelQuery

    {

        return $query->where('status=:_s_status', ['_s_status' => 'published']);

    }



    public function scopeByAuthor(ModelQuery $query, int $authorId): ModelQuery

    {

        return $query->where('author_id=:_s_author', ['_s_author' => $authorId]);

    }



    public function scopeRecent(ModelQuery $query, int $days = 7): ModelQuery

    {

        $date = date('Y-m-d H:i:s', strtotime("-{$days} days"));

        return $query->where('created_at>=:_s_since', ['_s_since' => $date]);

    }

}



// Use scopes → called without the "scope" prefix

$posts = Post::query()

    ->published()

    ->byAuthor(42)

    ->recent(30)

    ->get();

Scopes are resolved via __call() on the query builder → published() dispatches to scopePublished().

Eager Loading

Solve the N+1 problem by pre-loading relationships:

// Without eager loading: 1 + N queries

$posts = Post::query()->get();

foreach ($posts as $post) {

    echo $post->author->name;  // each access = 1 query

}



// With eager loading: 2 queries total

$posts = Post::query()->with('author')->get();

foreach ($posts as $post) {

    echo $post->author->name;  // already loaded

}



// Multiple relationships

$posts = Post::query()->with('author', 'comments', 'tags')->get();

Bulk Operations

// Bulk update

Post::query()

    ->where('status=:s', ['s' => 'draft'])

    ->bulkUpdate(['status' => 'archived']);



// Bulk delete

Post::query()

    ->where('created_at<:before', ['before' => '2024-01-01'])

    ->bulkDelete();

Chunked Processing

Process large result sets in memory-efficient batches:

// Process 200 records at a time

User::query()->chunk(200, function (ModelCollection $users) {

    foreach ($users as $user) {

        $user->sendNewsletter();

    }

});



// Cursor-based iteration (one model at a time)

foreach (User::query()->cursor() as $user) {

    $user->recalculateScore();

}

ModelCollection

ModelCollection wraps an array of models with collection-style operations. Implements ArrayAccess, Countable, and IteratorAggregate.

Iteration & Access

$users = User::query()->get();



// Count

count($users);       // 42

$users->count();     // 42

$users->isEmpty();   // false



// Access

$first = $users->first();

$last  = $users->last();

$third = $users[2];   // ArrayAccess



// Contains

$users->contains(fn(User $u) => $u->role === 'admin');



// Iteration

foreach ($users as $user) {

    echo $user->name;

}



$users->each(function (User $user) {

    $user->notify();

});

Transformation Methods

// Extract single attribute

$emails = $users->pluck('email');

// ['alice@example.com', 'bob@example.com', ...]



// Map to new values

$names = $users->map(fn(User $u) => strtoupper($u->name));



// Filter

$admins = $users->filter(fn(User $u) => $u->role === 'admin');



// FlatMap

$tags = $posts->flatMap(fn(Post $p) => $p->tags);



// First matching condition

$alice = $users->firstWhere('name', 'Alice');



// Chunk into sub-collections

$batches = $users->chunk(10);



// Sort

$sorted = $users->sortBy('name');

$sorted = $users->sortBy(fn(User $u) => $u->created_at);



// Unique

$unique = $users->unique('role');



// Group / key

$byRole   = $users->groupBy('role');

// ['admin' => ModelCollection [...], 'member' => ModelCollection [...]]

$byId     = $users->keyBy('id');

// [1 => User, 2 => User, ...]



// Reduce

$totalAge = $users->reduce(fn(int $carry, User $u) => $carry + $u->age, 0);

Aggregation Methods

$users->sum('balance');

$users->avg('age');

$users->min('score');

$users->max('score');

Serialisation

$array = $users->toArray();  // array of model arrays

$json  = $users->toJson();   // JSON string

Paginator

Paginator wraps a page of results with metadata for building pagination UI. Implements ArrayAccess, Countable, IteratorAggregate, and JsonSerializable.

Creating Paginators

// Full pagination (with total count)

$page = User::query()

    ->where('role=:role', ['role' => 'member'])

    ->orderBy('name')

    ->paginate(2, 15);



// Simple pagination (no total count → faster)

$page = User::query()

    ->orderBy('created_at', 'DESC')

    ->simplePaginate(1, 25);

Navigation

$page->items();        // ModelCollection of current page

$page->total();        // Total records (null for simplePaginate)

$page->currentPage();  // 2

$page->perPage();      // 15

$page->lastPage();     // 7 (null for simplePaginate)

$page->count();        // Items on this page (→ perPage)



// Boolean helpers

$page->hasPages();      // total > perPage?

$page->hasMorePages();  // currentPage < lastPage?

$page->onFirstPage();   // currentPage === 1?

$page->onLastPage();    // currentPage === lastPage?

URL Generation

$page->setPath('/users');  // base URL path



$page->url(3);             // '/users?page=3'

$page->firstPageUrl();     // '/users?page=1'

$page->lastPageUrl();      // '/users?page=7'

$page->previousPageUrl();  // '/users?page=1' (null on first page)

$page->nextPageUrl();      // '/users?page=3' (null on last page)

Rendering Links

// Get a range of page numbers for navigation

$range = $page->getPageRange(window: 3);

// [1, 2, 3, 4, 5] (centered on current page)



// Build links for template rendering

$links = $page->links(window: 3);

/*

[

    ['url' => '/users?page=1', 'label' => '1', 'active' => false],

    ['url' => '/users?page=2', 'label' => '2', 'active' => true],

    ['url' => '/users?page=3', 'label' => '3', 'active' => false],

    ...

]

*/



// Serialise to JSON (useful for API responses)

echo json_encode($page);

/*

{

    "data": [...],

    "current_page": 2,

    "per_page": 15,

    "total": 100,

    "last_page": 7,

    "first_page_url": "/users?page=1",

    "last_page_url": "/users?page=7",

    "prev_page_url": "/users?page=1",

    "next_page_url": "/users?page=3"

}

*/

Relationships

Define relationships as methods on your model. Access them as properties for lazy loading or use with() for eager loading.

HasOne

A one-to-one relationship. The related table has the foreign key.

class User extends Model

{

    public function profile(): HasOne

    {

        return new HasOne(

            model: $this,

            related: Profile::class,

            foreignKey: 'user_id',    // default: inferred

            localKey: 'id',           // default: $primaryKey

        );

    }

}



// Lazy load

$user = User::find(1);

$profile = $user->profile;  // SELECT * FROM profiles WHERE user_id = 1 LIMIT 1



// Eager load

$users = User::query()->with('profile')->get();

HasOne::resolve() executes the query and returns the first matching model (or null).

HasMany

A one-to-many relationship. The related table has the foreign key.

class User extends Model

{

    public function posts(): HasMany

    {

        return new HasMany(

            model: $this,

            related: Post::class,

            foreignKey: 'author_id',

            localKey: 'id',

        );

    }

}



$user = User::find(1);

$posts = $user->posts;  // ModelCollection of Post

HasMany::resolve() returns a ModelCollection.

BelongsTo

The inverse of HasOne / HasMany. The current model has the foreign key.

class Post extends Model

{

    public function author(): BelongsTo

    {

        return new BelongsTo(

            model: $this,

            related: User::class,

            foreignKey: 'author_id',  // column on THIS table

            ownerKey: 'id',           // column on the related table

        );

    }

}



$post = Post::find(1);

$author = $post->author;  // User model

BelongsToMany

A many-to-many relationship via a pivot table.

class User extends Model

{

    public function roles(): BelongsToMany

    {

        return new BelongsToMany(

            model: $this,

            related: Role::class,

            pivotTable: 'role_user',       // pivot table name

            foreignPivotKey: 'user_id',    // FK pointing to this model

            relatedPivotKey: 'role_id',    // FK pointing to related model

        );

    }

}



class Role extends Model

{

    public function users(): BelongsToMany

    {

        return new BelongsToMany(

            model: $this,

            related: User::class,

            pivotTable: 'role_user',

            foreignPivotKey: 'role_id',

            relatedPivotKey: 'user_id',

        );

    }

}

Pivot Operations

$user = User::find(1);



// Attach roles

$user->roles()->attach(1);

$user->roles()->attach([2, 3, 4]);



// Detach roles

$user->roles()->detach(1);

$user->roles()->detach([2, 3]);



// Sync (set to exactly these IDs → removes extras, adds missing)

$user->roles()->sync([1, 3, 5]);



// Read

$roles = $user->roles;  // ModelCollection of Role

Resolve mechanism: BelongsToMany::resolve() executes two queries:

  1. SELECT role_id FROM role_user WHERE user_id = ? → get related IDs from pivot

  2. SELECT * FROM roles WHERE id IN (?, ?, ?) → fetch related models


SoftDeletes

The SoftDeletes trait adds "trash" functionality. Deleted records get a deleted_at timestamp instead of being removed from the database.

Setup

use Razy\ORM\SoftDeletes;



class Post extends Model

{

    use SoftDeletes;



    // Customise the column name (default: 'deleted_at')

    // public function getDeletedAtColumn(): string

    // {

    //     return 'removed_at';

    // }

}

The SoftDeletes trait registers a global scope via bootSoftDeletes() that adds WHERE deleted_at IS NULL to every query automatically.

Usage

$post = Post::find(1);



// Soft delete (sets deleted_at)

$post->delete();



// Check if trashed

$post->trashed();  // true



// Restore

$post->restore();

$post->trashed();  // false



// Permanently delete (bypass soft delete)

$post->forceDelete();

Querying Soft-Deleted Records

// Default: only non-deleted records

Post::query()->get();

// WHERE deleted_at IS NULL



// Include trashed records

Post::query()->withTrashed()->get();

// No deleted_at constraint



// Only trashed records

Post::query()->onlyTrashed()->get();

// WHERE deleted_at IS NOT NULL

These are implemented as local scopes (scopeWithTrashed, scopeOnlyTrashed) that remove or modify the global scope.


API Reference

Model (Static Methods)

| Method | Signature | Returns |

| --- | --- | --- |

| boot | (Database $db): void | Set DB connection |

| query | (): ModelQuery | New query builder |

| find | (mixed $id): ?static | Find by PK |

| findOrFail | (mixed $id): static | Find or throw |

| all | (): ModelCollection | All records |

| create | (array $attributes): static | Mass-assign + save |

| destroy | (mixed ...$ids): int | Delete by PKs |

| firstOrCreate | (array $search, array $values = []): static | Find or create |

| firstOrNew | (array $search, array $values = []): static | Find or instantiate |

| updateOrCreate | (array $search, array $values): static | Upsert |

| addGlobalScope | (string $name, Closure $scope): void | Register scope |

| removeGlobalScope | (string $name): void | Unregister scope |

| clearBootedModels | (): void | Reset all boot state |

Model (Instance Methods)

| Method | Signature | Returns |

| --- | --- | --- |

| fill | (array $attributes): static | Mass-assign |

| save | (): bool | INSERT or UPDATE |

| delete | (): bool | DELETE |

| refresh | (): static | Reload from DB |

| isDirty | (?string $attr = null): bool | Has changes? |

| getDirty | (): array | Changed attributes |

| getOriginal | (?string $attr = null): mixed | Original values |

| increment | (string $col, int\|float $amt = 1): static | Increment column |

| decrement | (string $col, int\|float $amt = 1): static | Decrement column |

| replicate | (): static | Clone without PK |

| touch | (): bool | Update updated_at |

| toArray | (): array | Serialise (respects hidden) |

| toJson | (): string | JSON output |

Model Events (Static)

| Method | Callback Signature |

| --- | --- |

| creating(Closure) | fn(Model $m): ?bool |

| created(Closure) | fn(Model $m): void |

| updating(Closure) | fn(Model $m): ?bool |

| updated(Closure) | fn(Model $m): void |

| saving(Closure) | fn(Model $m): ?bool |

| saved(Closure) | fn(Model $m): void |

| deleting(Closure) | fn(Model $m): ?bool |

| deleted(Closure) | fn(Model $m): void |

| restoring(Closure) | fn(Model $m): ?bool |

| restored(Closure) | fn(Model $m): void |

ModelQuery

| Method | Signature | Returns |

| --- | --- | --- |

| where | (string $syntax, array $params = []): static | Add AND condition (Simple Syntax) |

| orWhere | (string $syntax, array $params = []): static | Add OR condition (Simple Syntax) |

| whereIn | (string $col, array $vals): static | IN clause |

| whereNotIn | (string $col, array $vals): static | NOT IN clause |

| whereBetween | (string $col, mixed $min, mixed $max): static | BETWEEN |

| whereNotBetween | (string $col, mixed $min, mixed $max): static | NOT BETWEEN |

| whereNull | (string $col): static | IS NULL |

| whereNotNull | (string $col): static | IS NOT NULL |

| orderBy | (string $col, string $dir = 'ASC'): static | Sort |

| limit | (int $n): static | Limit rows |

| offset | (int $n): static | Skip rows |

| select | (string $columns): static | Column selection (comma-separated) |

| with | (string ...$relations): static | Eager load |

| withoutGlobalScope | (string ...$names): static | Remove named scope(s) |

| withoutGlobalScopes | (): static | Remove all scopes |

| get | (): ModelCollection | Execute query |

| first | (): ?Model | First result |

| find | (mixed $id): ?Model | By PK |

| count | (): int | Count rows |

| paginate | (int $page = 1, int $perPage = 15): Paginator | Full pagination |

| simplePaginate | (int $page = 1, int $perPage = 15): Paginator | No total count |

| chunk | (int $size, callable $callback): bool | Batch processing (return false to stop) |

| create | (array $attributes): Model | Insert row and return hydrated Model |

| cursor | (): Generator | Lazy iteration |

| bulkUpdate | (array $values): int | Mass UPDATE |

| bulkDelete | (): int | Mass DELETE |

ModelCollection

| Method | Signature | Returns |

| --- | --- | --- |

| first | (): ?Model | First item |

| last | (): ?Model | Last item |

| isEmpty | (): bool | Empty? |

| count | (): int | Item count |

| pluck | (string $key): array | Extract column |

| map | (Closure $fn): array | Transform |

| filter | (Closure $fn): static | Filter items |

| each | (Closure $fn): static | Iterate |

| contains | (Closure $fn): bool | Any match? |

| flatMap | (Closure $fn): array | Map + flatten |

| firstWhere | (string $key, mixed $val): ?Model | Find by attr |

| chunk | (int $size): array | Sub-collections |

| reduce | (Closure $fn, mixed $init): mixed | Accumulate |

| sum | (string $key): int\|float | Sum column |

| avg | (string $key): float | Average |

| min | (string $key): mixed | Minimum |

| max | (string $key): mixed | Maximum |

| sortBy | (string\|Closure $key): static | Sort |

| unique | (string $key): static | Deduplicate |

| groupBy | (string $key): array | Group into map |

| keyBy | (string $key): array | Key by column |

| toArray | (): array | Array of arrays |

| toJson | (): string | JSON string |

Paginator

| Method | Signature | Returns |

| --- | --- | --- |

| items | (): ModelCollection | Page data |

| total | (): ?int | Total rows |

| currentPage | (): int | Current page |

| perPage | (): int | Items per page |

| lastPage | (): ?int | Last page number |

| hasPages | (): bool | Multiple pages? |

| hasMorePages | (): bool | Not on last? |

| onFirstPage | (): bool | On page 1? |

| onLastPage | (): bool | On last page? |

| setPath | (string $path): static | Set base URL |

| url | (int $page): string | URL for page |

| firstPageUrl | (): string | First page URL |

| lastPageUrl | (): ?string | Last page URL |

| previousPageUrl | (): ?string | Previous URL |

| nextPageUrl | (): ?string | Next URL |

| getPageRange | (int $window): array | Page numbers |

| links | (int $window): array | Link data |

| toArray | (): array | Serialise |

| toJson | (): string | JSON |

Relationships

| Class | resolve() Returns |

| --- | --- |

| HasOne | ?Model (first match) |

| HasMany | ModelCollection |

| BelongsTo | ?Model (inverse first) |

| BelongsToMany | ModelCollection (two-query) |

BelongsToMany extra methods:

| Method | Signature | Returns |

| --- | --- | --- |

| attach | (int\|array $ids): void | Insert pivot rows |

| detach | (int\|array $ids): void | Remove pivot rows |

| sync | (array $ids): void | Set exact pivot set |


Common Mistakes

| Problem | Cause | Fix |

| --- | --- | --- |

| N+1 query performance | Looping $model->relation without eager-loading | Use ::with('relation') or query()->with(...) |

| Mass-assignment blocked | $fillable not defined on Model | Add protected array $fillable = [...] |

| save() silently fails | Forgot to pass $db to save() | Always call $model->save($db) |

| Wrong cast output | Using 'int' for a decimal column | Choose the correct $casts type ('float', 'decimal:2') |

| Soft-deleted rows returned | Querying without SoftDeletes trait awareness | Use withTrashed() / onlyTrashed() to control scope |

| Relationship returns null | Foreign key column name doesn't match convention | Pass explicit column names to hasOne() / belongsTo() |

Decision Guide

| Scenario | Use | Why |

| --- | --- | --- |

| Find or insert in one step | firstOrCreate() | Writes immediately if not found |

| Find or prepare (no auto-save) | firstOrNew() | Returns unsaved instance for further edits |

| Paginated HTML pages | paginate() | Generates full page count + links |

| Infinite scroll / API | simplePaginate() | Cheaper — only checks hasMorePages() |

| Fetch by primary key | Model::find($db, $id) | Returns single Model or null |

| Complex WHERE filters | Model::query($db)->where(...) | Builds full SQL via ModelQuery |

← Previous: CSRF

Queue

Clone this wiki locally