🗂️ 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
$fillablevs$guardedmass-assignment rules - Transform data with the Laravel 11
casts()method, accessors, and mutators via theAttributeclass - Hook into the model lifecycle with events and dedicated observers
- Combine every feature into a realistic e-commerce
Productmodel
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.
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 class | Assumed table |
|---|---|
User | users |
Product | products |
OrderItem | order_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 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.
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:
- Generate the model and migration:
php artisan make:model Category -m. - Make
name,slug,description, andis_visiblefillable. - Cast
is_visibletobooleanusing thecasts()method. - Add an accessor
displayNamethat returns the name in title case. - Generate a
CategoryObserverthat auto-fillsslugfromnameoncreating, 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 viaAttribute. - Run lifecycle logic through events or observers — but remember bulk queries skip them.
📚 Further Reading
- Laravel 11 Docs — Eloquent: Getting Started
- Laravel 11 Docs — Accessors, Mutators & Casting
- Laravel 11 Docs — Observers
🚀 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.