Picture a mobile app that feels “sticky”: every screen takes a beat too long to load. The culprit wasn’t the phone, the network or even PHP — it was a single /api/articles endpoint firing 62 database queries on every request.
The good news: Laravel ships with everything you need to fix this. Below are the three changes that consistently give me the biggest wins — eager loading, response caching and rate limiting — with the results measured at the end.
Why your API is slow (it’s usually the database)
The most common performance bug in Laravel apps is the N+1 query problem. You load a list of models, then touch a relationship inside a loop, and Eloquent quietly runs one extra query per row. It looks perfectly innocent:
Route::get('/articles', function () {
$articles = Article::latest()->take(20)->get();
return $articles->map(fn ($article) => [
'title' => $article->title,
'author' => $article->author->name, // 1 query per article!
'category' => $article->category->name, // ...and another one
]);
});
Twenty articles means 1 + 20 + 20 = 41 queries. Add tags and comment counts and you’re past sixty before you know it.
If a page gets slower as you add more rows, suspect N+1 queries before you suspect your server.
Spotting N+1 queries early
Laravel can throw an exception the moment a relationship is lazy loaded. Turn it on for local development so these bugs never reach production:
<?php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Fail loudly on lazy loading everywhere except production
Model::preventLazyLoading(! $this->app->isProduction());
}
}
Eager loading done right
The fix is to tell Eloquent up front which relationships you need with with(). While we’re here, we’ll select only the columns we use and let the database count comments for us with withCount():
public function index(Request $request)
{
$articles = Article::query()
->with(['author:id,name,avatar', 'category:id,name,slug'])
->withCount('comments')
->published()
->latest('published_at')
->paginate(20);
return ArticleResource::collection($articles);
}
That’s three queries no matter how many articles you return. A few habits make eager loading stick:
- Always paginate list endpoints — never return an unbounded collection.
- Select specific columns on relations (
author:id,name) — but always include the key. - Use
withCount()andwithSum()instead of loading whole relations just to count them. - Return API Resources so your JSON shape doesn’t silently trigger extra queries.
Caching responses with Redis
Once queries are efficient, the next win is not running them at all. Install the Redis client and point Laravel’s cache at it:
composer require predis/predis
php artisan config:clear
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Now wrap expensive lookups in Cache::remember(). The closure only runs on a cache miss; every other request is served straight from memory:
use Illuminate\Support\Facades\Cache;
public function show(string $slug)
{
$article = Cache::remember("article:{$slug}", now()->addMinutes(30), function () use ($slug) {
return Article::with(['author', 'category', 'tags'])
->where('slug', $slug)
->firstOrFail();
});
return new ArticleResource($article);
}
Invalidating the cache on update
Caching is easy; uncaching is where bugs hide. An Eloquent observer clears the right keys whenever an article is saved, so editors never see stale content:
class ArticleObserver
{
public function saved(Article $article): void
{
Cache::forget("article:{$article->slug}");
Cache::tags('articles')->flush();
}
}
file or database driver, forget individual keys instead.Rate limiting your endpoints
A fast API attracts traffic — including scrapers and runaway scripts. Laravel’s rate limiter lets you give signed-in users a generous allowance while keeping anonymous traffic in check:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(30)->by($request->ip());
});
When a client exceeds the limit, Laravel responds with HTTP 429 and a helpful body:
{
"message": "Too Many Attempts.",
"retry_after": 42
}
Handling 429s on the front end
If your front end uses jQuery (as this site does), handle the limit gracefully instead of showing a broken page. Note the use of .text() — never inject API data as raw HTML:
$.getJSON('/api/articles', { page: 2 })
.done(function (res) {
const $list = $('#article-list');
res.data.forEach(article => {
$('<li>').text(article.title).appendTo($list);
});
})
.fail(function (xhr) {
if (xhr.status === 429) {
console.warn(`Rate limited — retry in ${xhr.responseJSON.retry_after}s`);
}
});
Don’t forget the database index
Eager loading reduces the number of queries; indexes make each one fast. The listing query filters by category and status and sorts by publish date, so a composite index covers it perfectly:
-- Speeds up "latest published articles in a category"
CREATE INDEX idx_articles_category_published
ON articles (category_id, status, published_at DESC);
EXPLAIN SELECT id, title FROM articles
WHERE category_id = 3 AND status = 'published'
ORDER BY published_at DESC
LIMIT 20;
Measuring the results
Here’s what the three changes did for the client’s API, measured over 1,000 requests on the same server:
| Endpoint | Before | After | Queries |
|---|---|---|---|
GET /articles | 840 ms | 95 ms | 62 → 3 |
GET /articles/{slug} | 310 ms | 12 ms | 9 → 0 (cached) |
GET /categories/{slug} | 520 ms | 70 ms | 41 → 2 |
The fastest query is the one you never run.
Wrapping up
None of these techniques are exotic, and that’s the point. Most performance problems come from a handful of predictable mistakes. Work through them in order:
- Turn on
preventLazyLoading()and fix every N+1 it finds. - Eager load, select only needed columns and always paginate.
- Cache read-heavy endpoints in Redis, with observers to invalidate.
- Add composite indexes that match your
WHEREandORDER BY. - Protect everything with sensible rate limits.
Do that, and your API will feel instant — and your server bill will thank you. In the next article we’ll push further with queues and HTTP caching headers.