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., User ??user_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