Eloquent Models
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The models are
Bookshelf’s Book, Author, Review and Loan, and the examples run on PostgreSQL 17.11 from a plain PHP script
that boots the application (Tinker’s --execute collapses runs of spaces in its input, which hid one of the results
below). Eloquent is Laravel’s ORM and follows the Active Record pattern: each model class maps to a table, each
instance to a row, and the object saves itself. Doctrine ORM follows Data Mapper instead, with plain entities and a
separate entity manager, the distinction the Wiley framework book draws in its ORM chapters. Its
relationships are on Eloquent Relationships, casts and
serialization on Eloquent Casts, Accessors and
Serialization.
A model and its conventions
php artisan make:model Loan -mf creates the model with a migration and a factory (-a adds a controller, policy,
seeder and form requests). With no configuration, Loan uses the loans table, an auto-incrementing id key, the
default connection and created_at/updated_at timestamps; Loan here overrides the key with ULIDs:
<?php
// app/Models/Loan.php
namespace App\Models;
use App\Enums\LoanStatus;
use App\Models\Scopes\NotLostScope;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[ScopedBy(NotLostScope::class)]
#[Fillable(['book_id', 'user_id', 'status', 'note', 'due_at', 'returned_at', 'late_fee_cents'])]
class Loan extends Model
{
/** @use HasFactory<\Database\Factories\LoanFactory> */
use HasFactory, HasUlids;
protected function casts(): array
{
return ['status' => LoanStatus::class, 'due_at' => 'datetime', 'returned_at' => 'datetime'];
}
public function book(): BelongsTo
{
return $this->belongsTo(Book::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Laravel 13 lets the conventions be overridden with class attributes instead of properties: #[Table('my_loans',
key: 'loan_id', keyType: 'string', incrementing: false, timestamps: false)], #[Connection('mysql')],
#[Fillable([...])], #[Unguarded], #[Refreshes('slug')] for generated columns, and #[WithoutTimestamps];
#[ScopedBy], #[ObservedBy] and #[Scope] already existed. The $table, $primaryKey, $fillable and
$connection properties still work. Eloquent has no composite primary keys: a pivot-like table with a composite key,
such as book_tag, is used through relationships, not as a model.
HasUuids generates UUIDv7 keys and HasUlids ULIDs, both time-ordered, which keeps B-tree inserts at the end of
the index instead of scattering them as random UUIDv4 keys do:
uuid7: 01a10dda-2b5b-70f4-a4e2-67774597f3ef 01a10dda-2b5d-724c-991d-7b850c68b403 sorted=true version=7
table=books key=id incrementing=true loan key type=string
Source: Laravel — UUID and ULID Keys
php artisan model:show Book prints the table, columns, casts, relationships and observers Eloquent sees for a model.
Retrieving, creating and updating
A model is a query builder entry point: Book::where(...)->get() returns an Eloquent collection of models, and every
builder method from Database and Query Builder works. The
model-specific methods, from one run:
<?php
use App\Models\{Author, Book};
$butler = Author::where('name', 'Octavia E. Butler')->firstOrFail();
echo 'find: ', Book::find(1)?->title, '; findOrFail(999): ';
try { Book::findOrFail(999); } catch (Illuminate\Database\Eloquent\ModelNotFoundException $e) { echo class_basename($e), ' -> 404', PHP_EOL; }
$dawn = Book::firstOrCreate(['isbn' => '978-0-446-60377-6'], ['author_id' => $butler->id, 'title' => ' Dawn ', 'published_year' => 1987]);
echo 'firstOrCreate: "', $dawn->title, '" wasRecentlyCreated=', var_export($dawn->wasRecentlyCreated, true), PHP_EOL;
echo 'again: wasRecentlyCreated=', var_export(Book::firstOrCreate(['isbn' => '978-0-446-60377-6'], ['title' => 'x'])->wasRecentlyCreated, true), PHP_EOL;
$dawn->published_year = 1988;
echo 'isDirty=', var_export($dawn->isDirty('published_year'), true), ' original=', $dawn->getOriginal('published_year');
$dawn->save();
echo ' wasChanged=', var_export($dawn->wasChanged('published_year'), true), PHP_EOL;
find: Kindred; findOrFail(999): ModelNotFoundException -> 404
firstOrCreate: "Dawn" wasRecentlyCreated=true
again: wasRecentlyCreated=false
isDirty=true original=1987 wasChanged=true
-
findOrFail()andfirstOrFail()throwModelNotFoundException, which Laravel renders as a 404, so controllers need noif (! $book) abort(404). -
firstOrCreate()looks up by the first array and creates with both arrays merged; the second call found the row and ignored'title' => 'x'.firstOrNew()returns an unsaved model,updateOrCreate()updates the match. Under concurrency two requests can both miss and both insert;createOrFirst()inserts first and falls back to a lookup on a unique-constraint violation, which is race-free when the column has a unique index. -
" Dawn "was saved as"Dawn"by theBookObserverdescribed below. -
isDirty(),getOriginal(),wasChanged()andgetChanges()report what changed before and aftersave();save()with nothing dirty runs no query.
For large tables, chunkById(), lazyById() and cursor() iterate without loading everything; the trade-offs are
measured on Database and Query Builder. Aggregates (count,
sum, max) return scalars, not models.
Mass assignment
Book::create($array), fill() and update($array) only set attributes listed as fillable, so a request that adds
is_admin=1 or id=… to a form cannot set them. Laravel 13’s #[Fillable] attribute replaces the $fillable
property:
<?php
use App\Models\Book;
use Illuminate\Database\Eloquent\Model;
$attempt = Book::create(['id' => 999, 'author_id' => $butler->id, 'title' => 'Imago', 'isbn' => '978-0-446-60363-9', 'published_year' => 1989]);
echo 'create with id=999 -> id ', $attempt->id, PHP_EOL;
Model::preventSilentlyDiscardingAttributes();
try { Book::create(['id' => 998, 'author_id' => $butler->id, 'title' => 'Adulthood Rites', 'isbn' => '978-0-446-60378-3']); }
catch (Illuminate\Database\Eloquent\MassAssignmentException $e) { echo 'strict: ', $e->getMessage(), PHP_EOL; }
Model::preventSilentlyDiscardingAttributes(false);
$unguarded = Model::unguarded(fn () => Book::create(['id' => 997, 'author_id' => $butler->id, 'title' => 'Fledgling', 'isbn' => '978-0-446-69616-7']));
echo 'unguarded: id ', $unguarded->id, PHP_EOL;
create with id=999 -> id 6
strict: Add fillable property [id] to allow mass assignment on [App\Models\Book].
unguarded: id 997
Source: Laravel — Mass Assignment
By default the unknown id was dropped silently: the book got id 6. That hides typos too (publishd_year would
vanish the same way), which is why strict mode turns it into an exception in development. Model::unguarded() (or
#[Unguarded], Model::unguard()) disables the protection; with Book::create($request->all()) that lets a user
set any column, including the primary key as above. The safe pattern combines both layers: fillable lists on the
model and $request->validated() or safe()->only([...]) from
Validation and Form Requests. $guarded = [] is the same as
unguarding the model.
Query updates skip model events
<?php
use App\Models\Book;
Book::where('id', $dawn->id)->update(['title' => ' Dawn (Xenogenesis 1) ']);
echo 'query update: "', Book::find($dawn->id)->title, '"', PHP_EOL;
$dawn->refresh()->update(['title' => ' Dawn (Xenogenesis 1) ']);
echo 'model update: "', $dawn->title, '"', PHP_EOL;
query update: " Dawn (Xenogenesis 1) "
model update: "Dawn (Xenogenesis 1)"
Source: Laravel — Mass Updates
Book::where(...)->update() is one SQL UPDATE: no models are loaded, so saving, updating, updated and
saved events never fire, and the observer’s whitespace clean-up did not run. The same applies to
Book::where(...)->delete(), increment() on a query, upsert() and insert(). Logic that must always run belongs
in the database (constraints, triggers) or in code paths that load models.
Events and observers
Eloquent fires retrieved, creating/created, updating/updated, saving/saved, deleting/deleted,
trashed, forceDeleting/forceDeleted, restoring/restored and replicating. An observer groups the handlers
for one model and is attached with #[ObservedBy]:
<?php
// app/Observers/BookObserver.php (attached with #[ObservedBy(BookObserver::class)] on App\Models\Book)
namespace App\Observers;
use App\Models\Book;
use Illuminate\Support\Facades\Log;
class BookObserver
{
public function saving(Book $book): void // before insert and update
{
$book->title = trim(preg_replace('/\s+/', ' ', $book->title));
}
public function deleted(Book $book): void
{
Log::info('book deleted', ['id' => $book->id, 'title' => $book->title]);
}
}
Source: Laravel — Observers
$book->saveQuietly(), deleteQuietly() and Model::withoutEvents(fn () => ...) skip events; with the observer above,
saveQuietly() stored " Kindred " unchanged. An observer that implements ShouldHandleEventsAfterCommit runs its
handlers only after the surrounding transaction commits, which is the right choice when a handler sends mail or
dispatches jobs. Observers make behaviour implicit: a reader of BookController cannot see that titles are
rewritten, so keep them to cross-cutting concerns (normalising, auditing, cache invalidation) and put business rules
in actions.
Local and global scopes
A local scope is a reusable query constraint, marked with #[Scope] on a protected method; a global scope
applies to every query of the model:
<?php
// app/Models/Book.php (excerpt)
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
// ...
#[Scope] // Book::publishedBefore(1980)
protected function publishedBefore(Builder $query, int $year): void
{
$query->where('published_year', '<', $year);
}
#[Scope]
protected function byAuthor(Builder $query, string $name): void
{
$query->whereRelation('author', 'name', $name);
}
}
<?php
// app/Models/Scopes/NotLostScope.php (attached with #[ScopedBy(NotLostScope::class)] on App\Models\Loan)
namespace App\Models\Scopes;
use App\Enums\LoanStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
/** A global scope: every Loan query leaves out lost books unless asked. */
class NotLostScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', '!=', LoanStatus::Lost);
}
}
scopes: The Dispossessed, The Lathe of Heaven # Book::publishedBefore(1980)->byAuthor('Ursula K. Le Guin')
loans: 1 visible, 2 in total # after adding a lost loan: Loan::count() vs withoutGlobalScopes()
Source: Laravel — Query Scopes
The #[Scope] attribute (Laravel 12) replaces the scopePublishedBefore() naming convention, which still works.
Global scopes are easy to forget: reports, admin screens and unique-validation queries (Rule::unique, which uses the
query builder, not the model) see different data from the rest of the code. withoutGlobalScope(NotLostScope::class)
removes one where needed.
Soft deletes, pruning and replicating
SoftDeletes (with a softDeletes() column) turns delete() into setting deleted_at, and excludes those rows
through a global scope. Prunable or MassPrunable with a prunable() query let php artisan model:prune (scheduled
daily) delete old rows for good:
<?php
// app/Models/Review.php (excerpt)
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Review extends Model
{
use HasFactory, MassPrunable, SoftDeletes;
// ...
public function prunable(): Builder // php artisan model:prune
{
return static::onlyTrashed()->where('deleted_at', '<', now()->minus(days: 30));
}
}
soft: 0 visible, 1 with trashed, trashed()=true # $review->delete(): Review::count() vs withTrashed()
replicate: 7 Kindred (25th anniversary) 1979 # Book::find(1)->replicate(['isbn'])->fill([...])->save()
$ php artisan model:prune --pretend
INFO 1 [App\Models\Review] records will be pruned.
$ php artisan model:prune
INFO Pruning [App\Models\Review] records.
App\Models\Review ................................................ 1 records
Source: Laravel — Soft Deleting and Pruning Models
-
restore(),forceDelete(),withTrashed()andonlyTrashed()manage soft-deleted rows; unique indexes still see them, so a soft-deleted ISBN blocks a new book with the same ISBN unless the index includesdeleted_at. -
Adding
SoftDeletestoReviewbroke a test written earlier for Controllers:assertModelMissing($review)failed because the row still exists. Tests of soft-deleting code useassertSoftDeleted(). -
MassPrunabledeletes with one query per chunk and fires no events;Prunableloads each model and calls itspruning()hook first, for example to delete files. -
replicate()copies attributes except the key and timestamps (and the listed columns), and leaves the copy unsaved.
Strict mode
Model::shouldBeStrict(! app()->isProduction()) in AppServiceProvider::boot() turns on three checks at once:
prevent lazy loading, prevent silently discarding attributes (above) and prevent accessing attributes that were not
selected:
<?php
use App\Models\Book;
use Illuminate\Database\Eloquent\Model;
Model::shouldBeStrict();
echo 'single model: ', Book::first()->author->name, PHP_EOL; // not prevented: only one model loaded
try { Book::all()->first()->author->name; } catch (Illuminate\Database\LazyLoadingViolationException $e) { echo 'lazy: ', $e->getMessage(), PHP_EOL; }
$partial = Book::select('id', 'title')->first();
try { $partial->isbn; } catch (Illuminate\Database\Eloquent\MissingAttributeException $e) { echo 'missing: ', $e->getMessage(), PHP_EOL; }
echo 'eager: ', Book::with('author')->first()->author->name, PHP_EOL;
single model: Octavia E. Butler
lazy: Attempted to lazy load [author] on model [App\Models\Book] but lazy loading is disabled.
missing: The attribute [isbn] either does not exist or was not retrieved for model [App\Models\Book].
eager: Octavia E. Butler
Lazy loading is only prevented on models that were loaded together with other models: Book::first() loaded one
model, so ->author was allowed, because a single lazy load cannot become an N+1 loop. A missing attribute normally
returns null, which hides bugs where a select() left out a column. Strict mode is usually off in production so a
missed case degrades instead of failing; N+1 detection is covered on
Query Performance and N+1.
Where older books and tutorials are outdated
-
Models in
app/(App\Book) (Stauffer ch. 5) moved toapp/Modelsin Laravel 8. -
protected $fillable,$tableand$connectionproperties are complemented by class attributes in Laravel 13, andscopeXxx()methods by#[Scope](Laravel 12). -
Str::uuid()(UUIDv4) primary keys are replaced byHasUuids, which generates time-ordered UUIDv7 keys (Laravel 12;HasVersion4Uuidskeeps the old behaviour). -
$dates = ['published_at']is thecasts()method with'datetime'(Laravel 10 removed$dates). -
Model::unguard()in seeders and$guarded = []everywhere trade safety for convenience; validated input and fillable lists are the documented pattern. -
Observers registered in
EventServiceProvider(Book::observe(...)or$observers) are#[ObservedBy]on the model (documented since Laravel 10) in new code.