Tech Verse Logo
Enable dark mode
Model Caching Patterns in Laravel 12 for Production

Model Caching Patterns in Laravel 12 for Production

Tech Verse Daily

Tech Verse Daily

5 min read

The Architectural Boundary: Where Model Caching Belongs

A common mistake in high-growth Laravel applications is applying caching directly inside Eloquent model hooks or auto-caching query traits. While global query interception packages look convenient in tutorials, they break down in production environments operating on Laravel 12 and PHP 8.3. Automatic query caching traits obscure the SQL boundaries, lead to unpredicted key collisions, and serialize fully hydrated model instances into cache stores like Redis.

Serializing full Eloquent models into Redis introduces massive payload overhead. A hydrated model carries its original attributes, mutated state, loaded relations, and internal framework references. When PHP 8.3 deserializes this cached payload, it must re-instantiate the model class, bypass constructor safety, and reconstruct internal state. This process often consumes more CPU cycles and RAM than running an indexed SQL query directly against MySQL 8 or PostgreSQL 16.

To maintain predictable performance, place your caching logic in a dedicated service or repository boundary sitting above Eloquent, or cache raw scalar arrays instead of full model instances. Keep the data access layer explicit about when reads hit the primary database and when they fetch from memory.

The Pitfalls of Automatic Eloquent Caching Traits

Automatic model caching packages attempt to override query builder methods to catch call chains like User::where('email', $email)->first(). In production workloads, this creates three major architectural risks:

  • Stale Relation Graphs: If you cache a parent model along with its preloaded with('posts') relation, updating a post directly will not automatically clear the cached parent model key unless deep graph invalidation is explicitly programmed.

  • PHP 8.3 Property Type Incompatibility: Deserializing stored Eloquent instances across code deployments can trigger fatal type errors if class properties, casts, or enum schemas have mutated between releases.

  • Unbounded Cache Key Growth: Query parameters with variable ordering or microsecond timestamps create infinite cache key combinations, exhausting Redis memory with low-cardinality hits.

Cache Invalidation with Observers and Transaction Safety

The standard pattern for invalidating cached model data relies on Eloquent Observers listening to lifecycle events such as created, updated, deleted, and restored. However, using basic observers without transaction awareness introduces severe race conditions in high-concurrency systems.

If an observer fires a Cache::forget() command inside an open database transaction, the cache key is purged immediately. However, if a parallel HTTP request hits the application microsecond later—before the database transaction commits—the concurrent request reads stale data from the database, writes it back into Redis, and repopulates the cache with old state. When the original transaction finally commits, the cache remains permanently out of sync.

In Laravel 12, Eloquent Observers support the $afterCommit property natively. Setting this property instructs Laravel to delay all cache purge logic until the surrounding database transaction successfully finishes committing to disk.

<?php

namespace App\Observers;

use App\Models\User;
use Illuminate\Support\Facades\Cache;

class UserObserver
{
    public bool $afterCommit = true;

    public function updated(User $user): void
    {
        Cache::forget($this->cacheKey($user->id));
    }

    public function deleted(User $user): void
    {
        Cache::forget($this->cacheKey($user->id));
    }

    private function cacheKey(int $userId): string
    {
        return "users:v1:{$userId}";
    }
}

Handling Bulk Operations and Direct Database Mutations

Model Observers do not fire when executing direct query builder updates or deletes, such as User::where('active', false)->update(['status' => 'archived']). If your codebase relies on bulk updates, model observers will fail to invalidate cached entities. For high-volume updates, you must execute explicit cache tag flushing or dispatch dedicated job events that purge the targeted primary keys explicitly.

Cache Tags versus Key Versioning in Redis

Laravel provides tag-based caching via Cache::tags(['users', 'settings']) for cache stores like Redis and Memcached. While tags simplify purging entire domains of data with a single Cache::tags('users')->flush() call, they carry hidden performance penalties on high-throughput Redis instances.

Redis does not natively support hierarchical cache keys. To achieve tagging, Laravel maintains set indexes inside Redis containing all tagged keys. When flushing a tag, Laravel fetches the set index and executes an atomic multi-key deletion or a Lua script. On high-throughput systems storing hundreds of thousands of keys under a single tag, purging that tag causes noticeable Redis CPU latency spikes and blocks single-threaded command execution.

An alternative pattern for production is explicit version prefixing inside cache key strings rather than relying on global tag flushes. When full invalidation is needed, incrementing an application configuration version string (e.g., changing users:v1:100 to users:v2:100) instantly renders previous keys dead without issuing blocking multi-key purges against Redis.

What Never to Cache

Attempting to cache every Eloquent query will degrade overall system throughput. Avoid caching the following categories of data:

  1. High-Frequency Counter Attributes: Columns like view_count, login_attempts, or last_active_at that change on nearly every request. Instead of saving these to MySQL and caching the model, increment atomical keys directly in Redis using Redis::incr() and periodically sync them back to persistent storage via scheduled background jobs.

  2. Paginated Dynamic Query Results: Caching full paginated responses for searches with complex filter combinations leads to low cache hit ratios and high memory consumption. Cache primary entity lookup records instead, then re-query primary key IDs from indexed database queries before batch-fetching keys from memory.

  3. User-Specific Authorization State: Caching permission trees directly inside cached model graphs risks leaking tenant context across user sessions if cache key isolation is improperly configured.

Building a Production-Ready Cache Repository

The most resilient pattern for laravel model caching combines array-level storage, explicit key generation, PHP 8.3 type safety, and fallbacks to primary read replicas. The example below shows a production Data Access Repository that caches array representations of models to bypass serialization penalties while preserving full transaction safety.

<?php

namespace App\Repositories;

use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Carbon\CarbonInterval;

class UserRepository
{
    private const TTL_HOURS = 24;
    private const KEY_PREFIX = 'users:v1:';

    public function findById(int $id): ?array
    {
        $key = self::KEY_PREFIX . $id;

        return Cache::remember($key, CarbonInterval::hours(self::TTL_HOURS), function () use ($id) {
            $user = User::query()->find($id);

            if (! $user) {
                return null;
            }

            return $user->toArray();
        });
    }

    public function findManyByIds(array $ids): array
    {
        $keys = array_map(fn (int $id) => self::KEY_PREFIX . $id, $ids);
        $cached = Cache::many($keys);
        $missingIds = [];
        $results = [];

        foreach ($ids as $id) {
            $key = self::KEY_PREFIX . $id;
            if (isset($cached[$key]) && $cached[$key] !== null) {
                $results[$id] = $cached[$key];
            } else {
                $missingIds[] = $id;
            }
        }

        if (! empty($missingIds)) {
            $freshModels = User::query()->whereIn('id', $missingIds)->get();
            
            $toCache = [];
            foreach ($freshModels as $model) {
                $arrayData = $model->toArray();
                $results[$model->id] = $arrayData;
                $toCache[self::KEY_PREFIX . $model->id] = $arrayData;
            }

            if (! empty($toCache)) {
                Cache::putMany($toCache, CarbonInterval::hours(self::TTL_HOURS));
            }
        }

        return $results;
    }
}

Summary Checklist for Production

Before deploying model caching in Laravel 12 applications running on PHP 8.3, confirm your architecture adheres to these core operational principles:

  • Ensure all Observers invalidating cache utilize $afterCommit = true to prevent race conditions during transaction rollbacks or delays.

  • Cache scalar arrays returned via toArray() rather than full serialized Eloquent model instances to eliminate instantiation bloat.

  • Avoid global Redis cache tags on high-cardinality keys to prevent Redis thread lockups during purge operations.

  • Isolate high-frequency atomic writes like view counts into standalone Redis keys outside the primary model structure.

    Latest Posts

    View All

    Detecting and Fixing N+1 Queries in Laravel 12

    Detecting and Fixing N+1 Queries in Laravel 12

    Model Caching Patterns in Laravel 12 for Production

    Model Caching Patterns in Laravel 12 for Production

    Ensuring Secure URLs in Laravel Applications

    Ensuring Secure URLs in Laravel Applications

    Simple Feature Flags in Laravel with Laravel Toggle

    Simple Feature Flags in Laravel with Laravel Toggle

    Laravel WhatsApp: A New Package That Combines the Cloud API and whatsapp-web.js in One Library

    Laravel WhatsApp: A New Package That Combines the Cloud API and whatsapp-web.js in One Library

    Laravel diffForHumans() Guide: Display Human-Readable Time Like a Pro

    Laravel diffForHumans() Guide: Display Human-Readable Time Like a Pro

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

    A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

    PestPHP Intellisense in Laravel VS Code Extension v1.7.0

    PestPHP Intellisense in Laravel VS Code Extension v1.7.0

    Laravel Starter Kits Now Come with Built-in Toast Notifications

    Laravel Starter Kits Now Come with Built-in Toast Notifications