-
Notifications
You must be signed in to change notification settings - Fork 0
ORM
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.
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();All models extend Razy\ORM\Model. The base class provides attribute management, persistence, events, and relationship resolution.
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).
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();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') |
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();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();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// 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'],
);// 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$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);$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();$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();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.
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 is the query builder for models. Obtain one via Model::query().
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();User::query()
->orderBy('created_at', 'DESC')
->orderBy('name', 'ASC')
->limit(25)
->offset(50)
->get();$users = User::query()
->select('id, name, email')
->get();$total = User::query()->count();
// No need for ->get(), returns int directlyDefine 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().
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 update
Post::query()
->where('status=:s', ['s' => 'draft'])
->bulkUpdate(['status' => 'archived']);
// Bulk delete
Post::query()
->where('created_at<:before', ['before' => '2024-01-01'])
->bulkDelete();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 wraps an array of models with collection-style operations. Implements ArrayAccess, Countable, and IteratorAggregate.
$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();
});// 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);$users->sum('balance');
$users->avg('age');
$users->min('score');
$users->max('score');$array = $users->toArray(); // array of model arrays
$json = $users->toJson(); // JSON stringPaginator wraps a page of results with metadata for building pagination UI. Implements ArrayAccess, Countable, IteratorAggregate, and JsonSerializable.
// 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);$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?
$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)// 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"
}
*/Define relationships as methods on your model. Access them as properties for lazy loading or use with() for eager loading.
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).
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 PostHasMany::resolve() returns a ModelCollection.
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 modelA 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',
);
}
}$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 RoleResolve mechanism: BelongsToMany::resolve() executes two queries:
-
SELECT role_id FROM role_user WHERE user_id = ?→ get related IDs from pivot -
SELECT * FROM roles WHERE id IN (?, ?, ?)→ fetch related models
The SoftDeletes trait adds "trash" functionality. Deleted records get a deleted_at timestamp instead of being removed from the database.
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.
$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();// 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 NULLThese are implemented as local scopes (scopeWithTrashed, scopeOnlyTrashed) that remove or modify the global scope.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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() |
| 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 |