Eloquent is the ORM that ships with Laravel. Each database table gets a model class, and each row becomes an instance of that class. You read and write data through the model instead of writing SQL by hand, and when you need SQL, the query builder is still underneath.
This post covers the parts I use on most projects: models and conventions, basic queries, relationships, the N+1 problem, scopes, casts and accessors, and working with large tables.
Models and Conventions
Generate a model with a migration:
php artisan make:model Post -m
Eloquent assumes a few things so you don't have to configure them:
- The
Postmodel maps to thepoststable. - The primary key is an auto-incrementing
id. - The table has
created_atandupdated_atcolumns, which Eloquent fills in.
If your table doesn't follow these, set the property on the model:
class Post extends Model
{
protected $table = 'blog_posts';
protected $primaryKey = 'post_id';
public $timestamps = false;
}
Reading and Writing
Most queries chain methods and end with get(), first() or find():
$post = Post::find(1);
$published = Post::where('status', 'published')
->orderByDesc('published_at')
->take(10)
->get();
get() returns a collection, so map, filter and pluck work on the result.
Creating and updating:
$post = Post::create([
'title' => 'Hello',
'body' => '...',
]);
$post->update(['title' => 'Hello again']);
$post->delete();
create() and update() take an array, which is why Laravel guards against mass assignment. List the columns a request is allowed to fill:
class Post extends Model
{
protected $fillable = ['title', 'body', 'status'];
}
Without this, a user could send is_admin=1 in a form and User::create($request->all()) would save it. Passing $request->validated() instead of $request->all() closes the same gap from the other side.
Two helpers save a lookup-then-insert:
// Find by email, or create with these extra values
$user = User::firstOrCreate(
['email' => $email],
['name' => $name],
);
// Find by email and update, or create
$user = User::updateOrCreate(
['email' => $email],
['name' => $name],
);
Relationships
Relationships are methods on the model that return a relation object.
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
Access the relationship as a property to get the results, or call it as a method to keep building the query:
$user->posts; // all posts, as a collection
$user->posts()->where('status', 'draft')->get(); // filtered in SQL
Creating through a relationship sets the foreign key for you:
$user->posts()->create(['title' => 'New post']);
$post->tags()->sync([1, 2, 3]); // pivot table now holds exactly these tags
The N+1 Problem
This is the mistake I see most often in Eloquent code:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
One query loads the posts, then one more query runs for each post to load its author. With 100 posts, that is 101 queries.
Eager loading fixes it with with():
$posts = Post::with('author')->get();
Now it is two queries: one for posts, one for all their authors. You can load several relationships, nested ones, and constrain them:
$posts = Post::with([
'author',
'tags',
'comments' => fn ($query) => $query->latest()->limit(5),
])->get();
If you only need a count, don't load the rows:
$posts = Post::withCount('comments')->get();
$posts->first()->comments_count;
To catch N+1 while developing, make lazy loading throw an exception outside production. In AppServiceProvider:
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Any relationship accessed without eager loading now fails loudly on your machine instead of quietly running extra queries in production.
Query Scopes
When the same where shows up in many places, move it into a scope on the model:
class Post extends Model
{
public function scopePublished(Builder $query): void
{
$query->where('status', 'published')
->where('published_at', '<=', now());
}
public function scopeByAuthor(Builder $query, User $user): void
{
$query->where('user_id', $user->id);
}
}
Call it without the scope prefix:
Post::published()->byAuthor($user)->get();
If the definition of "published" changes, you change it in one place.
Casts and Accessors
Casts convert column values when you read and write them:
class Post extends Model
{
protected function casts(): array
{
return [
'published_at' => 'datetime',
'is_featured' => 'boolean',
'meta' => 'array',
'status' => PostStatus::class, // a PHP enum
];
}
}
$post->published_at is now a Carbon instance, $post->meta is a PHP array stored as JSON, and $post->status is an enum case instead of a string.
Accessors and mutators shape a value on the way out or in:
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
);
}
}
Large Tables
Post::all() loads every row into memory at once. For a background job going through a big table, process it in pieces:
Post::where('status', 'draft')->chunkById(500, function ($posts) {
foreach ($posts as $post) {
// ...
}
});
chunkById pages by primary key, so it stays correct even when the callback updates the rows it is filtering on. Plain chunk uses offsets and can skip rows in that case.
lazy() does the same chunking but hands you one model at a time:
foreach (Post::lazy() as $post) {
// ...
}
For bulk changes that don't need model events, skip loading models entirely:
Post::where('status', 'draft')
->where('created_at', '<', now()->subYear())
->delete();
This runs a single DELETE statement. The trade-off is that model events and observers do not fire for these rows.
Seeing the SQL
When a query is slow or returns the wrong rows, look at what Eloquent sends:
Post::published()->toSql(); // SQL with ? placeholders
Post::published()->toRawSql(); // SQL with values filled in
DB::enableQueryLog();
// ... run code ...
dd(DB::getQueryLog());
Laravel Debugbar or Telescope show the same information per request, which is easier when you are looking for N+1 queries across a whole page.
Summary
- Follow the naming conventions and most models need no configuration.
- Set
$fillableand pass validated input tocreate()andupdate(). - Eager load relationships with
with(), and turn onpreventLazyLoadingin development. - Move repeated conditions into scopes.
- Use casts for dates, booleans, JSON and enums.
- Use
chunkByIdorlazy()for large tables, and query-level updates or deletes when you don't need model events.