Some Laravel features are famous, and some sit in the docs for years while people write the same longer code around them. These eight are small, each takes under a minute to try, and each one removes either a chunk of code or a whole class of bug. I ran every example on Laravel 13.35.0 with a real database, so the outputs below are what actually came back.
1. See the real SQL with toRawSql()
You want to know what query a chain of conditions produces. Instead of reading the query log, ask the builder:
Post::where('status', 'draft')
->where('reads', '>', 5)
->orderBy('id')
->limit(10)
->toRawSql();
// select * from `posts` where `status` = 'draft' and `reads` > 5 order by `id` asc limit 10
The values are already filled in, so you can paste the result straight into a database client and run EXPLAIN on it. The query builder also has dumpRawSql() and ddRawSql() for printing it in the middle of a chain (I confirmed these exist in the framework source; I ran toRawSql() itself). Treat the output as debugging information only and keep it out of logs on production if the values may be private.
2. Use sole() when exactly one row must exist
first() happily returns the first match, even when you expected only one. sole() insists:
Post::where('title', 'A')->sole(); // returns the model
Post::where('title', 'B')->sole(); // two rows exist
Post::where('title', 'Z')->sole(); // none exist
My results: one match returned the model, two matches threw MultipleRecordsFoundException ("2 records were found."), and zero matches threw the usual ModelNotFoundException. Good for lookups like "the one active subscription for this user" where a duplicate means your data is broken and you want to know now.
3. isDirty() before saving, wasChanged() after
These two get mixed up all the time. The test shows the difference:
$post->title = 'New title';
$post->isDirty('title'); // true (changed, not saved yet)
$post->wasChanged('title'); // false
$post->save();
$post->isDirty('title'); // false (nothing pending now)
$post->wasChanged('title'); // true (it changed during the last save)
Use isDirty() in code that runs before the save, and wasChanged() afterwards, for example in an observer's updated method when you only want to send an email if the status changed.
4. Copy a record with replicate()
Need a "duplicate this post" button? replicate() gives you an unsaved copy:
$copy = $post->replicate(); // exists = false, id = null, created_at = null
$copy->title = 'Copy of ' . $post->title;
$copy->save(); // inserted as a new row
$copy = $post->replicate(['status']); // skip the status attribute
The primary key and timestamps are not copied, and the title came across, as I verified. The array argument lists attributes to leave out. Relationships are not copied, so duplicate child rows yourself if you need them.
5. Update extra columns while incrementing
A page-view counter often also needs a "last viewed" time. One query does both:
$post->increment('reads', 1, ['last_read_at' => now()]);
The row afterwards had reads = 1 and the new last_read_at. Because it is one atomic UPDATE, two requests arriving together do not overwrite each other the way $post->reads++; $post->save(); can. One thing to remember from the timestamps article: increment() also bumps updated_at unless you wrap it in Model::withoutTimestamps().
6. Build optional filters with when()
Instead of a stack of if statements around your query:
$posts = Post::query()
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['min_reads'] ?? null, fn ($q, $min) => $q->where('reads', '>=', $min))
->get();
With a null status and a minimum of 3, the SQL was just select * from posts where reads >= 3. The empty filter added nothing. One catch: when() skips a value that is falsy, so a filter value of 0 or '0' is ignored. If zero is a valid choice, check explicitly with ->when($min !== null, ...).
7. Run something once per object with once()
If a method does expensive work and gets called several times, wrap the body in once():
public function heavy()
{
return once(function () {
// expensive work
return $this->calculate();
});
}
I called such a method three times on one object and the closure ran once. A second object ran its own closure once, so the cached result belongs to each object and does not leak between them. That also means it lasts only as long as the object does.
8. Turn on strict mode in development
This is the one I would add first to any project. Model::shouldBeStrict() switches on three safety checks together:
// app/Providers/AppServiceProvider.php, in boot()
Model::shouldBeStrict(! $this->app->isProduction());
What each check did in my test:
-
Lazy loading: looping over
Post::get()and touching$post->commentsthrewLazyLoadingViolationException("Attempted to lazy load [comments] on model [Post] but lazy loading is disabled."). That is the classic N+1 problem, caught during development. Loading a single model withfirst()and then its relation did not throw, so the check targets models loaded together. -
Silently dropped attributes:
new Post(['title' => 'x', 'reads' => 5])wherereadsis not fillable threwMassAssignmentExceptioninstead of quietly ignoring it. -
Missing attributes: selecting only
idand then reading$post->titlethrewMissingAttributeExceptioninstead of returningnull.
Running it only outside production (as above) is a common choice, because you want these mistakes to fail loudly while you build, not break a live page. After I turned strict mode off, Post::with('comments') loaded the same data without any errors, which is the usual fix for a lazy loading violation.
Originally published on DEV Talk.
Top comments (0)