Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Backend Features/Caching

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

  1. Overview
  2. Cache Dashboard
  3. Performance Monitoring
  4. Cache Drivers
  5. Basic Operations
  6. Cache Patterns
  7. Cache Invalidation
  8. Model Caching
  9. Response Caching
  10. Testing

Overview

Caching improves performance by storing computed results:

  • Database query results
  • API responses
  • Expensive computations
  • View fragments
  • Configuration and routes

When to Cache

CacheDon't Cache
Expensive queriesFrequently changing data
External API resultsUser-specific dynamic data
Aggregations/reportsSmall, fast queries
Static configurationReal-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

FeatureRouteDescription
Monitoring/admin/cacheOverview of cache stats, hit rates, and alerts
Management/admin/cache/manageClear caches, warmup, manage keys
Performance/admin/cache/performanceResponse times, query metrics
Analytics/admin/cache/analyticsTrends and insights
Keys/admin/cache/keysBrowse 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

DriverUse Case
fileSimple local development
databaseNo Redis, needs persistence
redisProduction, high performance
memcachedProduction, distributed
arrayTesting (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"