Table of Contents
Установка
Поставил пакет.
composer require predis/predis
В env.:
CACHE_STORE=redis
В config/database.php:
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
...
// Был до этого "phpredis"
До:
<?php
namespace App\Http\Controllers;
use App\Models\Attribute;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class CatalogController extends Controller
{
public function index()
{
$categories = Category::whereNull('parent_id')
->where('is_active', true)
->with('children')
->orderBy('sort_order')
->first()
->children;
return view('catalog.index', compact('categories'));
}
public function category(string $slug, Request $request)
{
$category = Category::where('slug', $slug)
->where('is_active', true)
->firstOrFail();
$children = $category->children()->where('is_active', true)->get();
$categoryIds = $category->descendants()->where('is_active', true)->pluck('id')->prepend($category->id);
$baseQuery = Product::whereIn('category_id', $categoryIds)->where('is_active', true);
// Get the lowest and highest price from the "price" column and return them as min_price and max_price
$priceRange = (clone $baseQuery)->selectRaw('MIN(price) as min_price, MAX(price) as max_price')->first();
$brands = Brand::where('is_active', true)
->whereHas('products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->orderBy('name')
->get();
$filterAttributes = Attribute::where('is_filterable', true)
->whereHas('values.products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->with(['values' => fn($q) => $q->whereHas('products', fn($q2) => $q2->whereIn('category_id', $categoryIds)->where('is_active', true))->orderBy('sort_order')])
->get();
$query = (clone $baseQuery)->with('images');
if ($request->filled('price_min')) {
$query->where('price', '>=', (float) $request->price_min);
}
if ($request->filled('price_max')) {
$query->where('price', '<=', (float) $request->price_max);
}
if ($request->filled('brands')) {
$query->whereIn('brand_id', (array) $request->brands);
}
if ($request->filled('attrs')) {
foreach ((array) $request->attrs as $attributeId => $valueIds) {
$query->whereHas('attributeValues', fn($q) => $q->where('attribute_id', $attributeId)->whereIn('id', (array) $valueIds));
}
}
$products = $query->paginate(12)->withQueryString();
$breadcrumbs = $category->ancestorsAndSelf()->get()->reverse();
return view('catalog.category', compact(
'category',
'children',
'products',
'breadcrumbs',
'brands',
'filterAttributes',
'priceRange'
));
}
public function product(string $slug)
{
$product = Product::where('slug', $slug)
->where('is_active', true)
->with(['category', 'brand', 'images', 'attributeValues.attribute'])
->firstOrFail();
$breadcrumbs = $product->category->ancestorsAndSelf()->get()->reverse();
return view('catalog.product', compact('product', 'breadcrumbs'));
}
}
Кешируем получение продуктов:
public function category(string $slug, Request $request)
{
$category = Category::where('slug', $slug)
->where('is_active', true)
->firstOrFail();
$children = $category->children()->where('is_active', true)->get();
$categoryIds = $category->descendants()->where('is_active', true)->pluck('id')->prepend($category->id);
$baseQuery = Product::whereIn('category_id', $categoryIds)->where('is_active', true);
// Get the lowest and highest price from the "price" column and return them as min_price and max_price
$priceRange = (clone $baseQuery)->selectRaw('MIN(price) as min_price, MAX(price) as max_price')->first();
$brands = Brand::where('is_active', true)
->whereHas('products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->orderBy('name')
->get();
$filterAttributes = Attribute::where('is_filterable', true)
->whereHas('values.products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->with(['values' => fn($q) => $q->whereHas('products', fn($q2) => $q2->whereIn('category_id', $categoryIds)->where('is_active', true))->orderBy('sort_order')])
->get();
$query = (clone $baseQuery)->with('images');
if ($request->filled('price_min')) {
$query->where('price', '>=', (float) $request->price_min);
}
if ($request->filled('price_max')) {
$query->where('price', '<=', (float) $request->price_max);
}
if ($request->filled('brands')) {
$query->whereIn('brand_id', (array) $request->brands);
}
if ($request->filled('attrs')) {
foreach ((array) $request->attrs as $attributeId => $valueIds) {
$query->whereHas('attributeValues', fn($q) => $q->where('attribute_id', $attributeId)->whereIn('id', (array) $valueIds));
}
}
// $products = $query->paginate(12)->withQueryString();
$products = Cache::remember('category:products', now()->addHour(), function () use ($query) {
return $query->paginate(12)->withQueryString();
});
$breadcrumbs = $category->ancestorsAndSelf()->get()->reverse();
return view('catalog.category', compact(
'category',
'children',
'products',
'breadcrumbs',
'brands',
'filterAttributes',
'priceRange'
));
}
Мы сделали:
$products = Cache::remember('category:products', now()->addHour(), function () use ($query) {
return $query->paginate(12)->withQueryString();
});
Проблемы:
1. Ключ одинаковый для всех запросов
'category:products' — один ключ на все категории, все фильтры, все страницы пагинации. Второй пользователь получит кеш первого.
Из-за этого даже для категории “Ноутбуки”, мы получаем “Телефоны”.

Если очистим кеш:
php artisan cache:clear
То увидим правильную категорию:

Надо чтобы ключ кеша был более детальным, а не просто “category:products”.
$products = Cache::remember("category_{$category->id}:products", now()->addHour(), function () use ($query) {
return $query->paginate(12)->withQueryString();
});
Теперь работает правильно.
Также посмотрим как меняется количество запросов.
Первый запрос:

Повторный запрос:

Следующая проблема – Кешируем только products, а тяжёлые запросы — нет
brands, filterAttributes, priceRange, descendants — они тоже бьют по базе каждый раз. Их как раз стоит кешировать, потому что они не зависят от фильтров пользователя.
// Get the lowest and highest price from the "price" column and return them as min_price and max_price
$priceRange = Cache::remember("category:{$category->id}:price_range", now()->addHour(), function () use ($categoryIds) {
return Product::whereIn('category_id', $categoryIds)
->where('is_active', true)
->selectRaw('MIN(price) as min_price, MAX(price) as max_price')
->first();
});
$brands = Cache::remember("category:{$category->id}:brands", now()->addHour(), function () use ($categoryIds) {
return Brand::where('is_active', true)
->whereHas('products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->orderBy('name')
->get();
});
$filterAttributes = Attribute::where('is_filterable', true)
->whereHas('values.products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->with(['values' => fn($q) => $q->whereHas('products', fn($q2) => $q2->whereIn('category_id', $categoryIds)->where('is_active', true))->orderBy('sort_order')])
->get();
$query = (clone $baseQuery)->with('images');
if ($request->filled('price_min')) {
$query->where('price', '>=', (float) $request->price_min);
}
if ($request->filled('price_max')) {
$query->where('price', '<=', (float) $request->price_max);
}
if ($request->filled('brands')) {
$query->whereIn('brand_id', (array) $request->brands);
}
if ($request->filled('attrs')) {
foreach ((array) $request->attrs as $attributeId => $valueIds) {
$query->whereHas('attributeValues', fn($q) => $q->where('attribute_id', $attributeId)->whereIn('id', (array) $valueIds));
}
}
// Don't cache filtered products — too many filter/sort/page combinations
$products = $query->paginate(12)->withQueryString();
Почему products лучше не кешировать?
paginate()->withQueryString() внутри кеша не работает
withQueryString() читает текущий $request, но при следующем запросе результат берётся из кеша — объект пагинации уже сериализован, и ссылки на страницы будут без query-параметров или с устаревшими.
А комбинации фильтров, сортировок и страниц дают тысячи ключей, которые быстро протухают. Redis забьётся, а hit rate будет низкий.
Было 17 запросов, стало 14.
И в debugger сразу можно глянуть как работает кеш.
Первый запрос:

Второй:

- 0% — miss rate. Ноль промахов, всё берётся из кеша.
- 0μs — время чтения из Redis (настолько быстро что округлилось до нуля).
- 34.27KB — размер закешированных данных (коллекция брендов весит 34 КБ в сериализованном виде).
Еще в кеш добавил breadcrumbs и $filterAttributes, теперь запросов 11.

То есть кешируем стабильные данные, а отфильтрованные продукты идут напрямую из БД. Правильный подход.
Инвалидация кеша
Сейчас если, например, добавим новый товар, то он не будет показываться пока не истечет кеш, у нас это 1 час.
Добавим новый товар.
Продукт появился, потому что мы не кешируем сами продукты.
$products = $query->paginate(12)->withQueryString();

Но, например, максимальная цена в фильтре не обновилась.

После очистки кеша:

Надо сделать Observer для инвалидации кеша
php artisan make:observer ProductObserver --model=Product
app/Observers/ProductObserver.php
<?php
namespace App\Observers;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductObserver
{
public function created(Product $product): void
{
$this->clearCategoryCache($product->category_id);
}
public function updated(Product $product): void
{
$this->clearCategoryCache($product->category_id);
// якщо товар перенесли в іншу категорію — чистимо і стару
if ($product->wasChanged('category_id')) {
$this->clearCategoryCache($product->getOriginal('category_id'));
}
}
public function deleted(Product $product): void
{
$this->clearCategoryCache($product->category_id);
}
private function clearCategoryCache(int $categoryId): void
{
$ids = Category::find($categoryId)
?->ancestorsAndSelf()
->pluck('id')
->push($categoryId)
->unique();
foreach ($ids ?? [$categoryId] as $id) {
foreach (['ids', 'price_range', 'brands', 'filter_attributes', 'breadcrumbs'] as $key) {
Cache::forget("category:{$id}:{$key}");
}
}
}
}
И для категорий.
<?php
namespace App\Observers;
use App\Models\Category;
use Illuminate\Support\Facades\Cache;
class CategoryObserver
{
public function updated(Category $category): void
{
$this->clearCategoryCache($category->id);
// якщо категорію перенесли під іншого батька — чистимо і батьківську
if ($category->wasChanged('parent_id')) {
$this->clearCategoryCache($category->getOriginal('parent_id'));
$this->clearCategoryCache($category->parent_id);
}
}
public function deleted(Category $category): void
{
$this->clearCategoryCache($category->id);
}
private function clearCategoryCache(int $categoryId): void
{
foreach (['ids', 'price_range', 'brands', 'filter_attributes', 'breadcrumbs'] as $key) {
Cache::forget("category:{$categoryId}:{$key}");
}
}
}
И надо подключить в провайдер:
<?php
namespace App\Providers;
class AppServiceProvider extends ServiceProvider
{
...
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Product::observe(ProductObserver::class);
Category::observe(CategoryObserver::class);
}
}
Это регистрация — ты говоришь Laravel “следи за событиями этих моделей”. Без этой строки Observer существует как класс, но Laravel не знает что его нужно вызывать. boot() в AppServiceProvider запускается при старте приложения, поэтому observers подключаются один раз и работают на все запросы.
Что происходит при каждом изменении:
- Товар создан/удалено → чистится кэш его категории
- товар обновлен → чистится кэш категории; если изменилась category_id — чистится и старая категория
- Категория обновлена/удалена → чистится ее кэш; если изменился parent_id – чистится и старый, и новый отец (ибо у них меняется descendants)
Методы observer
Laravel автоматически вызывает методы observer с такими именами, когда соответствующее событие происходит на модели.
Полный список, который Laravel распознает:
| Метод | Коли викликається |
|---|---|
creating | перед INSERT |
created | після INSERT |
updating | перед UPDATE |
updated | після UPDATE |
saving | перед INSERT або UPDATE |
saved | після INSERT або UPDATE |
deleting | перед DELETE |
deleted | після DELETE |
restoring | перед відновленням (soft delete) |
restored | після відновлення |
forceDeleting | перед примусовим видаленням |
forceDeleted | після примусового видалення |
Теги кеша (Cache Tags)
Теги — это способ сгруппировать несколько записей в кеше под одним ярлыком, чтобы потом очистить их все одной командой.
Без тегов каждая запись живёт отдельно:
Cache::put('category:4:price_range', ...);
Cache::put('category:4:brands', ...);
Cache::put('category:4:filter_attributes', ...);
// очистка — перечисляешь каждый ключ вручную
Cache::forget('category:4:price_range');
Cache::forget('category:4:brands');
Cache::forget('category:4:filter_attributes');
С тегами все записи помечаются одним тегом при сохранении:
Cache::tags('category:4')->put('price_range', ...);
Cache::tags('category:4')->put('brands', ...);
Cache::tags('category:4')->put('filter_attributes', ...);
// очистка — одна строка, удаляет всё под тегом
Cache::tags('category:4')->flush();
Под капотом Redis хранит отдельный Set с ключами для каждого тега. flush() находит все ключи в этом Set и удаляет их.
Важно: теги работают только с драйверами redis и memcached. С драйвером file или database — упадёт с ошибкой.
Вот так переделали:
public function category(string $slug, Request $request)
{
$category = Category::where('slug', $slug)
->where('is_active', true)
->firstOrFail();
$children = $category->children()->where('is_active', true)->get();
// Cache tag
$cache = Cache::tags("category:{$category->id}");
// Use Cache tag
$categoryIds = $cache->remember('ids', now()->addHour(), function () use ($category) {
return $category->descendants()->where('is_active', true)->pluck('id')->prepend($category->id);
});
$baseQuery = Product::whereIn('category_id', $categoryIds)->where('is_active', true);
$priceRange = $cache->remember('price_range', now()->addHour(), function () use ($categoryIds) {
return Product::whereIn('category_id', $categoryIds)
->where('is_active', true)
->selectRaw('MIN(price) as min_price, MAX(price) as max_price')
->first();
});
$brands = $cache->remember('brands', now()->addHour(), function () use ($categoryIds) {
return Brand::where('is_active', true)
->whereHas('products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->orderBy('name')
->get();
});
$filterAttributes = $cache->remember('filter_attributes', now()->addHour(), function () use ($categoryIds) {
return Attribute::where('is_filterable', true)
->whereHas('values.products', fn($q) => $q->whereIn('category_id', $categoryIds)->where('is_active', true))
->with(['values' => fn($q) => $q->whereHas('products', fn($q2) => $q2->whereIn('category_id', $categoryIds)->where('is_active', true))->orderBy('sort_order')])
->get();
});
$query = (clone $baseQuery)->with('images');
if ($request->filled('price_min')) {
$query->where('price', '>=', (float) $request->price_min);
}
if ($request->filled('price_max')) {
$query->where('price', '<=', (float) $request->price_max);
}
if ($request->filled('brands')) {
$query->whereIn('brand_id', (array) $request->brands);
}
if ($request->filled('attrs')) {
foreach ((array) $request->attrs as $attributeId => $valueIds) {
$query->whereHas('attributeValues', fn($q) => $q->where('attribute_id', $attributeId)->whereIn('id', (array) $valueIds));
}
}
// Don't cache filtered products — too many filter/sort/page combinations
$products = $query->paginate(12)->withQueryString();
$breadcrumbs = $cache->remember('breadcrumbs', now()->addHour(), function () use ($category) {
return $category->ancestorsAndSelf()->get()->reverse();
});
return view('catalog.category', compact(
'category',
'children',
'products',
'breadcrumbs',
'brands',
'filterAttributes',
'priceRange'
));
}
<?php
namespace App\Observers;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductObserver
{
private function clearCategoryCache(int $categoryId): void
{
$ids = Category::find($categoryId)
?->ancestorsAndSelf()
->pluck('id')
->push($categoryId)
->unique();
foreach ($ids ?? [$categoryId] as $id) {
// Use Cache tag
Cache::tags("category:{$id}")->flush();
}
}
}