-
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
|