Skip to main content

🗂️ Eloquent Model Definition

Eloquent is Laravel's Active Record ORM: every database table gets a matching PHP class that knows how to read, write, and reason about its own rows. In this lesson you'll define models the modern Laravel 11 way — conventions, mass-assignment safety, casting, accessors, and lifecycle hooks — and assemble a production-ready Product model.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Generate a model with Artisan and understand the table, key, and timestamp conventions Eloquent assumes
  • Protect against over-posting with $fillable vs $guarded mass-assignment rules
  • Transform data with the Laravel 11 casts() method, accessors, and mutators via the Attribute class
  • Hook into the model lifecycle with events and dedicated observers
  • Combine every feature into a realistic e-commerce Product model

Estimated Time: 45–55 minutes  •  Difficulty: Intermediate

Hands-on: Build a fully-featured Category model with casts, an accessor, and an observer.

In This Lesson

What Is Eloquent?

Eloquent is Laravel's implementation of the Active Record pattern. Each database table has a corresponding Model, and a single instance of that model represents a single row. The class carries both the data and the behavior — it knows how to save itself, how it relates to other tables, and what business rules apply to it.

💡 A useful analogy: Think of a model as an intelligent representative for a table. Instead of you writing SQL to fetch a row, tweak a column, and write it back, you ask the representative — "give me product 5, mark it on sale, save it" — and it handles the SQL on your behalf.
Where the model sits in a Laravel application Controllers and routes talk to an Eloquent model, which maps object operations onto a database table. Routes & Controllers Product Eloquent Model products DB table SQL
Figure 1 — The model is the boundary between your object-oriented PHP code and the relational database. Everything above it thinks in objects; everything below it thinks in rows.

The power of Eloquent is that it abstracts away routine SQL while still letting you drop down to custom queries when you need precision — like a car with both an automatic transmission for everyday driving and a manual mode for the tricky bits.

Creating Your First Model

Laravel ships an Artisan generator for models. The flags let you scaffold companion files in one shot:

# A bare model in app/Models
php artisan make:model Product

# Model + database migration
php artisan make:model Product -m

# Model + migration, factory, seeder, and resource controller
php artisan make:model Product -mfsc

# Everything a resource needs (migration, factory, seeder,
# controller, form requests, policy) in one command
php artisan make:model Product --all

The generated class lands in app/Models. In Laravel 11 it looks like this:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    // Configuration and behavior go here
}

📖 Key Terms

Migration: a versioned PHP file that defines the table's columns, so the schema lives in code alongside the model.

Factory: a blueprint for generating fake model instances, invaluable for tests and seeding.

Convention over configuration: Eloquent guesses sensible defaults (table name, key, timestamps) so you only write config when you deviate.

Tables, Keys & Timestamps

Eloquent infers a lot from the class name. Knowing the defaults — and how to override them — saves you from fighting the framework.

Table name

Eloquent uses the snake_case, plural form of the class name:

Model classAssumed table
Userusers
Productproducts
OrderItemorder_items

Override it when your table name doesn't follow the pattern:

class Product extends Model
{
    // Point at a table Eloquent would not guess
    protected $table = 'store_products';
}

Primary key

By default Eloquent expects an auto-incrementing integer column named id. To use a different column, a UUID, or a non-incrementing key:

class Product extends Model
{
    protected $primaryKey = 'product_id';   // custom key column
    public $incrementing = false;            // not auto-incrementing
    protected $keyType = 'string';           // key is a string (e.g. UUID)
}

Timestamps

Eloquent automatically maintains created_at and updated_at columns. Disable or rename them if your table differs:

class Product extends Model
{
    // Turn timestamp management off entirely
    public $timestamps = false;

    // Or rename the columns Eloquent writes to
    const CREATED_AT = 'creation_date';
    const UPDATED_AT = 'last_update';
}

⚠️ Common trip-up

The $timestamps flag is a public property, not protected. If you write protected $timestamps = false; Laravel will still try to write the columns and you'll get a "column not found" error. Match the visibility exactly.

Mass-Assignment Protection

Mass assignment means setting many attributes at once from an array — usually straight from a form request:

$product = Product::create([
    'name' => 'Smartphone',
    'price' => 599.99,
    'description' => 'Latest smartphone model',
]);

Convenient — but dangerous if the array comes from user input. A malicious visitor could append is_admin or approved to the request and quietly grant themselves privileges. That's why Eloquent requires you to declare which fields are safe. You choose one of two strategies:

Fillable allow-list versus guarded block-list An incoming attribute array is filtered either by a fillable allow-list or a guarded block-list before reaching the model. Input array incl. is_admin? $fillable allow-list (safest) $guarded block-list Safe model
Figure 2 — $fillable is a whitelist (only these may be mass-assigned); $guarded is a blacklist (everything except these may be). Pick exactly one per model.

Fillable (recommended)

protected $fillable = [
    'name',
    'price',
    'description',
    'category_id',
    'stock_quantity',
];

Guarded

protected $guarded = ['id', 'is_admin', 'approved'];

// Or disable protection entirely (only when input is fully trusted):
protected $guarded = [];

✅ Best practice

Prefer $fillable. An allow-list fails safe: forget to list a new column and it simply won't be mass-assignable. A block-list fails open: forget to guard a new sensitive column and it's suddenly writable from a form.

Attribute Casting

Databases store almost everything as strings. Casting tells Eloquent to convert columns to and from proper PHP types automatically, so is_featured comes back as a real boolean and metadata as a real array.

In Laravel 11 the idiomatic place to declare casts is the casts() method (it replaces the older protected $casts array, though that still works):

use Illuminate\Database\Eloquent\Casts\AsCollection;

protected function casts(): array
{
    return [
        'price'        => 'decimal:2',
        'is_featured'  => 'boolean',
        'options'      => 'array',
        'published_at' => 'datetime',
        'metadata'     => AsCollection::class,
        'sale_ends_at' => 'datetime:Y-m-d',
    ];
}

Common cast types include integer, float, decimal:<digits>, string, boolean, array, collection, object, date, datetime, and encrypted. Once cast, JSON columns are effortless:

// With 'options' cast to array — read and write like native PHP
$product->options = ['color' => 'blue', 'size' => 'large'];
$product->save();

echo $product->options['color']; // blue — no json_decode needed

Custom casts

For domain-specific conversions — say, storing money as integer cents but exposing dollars — write a cast class:

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class Money implements CastsAttributes
{
    // Cents in the DB -> dollars in PHP
    public function get(Model $model, string $key, mixed $value, array $attributes): string
    {
        return number_format($value / 100, 2);
    }

    // Dollars in PHP -> cents in the DB
    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [$key => (int) round($value * 100)];
    }
}
protected function casts(): array
{
    return ['price' => \App\Casts\Money::class];
}

Accessors & Mutators

Accessors transform an attribute when you read it; mutators transform it when you write it. Since Laravel 9 both live in a single method returning an Attribute object — this is the current Laravel 11 style.

An accessor (read transform)

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function formattedPrice(): Attribute
{
    return Attribute::make(
        get: fn () => '$' . number_format($this->price, 2),
    );
}

// Usage — access the snake_case version of the method name
echo $product->formatted_price; // $149.99

A mutator (write transform)

This example keeps a slug in sync whenever the name changes — a single method handles both directions:

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Str;

protected function name(): Attribute
{
    return Attribute::make(
        get: fn (string $value) => $value,
        set: fn (string $value) => [
            'name' => $value,
            'slug' => Str::slug($value),
        ],
    );
}

// Setting the name also sets the slug
$product->name = 'Wireless Headphones'; // slug becomes "wireless-headphones"
💡 Accessor vs cast: Reach for a cast when you're converting a stored value to a standard PHP type (bool, array, date). Reach for an accessor when you're computing a presentation value or combining columns (full_name, formatted_price).

Model Events & Observers

Eloquent fires events at each stage of a model's life, letting you run code automatically — generate a slug before saving, notify a service after creation, clean up related rows before deletion.

flowchart LR R[retrieved] --> C1[creating] --> C2[created] R --> U1[updating] --> U2[updated] C1 -.-> S[saving/saved] U1 -.-> S R --> D1[deleting] --> D2[deleted]

Inline hooks with booted()

For a handful of small hooks, register closures in the model's booted() method:

use Illuminate\Support\Str;

protected static function booted(): void
{
    static::creating(function (Product $product) {
        $product->slug ??= Str::slug($product->name);
    });

    static::updating(function (Product $product) {
        if ($product->isDirty('price')) {
            PriceHistory::create([
                'product_id' => $product->id,
                'old_price'  => $product->getOriginal('price'),
                'new_price'  => $product->price,
            ]);
        }
    });
}

Observers for heavier logic

When there are many hooks, extract them into a dedicated observer class. Generate it, then attach it with the Laravel 11 #[ObservedBy] attribute — no EventServiceProvider needed (that provider was removed in Laravel 11):

php artisan make:observer ProductObserver --model=Product
<?php

namespace App\Observers;

use App\Models\Product;
use Illuminate\Support\Str;

class ProductObserver
{
    public function creating(Product $product): void
    {
        $product->slug ??= Str::slug($product->name);
    }

    public function deleting(Product $product): void
    {
        $product->images()->delete();
    }
}
<?php

namespace App\Models;

use App\Observers\ProductObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;

#[ObservedBy(ProductObserver::class)]
class Product extends Model
{
    // ...
}

⚠️ Events don't fire on bulk operations

Model events run per-instance. Calling Product::where(...)->update([...]) or ->delete() bypasses them entirely because no models are hydrated. If you rely on a hook, load the models and loop, or move the logic into a database trigger.

Worked Example: A Real Product Model

Here's how the pieces fit together in a model you might actually ship. It uses soft deletes, casts, mass-assignment safety, an accessor, a query scope, an observer, and relationships:

<?php

namespace App\Models;

use App\Casts\Money;
use App\Observers\ProductObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

#[ObservedBy(ProductObserver::class)]
class Product extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name', 'slug', 'description', 'price', 'sale_price',
        'sku', 'stock_quantity', 'category_id', 'is_featured', 'metadata',
    ];

    // Columns hidden from array/JSON output
    protected $hidden = ['cost_price'];

    // Defaults for a fresh model
    protected $attributes = [
        'is_featured'    => false,
        'stock_quantity' => 0,
    ];

    protected function casts(): array
    {
        return [
            'price'        => Money::class,
            'sale_price'   => Money::class,
            'is_featured'  => 'boolean',
            'metadata'     => 'array',
            'published_at' => 'datetime',
        ];
    }

    // Accessor: current price is the sale price when on sale
    protected function currentPrice(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->isOnSale() ? $this->sale_price : $this->price,
        );
    }

    public function isOnSale(): bool
    {
        return $this->sale_price !== null && $this->sale_price < $this->price;
    }

    public function isInStock(): bool
    {
        return $this->stock_quantity > 0;
    }

    // Local query scope: Product::active()->get()
    public function scopeActive(Builder $query): Builder
    {
        return $query->whereNull('deleted_at')->where('is_featured', true);
    }

    // Relationships
    public function category()
    {
        return $this->belongsTo(Category::class);
    }

    public function images()
    {
        return $this->hasMany(ProductImage::class);
    }
}

That one class demonstrates nearly everything in this lesson: fillable safety, custom + built-in casts, a hidden column, sensible defaults, an accessor, helper methods, a reusable scope, and relationship definitions. The model becomes the single home for everything the word "product" means in your app.

Using it feels effortless:

$laptop = Product::create(['name' => 'ProBook', 'price' => 999.00]);
echo $laptop->slug;          // "probook" (set by the observer)
echo $laptop->current_price; // "999.00" (accessor + Money cast)
$laptop->update(['sale_price' => 799.00]);
$laptop->isOnSale();         // true

Hands-on Exercise

🏋️ Build a Category Model

Objective: Apply conventions, casting, an accessor, and an observer to a fresh model.

Instructions:

  1. Generate the model and migration: php artisan make:model Category -m.
  2. Make name, slug, description, and is_visible fillable.
  3. Cast is_visible to boolean using the casts() method.
  4. Add an accessor displayName that returns the name in title case.
  5. Generate a CategoryObserver that auto-fills slug from name on creating, and attach it with #[ObservedBy].
💡 Hint

The accessor method is named displayName() but you read it as $category->display_name. Inside the observer, guard the slug so an explicitly-provided one isn't overwritten: $category->slug ??= Str::slug($category->name);.

✅ Sample solution
// app/Models/Category.php
#[ObservedBy(CategoryObserver::class)]
class Category extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'slug', 'description', 'is_visible'];

    protected function casts(): array
    {
        return ['is_visible' => 'boolean'];
    }

    protected function displayName(): Attribute
    {
        return Attribute::make(
            get: fn () => Str::title($this->name),
        );
    }
}

// app/Observers/CategoryObserver.php
class CategoryObserver
{
    public function creating(Category $category): void
    {
        $category->slug ??= Str::slug($category->name);
    }
}

🎯 Quick Quiz

Question 1: Which mass-assignment strategy is generally safer, and why?

Question 2: In Laravel 11, where is the idiomatic place to declare attribute casts?

Question 3: Why might a deleting model event fail to fire?

Summary & Quiz

🎉 Key Takeaways

  • One model per table; an instance is one row, carrying both data and behavior.
  • Eloquent infers table, key, and timestamps by convention — override with $table, $primaryKey, $timestamps.
  • Guard against over-posting with $fillable (preferred) or $guarded.
  • Convert types with the Laravel 11 casts() method; compute presentation values with accessors/mutators via Attribute.
  • Run lifecycle logic through events or observers — but remember bulk queries skip them.

📚 Further Reading

🚀 What's Next?

Your models are defined — now let's connect them. In Model Relationships you'll wire products to categories, orders to users, and posts to tags, then traverse those links in object-oriented style.

🎉 Well done!

You can define a real, safe, expressive Eloquent model. Time to make them talk to each other.