Кеширование в Laravel – практика
Glossary overview

Кеширование в Laravel – практика

Установка

Поставил пакет.

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();
        }
    }
}