Caching Guide
Last Updated: 2026-01-12 Status: Active Audience: Developers
This guide documents caching patterns and strategies for the Client Portal application.
Table of Contents
- Overview
- Cache Dashboard
- Performance Monitoring
- Cache Drivers
- Basic Operations
- Cache Patterns
- Cache Invalidation
- Model Caching
- Response Caching
- Testing
Overview
Caching improves performance by storing computed results:
- Database query results
- API responses
- Expensive computations
- View fragments
- Configuration and routes
When to Cache
| Cache | Don't Cache |
|---|---|
| Expensive queries | Frequently changing data |
| External API results | User-specific dynamic data |
| Aggregations/reports | Small, fast queries |
| Static configuration | Real-time data |
Cache Dashboard
The application includes a comprehensive cache dashboard for monitoring and managing cache performance.
Accessing the Dashboard
Navigate to Admin → System → Cache Dashboard or visit /admin/cache.
Dashboard Features
| Feature | Route | Description |
|---|---|---|
| Monitoring | /admin/cache | Overview of cache stats, hit rates, and alerts |
| Management | /admin/cache/manage | Clear caches, warmup, manage keys |
| Performance | /admin/cache/performance | Response times, query metrics |
| Analytics | /admin/cache/analytics | Trends and insights |
| Keys | /admin/cache/keys | Browse and search cache keys |
Cache Statistics
The dashboard displays real-time statistics:
- Cache Driver: Current driver (file, database, redis)
- Hit Rate: Percentage of cache hits vs misses
- Memory Usage: Memory consumed by cache
- Total Keys: Number of cached items
- Top Keys: Most frequently accessed keys
Cache Management Actions
// Available management actions via the dashboard:
// Clear all cache
POST /admin/cache/clear-all
// Clear by pattern
POST /admin/cache/clear-pattern
// Body: { "pattern": "clients:*" }
// Clear by tag (Redis only)
POST /admin/cache/clear-tag
// Body: { "tag": "dashboard" }
// Invalidate specific key
POST /admin/cache/invalidate-key
// Body: { "key": "client:123:details" }
// Clear Laravel caches
POST /admin/cache/clear-views // Compiled views
POST /admin/cache/clear-config // Configuration
POST /admin/cache/clear-routes // Routes
// Warmup cache
POST /admin/cache/warmup
// Body: { "strategy": "dashboard" }
Cache Alerts
The system monitors cache health and shows alerts:
- Low Hit Rate: Warning when hit rate drops below 50%
- High Key Count: Info when keys exceed 100,000
- Memory Pressure: Warning when memory usage is high
Performance Monitoring
QueryProfiler Middleware
The QueryProfiler middleware logs slow queries and provides request profiling.
How It Works
// Automatically enabled for admin users in debug mode
// Located at: app/Http/Middleware/QueryProfiler.php
// The middleware:
// 1. Logs slow queries (>100ms) to performance.log
// 2. Logs slow requests (>500ms) to performance.log
// 3. Returns profiling data when ?_profile=1 is present
Using the Profiler
Add ?_profile=1 to any URL as an admin user to get profiling data:
GET /admin/clients?_profile=1
{
"profiling": {
"duration_ms": 45.23,
"memory_mb": 12.5,
"memory_peak_mb": 24.0,
"query_count": 8,
"slow_query_count": 0,
"queries": [
{
"query": "select * from clients where...",
"time_ms": 2.5,
"bindings_count": 1
}
]
}
}
Slow Query Logging
Slow queries are logged to storage/logs/performance.log:
[2026-01-12 10:30:45] local.WARNING: Slow queries detected {
"url": "https://portal.example.com/admin/reports",
"method": "GET",
"duration_ms": 523.45,
"slow_queries": [
{"query": "SELECT ...", "time_ms": 150.2, "bindings": 3}
]
}
Performance Collector
The PerformanceCollector service tracks request metrics:
use App\Services\Performance\PerformanceCollector;
// Get average response time (last hour)
$avgTime = $collector->getAverageResponseTime();
// Get slow requests
$slowRequests = $collector->getSlowRequests(limit: 10);
// Get database metrics
$dbMetrics = $collector->getDatabaseMetrics();
// Returns: avg_queries, slow_queries, n_plus_one count
// Get performance trends
$trends = $collector->getTrends('24h');
Performance Dashboard
The performance dashboard (/admin/cache/performance) shows:
- Response Time: Average and percentile (p95, p99)
- Cache Hit Rate: Real-time cache effectiveness
- Database Metrics: Query counts and slow query detection
- Slow Requests: List of slowest requests
- Trend Charts: Performance over time
Cache Drivers
Configuration
// config/cache.php
return [
'default' => env('CACHE_DRIVER', 'file'),
'stores' => [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
],
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
'array' => [
'driver' => 'array',
'serialize' => false,
],
],
'prefix' => env('CACHE_PREFIX', 'client_portal_cache'),
];
Driver Selection
| Driver | Use Case |
|---|---|
file | Simple local development |
database | No Redis, needs persistence |
redis | Production, high performance |
memcached | Production, distributed |
array | Testing (no persistence) |
Environment Configuration
# Development
CACHE_DRIVER=file
# Production
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Basic Operations
Storing and Retrieving
use Illuminate\Support\Facades\Cache;
// Store value for 60 minutes
Cache::put('key', 'value', now()->addMinutes(60));
// Store forever
Cache::forever('key', 'value');
// Retrieve value
$value = Cache::get('key');
// Retrieve with default
$value = Cache::get('key', 'default');
// Check existence
if (Cache::has('key')) {
// Key exists
}
// Retrieve and delete
$value = Cache::pull('key');
// Delete
Cache::forget('key');
// Clear all cache
Cache::flush();
Remember Pattern
// Get from cache or compute and store
$clients = Cache::remember('all-clients', now()->addHours(1), function () {
return Client::with('projects')->get();
});
// Forever version
$settings = Cache::rememberForever('app-settings', function () {
return Setting::all()->pluck('value', 'key');
});
Atomic Operations
// Increment/decrement
Cache::increment('page-views');
Cache::increment('page-views', 5);
Cache::decrement('stock-count');
// Add only if doesn't exist
$added = Cache::add('lock-key', 'value', now()->addMinutes(5));
// Atomic locks
$lock = Cache::lock('processing-invoices', 10);
if ($lock->get()) {
try {
// Process...
} finally {
$lock->release();
}
}
// Block until lock is available
Cache::lock('processing')->block(5, function () {
// Locked for up to 5 seconds
});
Using Specific Stores
// Use specific cache store
Cache::store('redis')->put('key', 'value', 3600);
// Use file cache even if Redis is default
Cache::store('file')->get('key');
Cache Patterns
Service Layer Caching
<?php
namespace App\Services;
use App\Models\Client;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class ClientService
{
private const CACHE_TTL = 3600; // 1 hour
/**
* Get all active clients (cached).
*/
public function getActiveClients(): Collection
{
return Cache::remember(
'clients:active',
self::CACHE_TTL,
fn () => Client::where('status', 'active')
->orderBy('company_name')
->get()
);
}
/**
* Get client with relationships (cached).
*/
public function getClientWithDetails(int $clientId): ?Client
{
return Cache::remember(
"client:{$clientId}:details",
self::CACHE_TTL,
fn () => Client::with(['projects', 'invoices', 'user'])
->find($clientId)
);
}
/**
* Get client statistics (cached).
*/
public function getClientStats(int $clientId): array
{
return Cache::remember(
"client:{$clientId}:stats",
now()->addMinutes(30),
function () use ($clientId) {
$client = Client::findOrFail($clientId);
return [
'total_projects' => $client->projects()->count(),
'active_projects' => $client->projects()->active()->count(),
'total_invoiced' => $client->invoices()->sum('total'),
'outstanding' => $client->invoices()->unpaid()->sum('total'),
];
}
);
}
/**
* Clear client cache.
*/
public function clearClientCache(int $clientId): void
{
Cache::forget("client:{$clientId}:details");
Cache::forget("client:{$clientId}:stats");
Cache::forget('clients:active');
}
}
Cache Tags (Redis/Memcached only)
// Store with tags
Cache::tags(['clients', 'reports'])->put('client-report', $report, 3600);
// Retrieve tagged cache
$report = Cache::tags(['clients', 'reports'])->get('client-report');
// Clear all items with tag
Cache::tags('clients')->flush();
Cache::tags(['clients', 'invoices'])->flush();
Cache Key Management
<?php
namespace App\Services;
class CacheKeyService
{
/**
* Generate consistent cache keys.
*/
public static function clientKey(int $clientId, string $suffix = ''): string
{
$key = "client:{$clientId}";
return $suffix ? "{$key}:{$suffix}" : $key;
}
public static function invoiceKey(int $invoiceId, string $suffix = ''): string
{
$key = "invoice:{$invoiceId}";
return $suffix ? "{$key}:{$suffix}" : $key;
}
public static function userKey(int $userId, string $suffix = ''): string
{
$key = "user:{$userId}";
return $suffix ? "{$key}:{$suffix}" : $key;
}
public static function listKey(string $model, array $filters = []): string
{
$filterHash = md5(serialize($filters));
return "{$model}:list:{$filterHash}";
}
}
// Usage
$cacheKey = CacheKeyService::clientKey($client->id, 'stats');
Query Result Caching
<?php
namespace App\Repositories;
use App\Models\Invoice;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
class InvoiceRepository
{
public function getPaginatedForClient(
int $clientId,
int $page = 1,
int $perPage = 15
): LengthAwarePaginator {
$cacheKey = "client:{$clientId}:invoices:page:{$page}:per:{$perPage}";
return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($clientId, $perPage) {
return Invoice::where('client_id', $clientId)
->with('items')
->orderByDesc('created_at')
->paginate($perPage);
});
}
public function getDashboardStats(): array
{
return Cache::remember('dashboard:invoice-stats', now()->addMinutes(30), function () {
return [
'total_outstanding' => Invoice::unpaid()->sum('total'),
'overdue_count' => Invoice::overdue()->count(),
'this_month_revenue' => Invoice::paid()
->whereMonth('paid_at', now()->month)
->sum('total'),
'pending_count' => Invoice::where('status', 'sent')->count(),
];
});
}
}
Cache Invalidation
Model Observer Pattern
<?php
namespace App\Observers;
use App\Models\Client;
use Illuminate\Support\Facades\Cache;
class ClientObserver
{
public function created(Client $client): void
{
$this->clearListCaches();
}
public function updated(Client $client): void
{
$this->clearClientCache($client);
$this->clearListCaches();
}
public function deleted(Client $client): void
{
$this->clearClientCache($client);
$this->clearListCaches();
}
private function clearClientCache(Client $client): void
{
Cache::forget("client:{$client->id}:details");
Cache::forget("client:{$client->id}:stats");
}
private function clearListCaches(): void
{
Cache::forget('clients:active');
Cache::forget('clients:all');
Cache::forget('dashboard:client-stats');
}
}
// Register in AppServiceProvider
public function boot(): void
{
Client::observe(ClientObserver::class);
}
Event-Based Invalidation
<?php
namespace App\Listeners;
use App\Events\InvoicePaid;
use Illuminate\Support\Facades\Cache;
class ClearInvoiceCaches
{
public function handle(InvoicePaid $event): void
{
$invoice = $event->invoice;
// Clear specific invoice cache
Cache::forget("invoice:{$invoice->id}");
// Clear client's invoice list
Cache::forget("client:{$invoice->client_id}:invoices");
// Clear dashboard stats
Cache::forget('dashboard:invoice-stats');
Cache::forget('dashboard:revenue-chart');
}
}
Cache Prefix for Bulk Invalidation
class ReportCache
{
private const PREFIX = 'reports:';
private const VERSION_KEY = 'reports:version';
public function get(string $reportName): mixed
{
$version = Cache::get(self::VERSION_KEY, 1);
return Cache::get(self::PREFIX . $version . ':' . $reportName);
}
public function set(string $reportName, mixed $data, int $ttl = 3600): void
{
$version = Cache::get(self::VERSION_KEY, 1);
Cache::put(self::PREFIX . $version . ':' . $reportName, $data, $ttl);
}
public function invalidateAll(): void
{
// Increment version to invalidate all report caches
Cache::increment(self::VERSION_KEY);
}
}
Model Caching
Cacheable Trait
<?php
namespace App\Traits;
use Illuminate\Support\Facades\Cache;
trait Cacheable
{
protected static function bootCacheable(): void
{
static::saved(function ($model) {
$model->clearCache();
});
static::deleted(function ($model) {
$model->clearCache();
});
}
public function getCacheKey(): string
{
return strtolower(class_basename($this)) . ':' . $this->getKey();
}
public function getCacheTtl(): int
{
return property_exists($this, 'cacheTtl') ? $this->cacheTtl : 3600;
}
public function clearCache(): void
{
Cache::forget($this->getCacheKey());
}
public static function findCached(int $id): ?static
{
$instance = new static;
$key = strtolower(class_basename($instance)) . ':' . $id;
return Cache::remember($key, $instance->getCacheTtl(), function () use ($id) {
return static::find($id);
});
}
}
Using the Trait
<?php
namespace App\Models;
use App\Traits\Cacheable;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use Cacheable;
protected int $cacheTtl = 86400; // 24 hours
public static function getValue(string $key, mixed $default = null): mixed
{
return Cache::remember("setting:{$key}", 86400, function () use ($key, $default) {
return static::where('key', $key)->value('value') ?? $default;
});
}
}
Response Caching
Middleware-Based Caching
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class CacheResponse
{
public function handle(Request $request, Closure $next, int $minutes = 60)
{
// Only cache GET requests
if ($request->method() !== 'GET') {
return $next($request);
}
// Don't cache authenticated requests
if ($request->user()) {
return $next($request);
}
$cacheKey = 'response:' . sha1($request->fullUrl());
if (Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
$response = $next($request);
if ($response->isSuccessful()) {
Cache::put($cacheKey, $response, now()->addMinutes($minutes));
}
return $response;
}
}
Controller-Level Caching
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class ReportController extends Controller
{
public function clientSummary(): View
{
$data = Cache::remember('report:client-summary', now()->addHours(1), function () {
return [
'total_clients' => Client::count(),
'active_clients' => Client::active()->count(),
'revenue_by_client' => Client::withSum('invoices', 'total')
->orderByDesc('invoices_sum_total')
->limit(10)
->get(),
'recent_clients' => Client::latest()->limit(5)->get(),
];
});
return view('reports.client-summary', $data);
}
}
Configuration Caching
Cache Config and Routes
# Cache configuration files
php artisan config:cache
# Cache routes
php artisan route:cache
# Cache views
php artisan view:cache
# Clear all caches
php artisan optimize:clear
# Build all caches
php artisan optimize
Deployment Script
#!/bin/bash
# Put application in maintenance mode
php artisan down
# Pull latest code
git pull origin main
# Install dependencies
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and rebuild caches
php artisan optimize:clear
php artisan optimize
# Restart queue workers
php artisan queue:restart
# Bring application back up
php artisan up
Testing
Faking Cache
use Illuminate\Support\Facades\Cache;
public function test_client_is_cached(): void
{
Cache::shouldReceive('remember')
->once()
->with('client:1:details', \Mockery::any(), \Mockery::any())
->andReturn($this->client);
$result = $this->service->getClientWithDetails(1);
$this->assertEquals($this->client->id, $result->id);
}
public function test_cache_is_cleared_on_update(): void
{
Cache::shouldReceive('forget')
->once()
->with('client:1:details');
Cache::shouldReceive('forget')
->once()
->with('clients:active');
$this->client->update(['company_name' => 'New Name']);
}
Using Array Driver
// phpunit.xml
<env name="CACHE_DRIVER" value="array"/>
// In test
public function test_caching_works(): void
{
$service = new ClientService();
// First call - computes value
$result1 = $service->getActiveClients();
// Second call - should return cached
$result2 = $service->getActiveClients();
$this->assertEquals($result1, $result2);
}
Testing Cache Invalidation
public function test_cache_invalidated_on_client_update(): void
{
// Prime the cache
$clients = Cache::remember('clients:active', 3600, fn () => Client::active()->get());
// Update a client
$client = Client::first();
$client->update(['status' => 'inactive']);
// Cache should be cleared
$this->assertNull(Cache::get('clients:active'));
}
Best Practices
Do
- Cache expensive operations
- Use meaningful cache keys
- Set appropriate TTLs
- Implement cache invalidation
- Use cache tags when available
- Monitor cache hit rates
- Clear caches on deployment
Don't
- Cache user-specific data globally
- Use very long TTLs without invalidation
- Forget to handle cache misses
- Cache tiny/fast queries
- Use cache for session-like data
- Skip cache in tests without reason
Cache Key Conventions
// Pattern: {entity}:{id}:{attribute}
"client:123:details"
"client:123:stats"
"invoice:456:pdf"
// Pattern: {entity}:{filter}:{hash}
"clients:active"
"invoices:overdue:2024-01"
// Pattern: {scope}:{type}
"dashboard:stats"
"reports:monthly-revenue"