Skip to content
Tech

Building a Blazing-Fast REST API in Laravel: Caching, Eager Loading & Rate Limiting

One endpoint was firing 62 queries per request. Here is how eager loading, Redis caching, indexes and rate limiting took it from 840 ms to under 100 ms.

Banner for Blazing-Fast Laravel REST APIs showing Redis caching, eager loading, and sub-100ms latency metrics
Banner for Blazing-Fast Laravel REST APIs showing Redis caching, eager loading, and sub-100ms latency metrics
In this article

    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() and withSum() 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);
    }
    Rows of servers in a data centre
    A warm cache turns a database round-trip into a memory lookup.

    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();
        }
    }
    Tip: cache tags only work with the Redis and Memcached drivers. On the 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:

    EndpointBeforeAfterQueries
    GET /articles840 ms95 ms62 → 3
    GET /articles/{slug}310 ms12 ms9 → 0 (cached)
    GET /categories/{slug}520 ms70 ms41 → 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:

    1. Turn on preventLazyLoading() and fix every N+1 it finds.
    2. Eager load, select only needed columns and always paginate.
    3. Cache read-heavy endpoints in Redis, with observers to invalidate.
    4. Add composite indexes that match your WHERE and ORDER BY.
    5. 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.

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.