How Eloquent Actually Builds Your Models

How Eloquent Actually Builds Your Models

A deep dive into Laravel Eloquent under the hood — explore how models are resolved, hydrated, and persisted, and uncover the internal mechanics most developers use daily but rarely fully understand.

Steve McDougall

Steve McDougall

May 14, 2026

If you have been writing Laravel for a while, you have a comfortable relationship with Eloquent. You call User::find(1), a User model comes back, you access properties on it, save it, and move on. It feels natural. It also hides a surprising amount of machinery.

In the first article in this series, we pulled back the curtain on the service container and saw how Laravel resolves and wires dependencies together. In the second, we traced the path a request takes from the web server to your controller and back. This time, we are going to do the same thing with Eloquent: start with a model instance in your hands and work backwards to understand exactly how it got there, and what it is actually doing when you interact with it.

This is the piece of Laravel that most developers interact with every single day, and it is also one of the most commonly misunderstood at exam level.


What a Model Actually Is

An Eloquent model is not a special database object. It is a PHP class that extends Illuminate\\Database\\Eloquent\\Model, which itself is a fairly large class that composes a number of traits together. When you write this:

      class User extends Model
{
    protected $fillable = ['name', 'email'];
}

    

You are inheriting a class that already knows how to talk to a database connection, hydrate attributes from raw data, cast values to PHP types, track which attributes have changed, serialize itself to JSON, dispatch model events, and much more. The model is the central object in Laravel's data layer, and understanding it deeply pays dividends when you are debugging unexpected behaviour or making architectural decisions.

The boot Method and Model Initialization

Before any model instance is created, Eloquent runs a static initialization routine. This is where a lot of important setup happens, and it is heavily tested at the senior and Artisan Master certification levels.

When a model class is first used, Laravel calls the static boot method. The base Model::boot() calls bootTraits(), which loops through all traits used by the model and calls any method named boot{TraitName} that it finds. This is how traits like SoftDeletes register their global scopes without you having to do anything.

      // This is what SoftDeletes does in its bootSoftDeletes method
protected static function bootSoftDeletes(): void
{
    static::addGlobalScope(new SoftDeletingScope);
}

    

You can hook into this yourself. Overriding boot in your own model is a common pattern for registering observers, adding global scopes, or wiring up model event listeners:

      class Post extends Model
{
    protected static function boot(): void
    {
        parent::boot();

        static::creating(function (Post $post) {
            $post->slug = Str::slug($post->title);
        });
    }
}

    

A subtlety that trips developers up in exam questions: boot is static and runs once per class per request. It is not called every time you instantiate a model. If you need per-instance initialization, initialize is the correct hook. Traits can define initialize{TraitName} methods, which are called on every new instance via the Model constructor.

How Attributes Are Stored

Eloquent does not map database columns to PHP class properties the way you might expect. There are no public $name declarations on your model. Instead, all attributes are stored in a single protected array called $attributes:

      protected $attributes = [];

    

When you do $user->name = 'Alice', PHP intercepts the property assignment through the __set magic method, which calls setAttribute('name', 'Alice'). When you read $user->name, __get intercepts the access and calls getAttribute('name').

This is the mechanism that makes accessors, mutators, and casting possible. Every attribute access goes through a method, which means Eloquent has a chance to transform the value before it reaches you.

Attribute Casting

Casting is one of the most-tested Eloquent features in the certification exams. The $casts array tells Eloquent how to transform attribute values when reading from and writing to the database:

      class Order extends Model
{
    protected $casts = [
        'options'      => 'array',
        'confirmed_at' => 'datetime',
        'total'        => 'decimal:2',
        'is_paid'      => 'boolean',
    ];
}

    

When you read $order->options, Eloquent calls castAttribute(), which detects the array cast and runs json_decode() on the raw database value. When you set $order->options = ['color' => 'red'], Eloquent calls castAttributeAsJson() and stores the encoded string. The database always stores JSON; your PHP code always sees an array.

Custom Casts

Since Laravel 7, you can write your own cast classes. This is senior and Artisan Master territory. A custom cast implements CastsAttributes and defines get and set methods:

      class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money($value, $attributes['currency']);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [
            'amount'   => $value->getAmount(),
            'currency' => $value->getCurrency(),
        ];
    }
}

    

Notice that set returns an array. This allows a single logical attribute to write to multiple database columns, which is a powerful and frequently tested capability.

You can also write inbound-only casts (for hashing passwords, for example) by implementing CastsInboundAttributes instead.

Accessors and Mutators

Before custom casts existed, accessors and mutators were the primary way to transform attributes. They are still useful today, and they work differently from casts.

The modern syntax (introduced in Laravel 9) uses a single method returning an Attribute object:

      class User extends Model
{
    protected function name(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucfirst($value),
            set: fn (string $value) => strtolower($value),
        );
    }
}

    

Eloquent discovers this by looking for a method whose name matches the camel-case version of the attribute. When you read $user->name, it finds name(), calls the get closure, and returns the result.

The key distinction between accessors and casts: casts work with the raw database value and are type-focused. Accessors are about presentation and transformation logic. A cast converts "1" to true. An accessor might combine first_name and last_name into a computed full_name that has no corresponding column.

The Dirty Tracking System

Eloquent keeps two copies of every model's data: $attributes (the current state) and $original (the state as it was when the model was hydrated from the database). The difference between them is what gets written when you call save().

      $user = User::find(1); // $original = ['name' => 'Alice', 'email' => 'alice@example.com']
$user->name = 'Bob';   // $attributes = ['name' => 'Bob', 'email' => 'alice@example.com']

$user->isDirty('name');  // true
$user->isClean('email'); // true
$user->getDirty();       // ['name' => 'Bob']

    

When save() is called, Eloquent calls getDirty() to build the UPDATE statement. If nothing is dirty, no query is executed at all.

This system also drives the updating and updated model events. They only fire when there are actual changes. If you call save() on an unmodified model, no events fire.

Global and Local Scopes

Scopes are a way to encapsulate reusable query constraints. They are commonly tested, and the distinction between global and local scopes is important.

A global scope is applied automatically to every query on a model. SoftDeletes uses one. You can add your own:

      class ActiveScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('active', true);
    }
}

class User extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new ActiveScope);
    }
}

// Now User::all() only returns active users
// To remove the scope: User::withoutGlobalScope(ActiveScope::class)->get()

    

A local scope is an opt-in constraint you define as a method on the model:

      class Post extends Model
{
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published');
    }
}

// Called as: Post::published()->get()

    

Eloquent finds local scopes by looking for methods prefixed with scope. The method receives the current query builder, and you return it with the additional constraints applied.

Model Events and Observers

Eloquent fires events at various points in a model's lifecycle: creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, and retrieved. These events allow you to hook into data operations without polluting your controllers or service classes.

The cleanest way to handle model events is with an Observer, which groups all the listeners for a model into a single class:

      class UserObserver
{
    public function creating(User $user): void
    {
        $user->uuid = Str::uuid();
    }

    public function updated(User $user): void
    {
        Cache::forget("user:{$user->id}");
    }

    public function deleted(User $user): void
    {
        $user->subscriptions()->delete();
    }
}

    

You register it in a service provider, or using the #[ObservedBy] attribute on the model class (available from Laravel 10):

      #[ObservedBy(UserObserver::class)]
class User extends Model {}

    

One exam-critical detail: saving fires before both creating and updating. saved fires after both created and updated. Knowing the event order matters when you are deciding where to put side effects.

Also worth knowing: bulk operations like User::where(...)->delete() do NOT fire model events because they operate at the query builder level and never instantiate models. This is a common trap.

Hydration: How Raw Data Becomes a Model

When Eloquent retrieves data from the database, it gets back plain PHP arrays. The process of turning those arrays into model instances is called hydration.

The newFromBuilder method on the model is the entry point. It calls newInstance to create a blank model, then calls setRawAttributes on it, passing the data as-is. Critically, setRawAttributes bypasses casting and mutators. The raw value goes directly into $attributes, and also gets stored in $original so dirty tracking works correctly.

The casting and accessor transformations only happen at read time, when you access an attribute. This is a lazy system: Eloquent does not cast every attribute as soon as a model is hydrated, only when the attribute is accessed.

This has a practical implication. If you hydrate 1000 models but only read one attribute on each, the other attributes are never cast. This is efficient, but it means you need to be deliberate when you are serializing models (to JSON or arrays), since toArray() does trigger accessors and casts across all visible attributes.

The newInstance Method and Model Replication

newInstance is worth knowing explicitly. It is used internally whenever Eloquent needs to create a blank model of the same type, for example when creating a related model through a relationship, or when you call replicate().

      $original = User::find(1);
$copy = $original->replicate(['email']); // excludes email from the copy
$copy->email = 'new@example.com';
$copy->save();

    

replicate calls newInstance with a copy of the model's attributes (minus the excluded ones and the primary key). The new instance is not yet persisted, so $exists is false and save() will run an INSERT rather than an UPDATE.

Putting It Together: What Really Happens on find(1)

Let us trace the full path of User::find(1):

  1. find is a static method called on the User class. Because User does not define it, PHP resolves it via __callStatic, which proxies to a new query builder instance for the User model.
  2. The query builder constructs and executes a SELECT * FROM users WHERE id = 1 LIMIT 1 query.
  3. The raw result comes back from PDO as an associative array.
  4. Eloquent calls newFromBuilder on the User model, which calls newInstance to get a blank User, then passes the raw data to setRawAttributes.
  5. The instance has $exists = true set on it, since it came from the database.
  6. Global scopes were applied to the query automatically, so if SoftDeletes is on this model, only non-deleted records were considered.
  7. The model is returned. No casting has happened yet.
  8. When you access $user->name, __get fires, getAttribute is called, casting rules are checked, and the value is returned.

This chain is not magic. Every step has a clear method you can look up in the framework source. Once you understand it, questions about unexpected behaviour become much easier to reason through.


Why This Matters for Your Certification

Eloquent is the single most-tested topic across the senior question bank, with over 70 questions touching model behaviour in some way. The questions at the junior level tend to be about what Eloquent does: how to define relationships, how to use scopes, what fillable means. At the senior and Artisan Master levels, the questions are about why and how: why does a where()->delete() skip observers, what is the difference between setAttribute and setRawAttributes, when does boot run versus initialize, what does a custom cast's set method return and why.

The concepts covered in this article map directly to those questions. Understanding the boot and booted lifecycle methods is tested heavily. Knowing the order of model events (and which operations bypass them entirely) appears regularly. Custom casts and the distinction between casts and accessors are frequent senior-level topics. Dirty tracking and how save() builds its query come up when questions involve model state and performance.

Eloquent is designed to hide complexity behind a clean API. That is its great strength. But for certification purposes, and for being an effective senior developer, you need to be comfortable looking past the API and understanding the engine underneath it.

The next article in this series turns to a topic that interacts closely with everything you have just read: how Laravel's query builder constructs SQL, and what happens when Eloquent hands off to it.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)