Skip to main content

๐Ÿ—„๏ธ Eloquent ORM and Database Migrations

Eloquent lets you talk to your database in plain PHP objects instead of raw SQL, and migrations turn your schema into version-controlled code your whole team can share. Together they form the data layer of nearly every serious Laravel application.

๐ŸŽฏ Learning Objectives

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

  • Create Eloquent models and perform full CRUD operations without writing SQL
  • Define and query relationships (one-to-one, one-to-many, many-to-many, polymorphic) and avoid the N+1 problem with eager loading
  • Shape data with accessors, mutators, and attribute casts
  • Write reversible migrations and populate data with seeders and factories
  • Assemble a complete blog data layer from schema to query

Estimated Time: 45โ€“60 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a versioned product-catalog schema with models, relationships, and factory-generated test data.

In This Lesson

What Is an ORM?

Eloquent is Laravel's Object-Relational Mapper (ORM). An ORM is a translation layer that maps rows in a relational database onto objects in your programming language. Instead of writing SELECT * FROM products WHERE id = 1 and manually stitching the result into a data structure, you write Product::find(1) and get back a Product object with typed attributes and methods.

Eloquent implements the Active Record pattern: each model class corresponds to one database table, and each instance of that class corresponds to one row. The object knows how to save, update, and delete itself.

๐Ÿ’ก A useful analogy: Think of Eloquent as a bilingual translator sitting between two rooms. In one room your PHP code speaks in objects and methods; in the other the database speaks in tables and SQL. You never have to learn the other language fluently โ€” the translator handles every exchange for you.
Eloquent maps PHP objects to database rows A PHP Product object on the left maps through the Eloquent ORM in the middle to a row in the products table on the right. PHP Object $product->name $product->price $product->save() Eloquent generates SQL products row id ยท name price ยท created_at
Figure 1 โ€” Eloquent maps a PHP object to a database row and back, generating the SQL on your behalf.

๐Ÿ“– Key Terms

Model: a PHP class (extending Model) that represents one table.

Migration: a versioned PHP file that describes how to build or change a table.

Mass assignment: creating or updating a model from an array of attributes in one call.

Models & CRUD

You generate a model with Artisan. The -m flag creates a matching migration at the same time โ€” a common shortcut.

# Just the model
php artisan make:model Product

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

# Model + migration, factory, seeder, controller, form requests, policy
php artisan make:model Product --all

A model needs almost no configuration. Laravel infers the table name (products), primary key (id), and timestamp columns automatically. You mainly declare what may be mass-assigned and how attributes should be cast.

<?php

namespace App\Models;

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

class Product extends Model
{
    use HasFactory;

    // Attributes that may be set via create()/update() (mass assignment).
    protected $fillable = ['name', 'description', 'price', 'category_id', 'active'];

    // Never expose these in arrays or JSON responses.
    protected $hidden = ['wholesale_price'];

    // Laravel 11+ casts live in a method; earlier versions used a $casts array.
    protected function casts(): array
    {
        return [
            'price'       => 'decimal:2',
            'active'      => 'boolean',
            'options'     => 'array',
            'released_at' => 'datetime',
        ];
    }
}

โš ๏ธ Mass-assignment protection

Only attributes listed in $fillable can be set through create() or update(). This stops an attacker from smuggling an unexpected field (like is_admin) through a form. If you leave a field out of $fillable, Eloquent silently ignores it during mass assignment.

The four CRUD operations

// CREATE
$product = Product::create([
    'name'  => 'Laptop',
    'price' => 1299.99,
    'category_id' => 2,
]);

// READ
$all       = Product::all();
$one       = Product::find(1);
$orFail    = Product::findOrFail(1);      // throws 404 if missing
$expensive = Product::where('price', '>', 1000)->get();

// UPDATE
$product->update(['price' => 899.99]);
Product::where('category_id', 5)->update(['active' => true]);

// DELETE
$product->delete();
Product::destroy([1, 2, 3]);

Because a model instance is a row, working with data feels like working with any object: you construct it, read its properties, change them, and delete it โ€” no SQL in sight.

Querying with Eloquent

Eloquent exposes a fluent query builder. You chain constraints, then call a terminal method like get(), first(), or paginate() to run the query.

$products = Product::where('price', '>', 100)
    ->where('category_id', 2)
    ->orderBy('name')
    ->limit(10)
    ->get();

// Aggregates
$count = Product::where('active', true)->count();
$avg   = Product::avg('price');

// Pagination (returns a paginator with links)
$page  = Product::latest()->paginate(15);

Local scopes: reusable query fragments

When the same constraint appears again and again, wrap it in a scope so the intent reads clearly at the call site.

class Product extends Model
{
    // Local scope โ€” call it as Product::popular()
    public function scopePopular($query)
    {
        return $query->where('views', '>', 1000);
    }

    public function scopePriceRange($query, $min, $max)
    {
        return $query->whereBetween('price', [$min, $max]);
    }
}

$popular  = Product::popular()->get();
$midRange = Product::priceRange(100, 500)->get();
๐Ÿ’ก Analogy: A query is like a request slip at a library. Rather than "bring me every book," you write "science-fiction titles published after 2010, sorted by author." A scope is a pre-printed slip for a request you make often.

Relationships & Eager Loading

Relationships are Eloquent's headline feature. You declare how models connect once, as methods, and then traverse those connections like properties.

classDiagram User "1" --> "*" Post : hasMany Post "*" --> "1" User : belongsTo Post "*" --> "*" Tag : belongsToMany Post "1" --> "*" Comment : hasMany class User { +posts() +comments() } class Post { +user() +tags() +comments() }
// One-to-many: a user has many posts
class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

// Inverse: a post belongs to one user
class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    // Many-to-many through a pivot table
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}

Traversing is natural โ€” call the method for a query, or access it as a property for the loaded result:

$user  = User::find(1);
$posts = $user->posts;              // Collection of Post models
$first = $user->posts()->latest()->first();  // add more constraints

// Attach / detach / sync on many-to-many
$post->tags()->attach([1, 2]);
$post->tags()->sync([2, 3]);        // ends up with exactly tags 2 and 3

โš ๏ธ The N+1 query problem

Looping over 50 posts and reading $post->user->name inside the loop runs 1 query for the posts plus 50 more โ€” one per post. That is the N+1 problem, and it silently wrecks performance. The fix is eager loading with with(), which fetches the related records in a single extra query:

// Bad: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name;   // a new query every iteration
}

// Good: 2 queries total, no matter how many posts
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    echo $post->user->name;   // already loaded
}

// Eager-load several, count, and constrain
$posts = Post::with(['user:id,name', 'tags'])
    ->withCount('comments')
    ->get();
๐Ÿ’ก Analogy: N+1 is like driving to the store for each ingredient in a recipe. Eager loading is writing one shopping list and making a single trip.

Accessors, Mutators & Casts

These three tools transform attribute values as they move between the database and your code, so the logic lives in one place.

  • Accessor โ€” transforms a value when you read it.
  • Mutator โ€” transforms a value when you write it.
  • Cast โ€” automatically converts a column's type (e.g. a JSON string โ†” a PHP array).
use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    // Modern (Laravel 9+) accessor + mutator in one method.
    protected function name(): Attribute
    {
        return Attribute::make(
            get: fn ($value) => ucwords($value),      // read: "jane doe" -> "Jane Doe"
            set: fn ($value) => strtolower($value),    // write: store lowercase
        );
    }

    // A computed, read-only attribute: $user->full_name
    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->first_name} {$this->last_name}",
        );
    }
}

๐Ÿ’ก Passwords hash themselves

In modern Laravel the User model casts password with 'hashed'. Assigning a plain-text password stores a secure hash automatically โ€” you never call bcrypt() by hand. You will see this again in the authentication lesson.

Migrations: Schema as Code

A migration is version control for your database. Each migration file has an up() method that applies a change and a down() method that reverses it. Because the schema lives in code, every teammate and every environment can reach an identical structure by running the same commands.

flowchart LR A[Write migration] --> B[php artisan migrate] B --> C[Schema updated] C --> D{Mistake?} D -->|yes| E[php artisan migrate:rollback] E --> A D -->|no| F[Commit & share]
php artisan make:migration create_products_table
php artisan migrate            # apply pending migrations
php artisan migrate:rollback   # undo the last batch
php artisan migrate:fresh --seed   # drop all, re-migrate, then seed
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description')->nullable();
            $table->decimal('price', 8, 2);
            $table->foreignId('category_id')->constrained();
            $table->boolean('active')->default(true);
            $table->timestamps();          // created_at + updated_at
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

The Blueprint object offers a fluent, database-agnostic vocabulary: column types (string, text, decimal, boolean, json, timestamp) and modifiers you chain on (->nullable(), ->unique(), ->default(), ->index()). The foreignId(...)->constrained() pair creates a foreign-key column and its constraint in one line.

๐Ÿ’ก Analogy: Migrations are a time machine for your schema. migrate moves the database forward; rollback moves it back. Because the trips are recorded, you can reproduce any point in history on any machine.

Seeders & Factories

Seeders fill your database with data; factories define how to fabricate a single realistic record. Together they give you a repeatable, populated database for development and testing.

// database/factories/PostFactory.php
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title'        => fake()->sentence(),
            'content'      => fake()->paragraphs(3, true),
            'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
        ];
    }

    // A named state for reuse
    public function draft(): static
    {
        return $this->state(['published_at' => null]);
    }
}
// database/seeders/DatabaseSeeder.php
public function run(): void
{
    // Create 10 users, each with 3 posts
    User::factory(10)
        ->has(Post::factory()->count(3))
        ->create();
}
php artisan db:seed                       # run DatabaseSeeder
php artisan db:seed --class=UserSeeder    # run one seeder
php artisan migrate:fresh --seed          # rebuild + seed in one shot

Result

Seeding: Database\Seeders\DatabaseSeeder
Seeded:  Database\Seeders\DatabaseSeeder (14.2ms)
Database seeding completed successfully.

Worked Example: A Blog Data Layer

Let's assemble everything into one coherent data layer: users write posts, posts belong to a category and carry many tags, and readers leave comments.

erDiagram USERS ||--o{ POSTS : writes POSTS }o--|| CATEGORIES : belongs-to POSTS ||--o{ COMMENTS : has POSTS }o--o{ TAGS : tagged-with USERS ||--o{ COMMENTS : writes

1 ยท Migrations

Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('content');
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('category_id')->constrained();
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});

// Pivot table for the many-to-many posts <-> tags
Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->primary(['post_id', 'tag_id']);
});

2 ยท The Post model

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

class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'slug', 'content', 'category_id', 'published_at'];

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

    // Relationships
    public function user()      { return $this->belongsTo(User::class); }
    public function category()  { return $this->belongsTo(Category::class); }
    public function tags()      { return $this->belongsToMany(Tag::class); }
    public function comments()  { return $this->hasMany(Comment::class); }

    // Scope: only posts that are live
    public function scopePublished($query)
    {
        return $query->whereNotNull('published_at')
                     ->where('published_at', '<=', now());
    }

    // Auto-generate the slug whenever the title is set
    protected function title(): Attribute
    {
        return Attribute::make(
            set: fn ($value) => [
                'title' => $value,
                'slug'  => Str::slug($value),
            ],
        );
    }

    // Read-only excerpt
    protected function excerpt(): Attribute
    {
        return Attribute::make(
            get: fn () => Str::limit(strip_tags($this->content), 150),
        );
    }
}

3 ยท Using the data layer

// Create a post through its author, then tag it
$post = $user->posts()->create([
    'title'        => 'Getting Started with Eloquent',
    'content'      => 'Eloquent makes the data layer a joy...',
    'category_id'  => $category->id,
    'published_at' => now(),
]);

$post->tags()->sync([
    Tag::firstOrCreate(['name' => 'Laravel', 'slug' => 'laravel'])->id,
    Tag::firstOrCreate(['name' => 'Eloquent', 'slug' => 'eloquent'])->id,
]);

// Fetch the blog index: published posts, eager-loaded, paginated
$posts = Post::with(['user:id,name', 'category', 'tags'])
    ->withCount('comments')
    ->published()
    ->latest('published_at')
    ->paginate(10);

โœ… What this demonstrates

Migrations create a normalized schema with foreign keys; models encapsulate relationships, scopes, and derived data; and a single expressive query pulls a fully-loaded, paginated feed โ€” with no raw SQL and no N+1 queries.

Hands-on Exercise

๐Ÿ‹๏ธ Build a Product-Catalog Data Layer

Objective: Practice migrations, relationships, and factories on a fresh schema.

Instructions:

  1. Create migrations and models for categories and products, where a category has many products and a product belongs to a category.
  2. Give products a price (decimal:2 cast), an active boolean (default true), and a nullable released_at datetime.
  3. Add a local scope scopeAvailable() that returns only active products.
  4. Write a ProductFactory and seed 5 categories, each with 10 products.
  5. Write one query that returns available products with their category eager-loaded, priced under 500, newest first.
๐Ÿ’ก Hint

Generate everything at once with php artisan make:model Product -mf (model + migration + factory). Use $table->foreignId('category_id')->constrained() for the relationship column, and remember to add category_id, price, etc. to the model's $fillable so the factory can mass-assign them.

โœ… Example solution (the final query)
// Product model
public function scopeAvailable($query)
{
    return $query->where('active', true);
}

// Query
$products = Product::with('category')
    ->available()
    ->where('price', '<', 500)
    ->latest()
    ->get();

Seeder: Category::factory(5)->has(Product::factory()->count(10))->create();

Best Practices

โœ… Do

  • Eager-load relationships you will touch in a loop (with()) to kill N+1 queries.
  • Keep each migration focused on one change and always implement down().
  • Use foreignId()->constrained() so the database enforces referential integrity.
  • Push repeated constraints into scopes and derived data into accessors.
  • List every mass-assignable column in $fillable.

โš ๏ธ Don't

  • Don't edit a migration that has already run on other machines โ€” write a new migration instead.
  • Don't reach for Model::all() on large tables; paginate or chunk.
  • Don't put business logic in controllers that belongs on the model (scopes, accessors, relationships).
  • Don't leave $fillable empty and rely on unguarded models โ€” that reopens mass-assignment risk.

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • Eloquent maps rows to objects using the Active Record pattern โ€” one model per table, one instance per row.
  • Relationships are declared as methods and traversed as properties; eager loading with with() prevents the N+1 problem.
  • Accessors, mutators, and casts keep data-shaping logic on the model.
  • Migrations version your schema; seeders and factories populate it repeatably.

๐ŸŽฏ Quick Quiz

Question 1: You loop over 100 posts and read $post->user->name each time. What is the best fix for the resulting flood of queries?

Question 2: What is the purpose of a migration's down() method?

Question 3: Which model feature would you use to store a password as a hash automatically whenever it is assigned?

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You can now model and persist data. Next we'll capture that data safely from users โ€” building forms, validating input, and giving clear feedback in Form Handling and Validation.

๐ŸŽ‰ Well done!

You've built a complete, version-controlled data layer with Eloquent and migrations.