If you’ve ever found yourself staring at a slow database query in Laravel’s debug bar, wondering how to speed it up without sacrificing code readability, you’re not alone. Eloquent queries can quickly become performance bottlenecks as your application grows. The problem is that most caching solutions require manually inserting cache keys or configuring complex cache stores.
You’ll build upon the existing functionality of Eloquent with reusable query caching, where selected database queries are cached based on defined criteria. By the end of this tutorial, you’ll be able to identify and tag frequently used queries for efficient cache management and even configure multiple cache stores to suit your application’s needs.
Enabling Query Caching in Laravel Configuration
To enable query caching in Laravel, you’ll need to configure your application’s cache settings.
First, navigate to your config/cache.php file and locate the top-level default key, which selects one of the entries in the stores array. Update it to use a driver that supports cache expiration, such as database, file, or redis. I’m using the database driver here for simplicity:
'default' => env('CACHE_STORE', 'database'),
Next, add a default lifetime for cached queries to the same config/cache.php file. This is our own option rather than one Laravel ships with, and it keeps the expiration time in one place instead of hard-coding it in every query:
'query_ttl' => env('QUERY_CACHE_TTL', 3600),
With these settings in place, you can now enable query caching for Eloquent models.
In your application’s environment file (.env), set the following variables to control the cache store and cache expiration:
CACHE_STORE=database
# default expiration time in seconds
QUERY_CACHE_TTL=3600
If you’re using the database driver, make sure the cache table exists. New Laravel applications include its migration; otherwise, generate it with php artisan make:cache-table and run php artisan migrate. This concludes the basic setup for enabling query caching in Laravel configuration.
Defining Cacheable Queries with Eloquent Traits
To make use of the query cache in your Laravel application, you’ll need to define which queries should be cached using a reusable Eloquent trait.
Create a new trait within your project by running php artisan make:trait Traits/QueryCacheable. This will generate a file named QueryCacheable.php in your app/Traits directory. Update this file with the following code:
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
trait QueryCacheable
{
public function scopeCacheFor(Builder $query, int $minutes)
{
$key = 'query:' . $this->getTable() . ':' . md5($query->toSql() . serialize($query->getBindings()));
return Cache::remember($key, now()->addMinutes($minutes), fn () => $query->get());
}
}
This trait provides a single query scope, cacheFor, which accepts an integer representing the number of minutes to cache the query. When you call it at the end of an Eloquent query in place of get(), the results will be cached based on the provided duration, using a key built from the query’s SQL and bindings.
To apply this trait to an individual Eloquent model, update its class definition to use the QueryCacheable trait:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\QueryCacheable;
class User extends Model
{
use QueryCacheable;
// ...
}
Now you can cache specific queries by calling cacheFor on your Eloquent query. For example, to cache a query for 30 minutes:
$users = User::where('name', 'John')->cacheFor(30);
By applying the QueryCacheable trait and using the cacheFor method, you can easily enable caching for specific queries within your application.
Implementing Cache Keys and Naming Conventions
When implementing automatic Eloquent query caching in Laravel, it’s essential to define a strategy for generating cache keys. A well-designed naming convention ensures that related queries are cached together and can be efficiently invalidated when data changes.
To start, we’ll create a trait that generates a unique cache key based on the query’s parameters. This approach helps avoid collisions between different queries with similar conditions.
// app/Traits/CacheableQueries.php
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
trait CacheableQueries
{
public function cacheKey(Builder $query): string
{
$params = json_encode($query->getBindings());
return 'query:' . $this->getTable() . ':' . md5($query->toSql() . ':' . $params);
}
}
In the above code, we’re using PHP’s md5() function to generate a deterministic hash for each query, so the same query always maps to the same key. This hash is based on the query’s SQL and an encoded version of the query parameters, and the key is prefixed with the model table name.
Next, we’ll update our Eloquent models to use this trait and cache key generator. By doing so, identical queries will always be cached under the same key, and every key for a model shares the same table-name prefix.
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\CacheableQueries;
class User extends Model
{
use CacheableQueries;
// ...
}
By implementing a consistent naming convention and using a unique cache key generator, we can efficiently manage our Eloquent query cache in Laravel. This sets the stage for further optimization techniques in upcoming sections.
Configuring Cache Stores for Eloquent Queries
Laravel provides several cache store options out-of-the-box, including Redis, Memcached, and file-based stores. To configure a cache store for Eloquent queries, you’ll need to define the default cache driver in your Laravel configuration.
// config/cache.php
'default' => env('CACHE_STORE', 'file'),
Here, we’re setting the default cache driver to file, but you can change this to any of the supported drivers. For a production environment, I recommend using Redis or Memcached for their performance benefits.
To use a different cache store with Eloquent queries, you’ll need to configure it in the stores array of your cache configuration file. Let’s say we’re using Redis as our default cache driver:
// config/cache.php
'stores' => [
// ...
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
],
In this example, we’re defining a redis store that uses the redis driver and the cache Redis connection. You’ll need to configure your Redis instance in the redis section of the config/database.php file.
Once you’ve configured your cache store, the Cache::remember() call behind our cacheFor scope will automatically use it for caching queries. Make sure to adjust the CACHE_STORE environment variable if you’re using a different driver. This setting is essential for configuring your cache store correctly.
By following these steps, you’ll be able to configure a cache store for Eloquent queries and take advantage of Laravel’s built-in caching features. With this setup, you can efficiently manage your application’s memory usage and improve performance.
Using Tagging and Grouping for Efficient Cache Management
To further optimize cache management, Laravel provides cache tags for grouping related entries. Tags are supported by the redis and memcached stores, but not by the file, database, or dynamodb drivers. They allow you to categorize cached queries by their functionality or purpose, making it easier to invalidate related caches when the underlying data changes.
Let’s consider a scenario where we have two model methods: getTopSellers() and getBestRatedProducts(). Both methods retrieve products from the database but use different criteria. To efficiently manage these queries’ cache, we can apply tags by wrapping each query in Cache::tags():
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
Cache::tags(['best-rated-products'])->remember('products:best-rated', 3600, function () {
return Product::where('rating', '>', 4)
->orderByDesc('rating')
->get();
});
Similarly, for getTopSellers():
Cache::tags(['top-sellers'])->remember('products:top-sellers', 3600, function () {
return Product::where('sales', '>', 1000)
->orderByDesc('sales')
->get();
});
By using tags, we can invalidate the cache for related queries when the underlying data changes. For instance, if a product’s rating increases, we can flush the best-rated-products tag to ensure only the latest ratings are cached:
Cache::tags('best-rated-products')->flush();
This approach enables efficient cache management and reduces the likelihood of stale data being retrieved from cache.
By implementing tagging and grouping, you’ll be able to manage your application’s cache more effectively, improving performance and reducing errors.
Testing and Debugging Cached Eloquent Queries
Testing cached Eloquent queries can be a bit tricky due to the caching mechanism. To ensure that our application is properly handling cache-related issues, we need to simulate scenarios where the cache might be invalid or missing.
Let’s create a simple test for our User model, which we will assume has a cacheable query for fetching all users:
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class User extends Model
{
use HasFactory;
public static function getAllUsers()
{
return Cache::remember('users:active', config('cache.query_ttl'), function () {
return self::where('active', true)->get();
});
}
}
To test this, we can create a test case in tests/Feature/UserTest.php:
// tests/Feature/UserTest.php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function testGetAllUsers()
{
User::factory()->count(3)->create(['active' => true]);
// Simulate cache miss by clearing the cache
Cache::flush();
// Test without cache: this call queries the database and stores the result
$usersWithoutCache = User::getAllUsers();
$this->assertGreaterThan(0, $usersWithoutCache->count());
$this->assertTrue(Cache::has('users:active'));
// Test with cache: this call should not run any database queries
DB::enableQueryLog();
$usersWithCache = User::getAllUsers();
$this->assertCount(0, DB::getQueryLog());
// We expect the same result as without cache since it's cached now
$this->assertEquals($usersWithoutCache, $usersWithCache);
}
}
This test checks if our model is properly fetching users from both scenarios: when there’s no cache and when there’s a cache hit.
Monitoring and Optimizing Cache Performance in Production
To monitor and optimize cache performance in production, you’ll need to use a combination of built-in Laravel tools and external monitoring software.
First, enable logging for your cache store by listening for the cache events Laravel dispatches, such as CacheHit and CacheMissed, in the boot method of your AppServiceProvider:
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Cache\Events\CacheHit;
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(function (CacheHit $event) {
Log::debug('Cache hit', ['key' => $event->key]);
});
Event::listen(function (CacheMissed $event) {
Log::debug('Cache miss', ['key' => $event->key]);
});
}
}
This will log cache-related events to the storage/logs/laravel.log file. You can then use these logs to identify performance bottlenecks and optimize your caching strategy.
Next, use Laravel Pulse, Laravel’s first-party monitoring dashboard, to track cache hit rates and miss rates. After installing it with composer require laravel/pulse, publishing its files with php artisan vendor:publish --provider="Laravel\Pulse\PulseServiceProvider", and running php artisan migrate, the dashboard at /pulse includes a cache card. If you customise the dashboard (php artisan vendor:publish --tag=pulse-dashboard), add the card to resources/views/vendor/pulse/dashboard.blade.php:
<livewire:pulse.cache cols="4" />
This will display cache hit and miss statistics for your application, both globally and for individual keys.
For more in-depth monitoring and optimization, consider using an external tool like Prometheus or New Relic. These tools can help you track cache performance metrics over time and identify areas for improvement.
To optimize cache performance, focus on the following best practices:
- Use efficient caching algorithms (e.g.,
RediswithTTLexpiration). - Implement proper cache key naming conventions.
- Regularly clear stale or unnecessary cache entries.
- Monitor cache hit rates and adjust your caching strategy accordingly.
By monitoring and optimizing cache performance in production, you can ensure that your application remains fast and scalable.
Frequently Asked Questions
What are the cache drivers that support expiration in Laravel?
All of Laravel’s persistent cache drivers support expiration, including ‘database’, ‘file’, ‘redis’, and ‘memcached’.
Why do I need a ‘cache’ table when using the ‘database’ cache driver?
The ‘database’ driver stores cached values in a database table, so that table must exist before Laravel can cache query results there.
What’s the purpose of the QUERY_CACHE_TTL variable in my environment file?
The QUERY_CACHE_TTL variable feeds the custom cache.query_ttl option and controls how long cached query results are stored, with a default expiration time of 3600 seconds (60 minutes).
Can I use query caching without configuring multiple cache stores?
Yes, you can use query caching by configuring a single cache store that supports expiration, such as the ‘database’ driver.
What’s the common error or pitfall when implementing query caching in Laravel?
A common mistake is forgetting to invalidate cached results when the underlying data changes, which leads to stale data being served from the cache.
