Laravel Eloquent ORM
Defining models, migrations, the Eloquent query builder, and hasMany/belongsTo relationships.
Defining a model
Eloquent is Laravel's ActiveRecord-style ORM — each model class maps to a database table, and an instance of that class maps to a single row. Generate one with Artisan, optionally alongside its migration in one command:
php artisan make:model Post -m
<?php
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['title', 'body', 'published'];
}
By convention, Eloquent assumes the Post model maps to a posts table (the plural, snake_case form of the class name), with an auto-incrementing id primary key and created_at/updated_at timestamp columns — all overridable, but rarely needed to be. $fillable is an allow-list of columns that can be mass-assigned (via Post::create([...]) or $post->update([...])) — a required safeguard against a malicious request stuffing extra, unexpected fields into a form submission.
Migrations
A migration is a version-controlled, incremental description of a schema change — the PHP equivalent of Django's or Flask-Migrate's migration files:
php artisan make:migration create_posts_table
<?php
// database/migrations/xxxx_xx_xx_create_posts_table.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('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->boolean('published')->default(false);
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
php artisan migrate
up() describes the change to apply; down() describes how to reverse it (used by php artisan migrate:rollback). $table->foreignId('author_id')->constrained('users')->cascadeOnDelete() is Laravel's shorthand for adding an author_id foreign key column referencing users.id, with the database itself deleting dependent posts when the referenced user is deleted.
Query builder basics
Every Eloquent model comes with a fluent, chainable query interface:
<?php
use App\Models\Post;
// Create
$post = Post::create([
'title' => 'Hello, Laravel',
'body' => 'My first post.',
]);
// Read — all rows
$allPosts = Post::all();
// Read — filtered and ordered
$recentPublished = Post::where('published', true)
->orderBy('created_at', 'desc')
->take(5)
->get();
// Read — a single row, or a 404 if it doesn't exist
$post = Post::findOrFail(1);
// Update
$post->title = 'Hello, Laravel (Updated)';
$post->save();
// or, in one call:
$post->update(['title' => 'Hello, Laravel (Updated)']);
// Delete
$post->delete();
Post::where(...) returns a query builder instance you can keep chaining (->orderBy(), ->take(), ->where() again for an additional condition) — nothing actually hits the database until a terminal method like ->get(), ->first(), or ->count() is called.
Relationships: hasMany and belongsTo
Relationships are defined as methods on the model, and Eloquent uses naming conventions (a user_id column, the calling method's name) to infer the foreign key unless told otherwise:
<?php
// app/Models/User.php
class User extends Authenticatable
{
public function posts(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Post::class);
}
}
<?php
// app/Models/Post.php
class Post extends Model
{
protected $fillable = ['title', 'body', 'published', 'author_id'];
public function author(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}
<?php
$user = User::find(1);
$user->posts; // every Post where author_id = 1 (a Collection)
$post = Post::find(5);
$post->author; // the related User instance
$post->author->name;
Accessing $user->posts or $post->author as a property (not a method call) triggers Eloquent to run the relationship's query and cache the result on the model instance — calling it again on the same instance doesn't re-query.
Common mistakes
- Forgetting
$fillable(or the inverse,$guarded) on a model and then callingModel::create($request->all())— this opens the door to mass assignment vulnerabilities, where a request can set columns it was never meant to touch. - Looping over a relationship in a way that triggers one query per row (the N+1 query problem) — e.g.,
foreach ($posts as $post) { $post->author->name }runs one query per post. Eager-load it instead withPost::with('author')->get(), which fetches all authors in a single additional query. - Confusing
$post->save()(updates whatever is currently set on the model instance) withPost::create([...])(mass-assigns and inserts a brand-new row) — usingcreate()on an existing model doesn't update it, it inserts a second row.
Interview questions
Q: What's the difference between using Eloquent and using the plain query builder directly?
Eloquent gives you an ActiveRecord-style model — an object with attributes, relationships, events, and behavior (accessors, casts, scopes) layered on top of a table, well suited to expressing business logic. The plain query builder (DB::table('posts')->where(...)->get()) returns plain stdClass objects (or arrays) with none of that model behavior attached, and is often a better fit for a large reporting query, bulk operation, or anything where the overhead and object identity of a full Eloquent model isn't needed. Under the hood, Eloquent is actually built on top of the query builder.
Q: What is the N+1 query problem, and how does Laravel help you avoid it?
It's the pattern where fetching a list of records (1 query) and then accessing a relationship on each one inside a loop triggers one additional query per record (N queries) — for 100 posts, that's 101 total queries just to show each post's author. Laravel's fix is eager loading: Post::with('author')->get() fetches every post's related author in a single additional query up front, regardless of how many posts there are, and $post->author inside the loop then reads from that already-loaded data instead of triggering a new query.