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

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() and firstOrFail() throw ModelNotFoundException, which Laravel renders as a 404, so controllers need no if (! $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 the BookObserver described below.

  • isDirty(), getOriginal(), wasChanged() and getChanges() report what changed before and after save(); 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

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

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]);
    }
}

$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()

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
  • restore(), forceDelete(), withTrashed() and onlyTrashed() 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 includes deleted_at.

  • Adding SoftDeletes to Review broke a test written earlier for Controllers: assertModelMissing($review) failed because the row still exists. Tests of soft-deleting code use assertSoftDeleted().

  • MassPrunable deletes with one query per chunk and fires no events; Prunable loads each model and calls its pruning() 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 to app/Models in Laravel 8.

  • protected $fillable, $table and $connection properties are complemented by class attributes in Laravel 13, and scopeXxx() methods by #[Scope] (Laravel 12).

  • Str::uuid() (UUIDv4) primary keys are replaced by HasUuids, which generates time-ordered UUIDv7 keys (Laravel 12; HasVersion4Uuids keeps the old behaviour).

  • $dates = ['published_at'] is the casts() 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.