Laravel 12 introduces a game-changing feature: asynchronous caching with Cache::asyncRemember()
. This powerful addition allows you to delegate expensive cache generation to background jobs, dramatically improving response times and user experience. Let's dive deep into this feature and learn how to implement it effectively.
Understanding Async Cache
Traditional caching in Laravel works synchronously - when a cache miss occurs, the application blocks while generating the cached value:
// Traditional synchronous caching
$data = Cache::remember('expensive-data', 3600, function () {
// This blocks the request for 5+ seconds
return $this->performExpensiveOperation();
});
With async caching, Laravel immediately returns a placeholder or cached fallback while generating the fresh data in the background:
// New asynchronous caching
$data = Cache::asyncRemember('expensive-data', 3600, function () {
// This runs in a background job
return $this->performExpensiveOperation();
});
How Async Cache Works
- Cache Hit: Returns cached data immediately
- Cache Miss:
- Dispatches background job to generate data
- Returns fallback value or triggers appropriate response
- Updates cache when job completes
Complete Implementation Example
Let's build a real-world example: an analytics dashboard that aggregates expensive data.
1- The Expensive Service
First, create a service that performs costly operations:
<?php
namespace App\\Services;
use App\\Models\\Order;
use App\\Models\\User;
use Illuminate\\Support\\Facades\\DB;
class AnalyticsService
{
public function generateDashboardData(): array
{
// Simulate expensive operations
sleep(5); // Remove in production
return [
'total_revenue' => $this->calculateTotalRevenue(),
'user_growth' => $this->calculateUserGrowth(),
'top_products' => $this->getTopProducts(),
'conversion_rates' => $this->calculateConversionRates(),
'generated_at' => now()->toISOString(),
];
}
private function calculateTotalRevenue(): float
{
return Order::where('created_at', '>=', now()->subDays(30))
->sum('total_amount');
}
private function calculateUserGrowth(): array
{
return DB::table('users')
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as count')
)
->where('created_at', '>=', now()->subDays(30))
->groupBy('date')
->orderBy('date')
->get()
->toArray();
}
private function getTopProducts(): array
{
return DB::table('order_items')
->join('products', 'order_items.product_id', '=', 'products.id')
->select('products.name', DB::raw('SUM(order_items.quantity) as total_sold'))
->groupBy('products.id', 'products.name')
->orderByDesc('total_sold')
->limit(10)
->get()
->toArray();
}
private function calculateConversionRates(): array
{
$visitors = DB::table('page_views')
->where('created_at', '>=', now()->subDays(30))
->distinct('session_id')
->count();
$orders = Order::where('created_at', '>=', now()->subDays(30))->count();
return [
'visitors' => $visitors,
'orders' => $orders,
'rate' => $visitors > 0 ? round(($orders / $visitors) * 100, 2) : 0,
];
}
}
2- The Background Job
Create a job to handle async cache generation:
<?php
namespace App\\Jobs;
use App\\Services\\AnalyticsService;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Cache;
use Illuminate\\Support\\Facades\\Log;
class GenerateAnalyticsCacheJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private string $cacheKey,
private int $ttl
) {}
public function handle(AnalyticsService $analyticsService): void
{
try {
Log::info("Starting analytics cache generation for key: {$this->cacheKey}");
$data = $analyticsService->generateDashboardData();
Cache::put($this->cacheKey, $data, $this->ttl);
Log::info("Analytics cache generated successfully for key: {$this->cacheKey}");
// Optionally broadcast completion event
broadcast(new \\App\\Events\\CacheGenerationCompleted($this->cacheKey, $data));
} catch (\\Exception $e) {
Log::error("Failed to generate analytics cache: " . $e->getMessage());
throw $e;
}
}
}
3- The Controller with Async Cache
<?php
namespace App\\Http\\Controllers;
use App\\Jobs\\GenerateAnalyticsCacheJob;
use App\\Services\\AnalyticsService;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Cache;
class AnalyticsController extends Controller
{
public function __construct(
private AnalyticsService $analyticsService
) {}
public function dashboard(Request $request): JsonResponse
{
$cacheKey = 'analytics.dashboard.v1';
$ttl = 3600; // 1 hour
// Try to get cached data
$cachedData = Cache::get($cacheKey);
if ($cachedData) {
return response()->json([
'status' => 'success',
'data' => $cachedData,
'cached' => true,
'cache_age' => now()->diffInSeconds($cachedData['generated_at'] ?? now()),
]);
}
// Check if generation is already in progress
$generationKey = "{$cacheKey}.generating";
if (!Cache::get($generationKey)) {
// Mark as generating to prevent duplicate jobs
Cache::put($generationKey, true, 300); // 5 minutes lock
// Dispatch background job
GenerateAnalyticsCacheJob::dispatch($cacheKey, $ttl)
->onQueue('analytics');
}
// Return 202 Accepted with fallback data
return response()->json([
'status' => 'generating',
'message' => 'Analytics data is being generated. Please check back in a moment.',
'data' => $this->getFallbackData(),
'retry_after' => 30, // Suggest retry after 30 seconds
], 202);
}
public function status(Request $request): JsonResponse
{
$cacheKey = 'analytics.dashboard.v1';
$generationKey = "{$cacheKey}.generating";
$isGenerating = Cache::get($generationKey, false);
$hasData = Cache::has($cacheKey);
return response()->json([
'is_generating' => $isGenerating,
'has_data' => $hasData,
'estimated_completion' => $isGenerating ? now()->addSeconds(30) : null,
]);
}
private function getFallbackData(): array
{
return [
'total_revenue' => 0,
'user_growth' => [],
'top_products' => [],
'conversion_rates' => ['visitors' => 0, 'orders' => 0, 'rate' => 0],
'generated_at' => now()->toISOString(),
'is_fallback' => true,
];
}
}
4- Queue Configuration
Configure Redis and Horizon for optimal performance:
config/queue.php:
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
],
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
config/horizon.php
:
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'analytics'],
'balance' => 'auto',
'processes' => 10,
'tries' => 3,
'nice' => 0,
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'analytics'],
'balance' => 'simple',
'processes' => 3,
'tries' => 3,
'nice' => 0,
],
],
],
5- Routes
// routes/api.php
Route::prefix('analytics')->group(function () {
Route::get('/dashboard', [AnalyticsController::class, 'dashboard']);
Route::get('/status', [AnalyticsController::class, 'status']);
});
UX/REST Best Practices
1- HTTP Status Codes
Use appropriate status codes to communicate cache state:
// Data available immediately
return response()->json($data, 200);
// Generation in progress
return response()->json([
'message' => 'Data is being generated',
'retry_after' => 30
], 202); // Accepted
// Generation failed
return response()->json([
'error' => 'Failed to generate data'
], 500);
2- Client-Side Polling
Implement smart polling on the frontend:
class AsyncCacheClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.maxRetries = 10;
this.baseDelay = 1000; // 1 second
}
async fetchDashboard() {
let retries = 0;
while (retries < this.maxRetries) {
try {
const response = await fetch(`${this.baseUrl}/analytics/dashboard`);
const data = await response.json();
if (response.status === 200) {
return { success: true, data: data.data };
}
if (response.status === 202) {
// Exponential backoff
const delay = Math.min(
this.baseDelay * Math.pow(2, retries),
30000 // Max 30 seconds
);
await this.sleep(delay);
retries++;
continue;
}
throw new Error(`HTTP ${response.status}: ${data.message}`);
} catch (error) {
if (retries === this.maxRetries - 1) {
throw error;
}
retries++;
await this.sleep(this.baseDelay * retries);
}
}
}
async checkStatus() {
const response = await fetch(`${this.baseUrl}/analytics/status`);
return response.json();
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Usage
const client = new AsyncCacheClient('/api');
client.fetchDashboard()
.then(result => {
console.log('Dashboard data:', result.data);
})
.catch(error => {
console.error('Failed to fetch dashboard:', error);
});
3- Real-time Notifications
Use WebSockets or Server-Sent Events for real-time updates:
// Event class
<?php
namespace App\\Events;
use Illuminate\\Broadcasting\\Channel;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class CacheGenerationCompleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $cacheKey,
public array $data
) {}
public function broadcastOn(): Channel
{
return new Channel('cache-updates');
}
public function broadcastWith(): array
{
return [
'cache_key' => $this->cacheKey,
'completed_at' => now()->toISOString(),
];
}
}
// Frontend WebSocket listener
const echo = new Echo({
broadcaster: 'pusher',
key: process.env.MIX_PUSHER_APP_KEY,
cluster: process.env.MIX_PUSHER_APP_CLUSTER,
});
echo.channel('cache-updates')
.listen('CacheGenerationCompleted', (e) => {
if (e.cache_key === 'analytics.dashboard.v1') {
// Refresh dashboard data
this.refreshDashboard();
}
});
Performance Monitoring
Monitor your async cache performance:
// Add to your job
public function handle(AnalyticsService $analyticsService): void
{
$startTime = microtime(true);
try {
$data = $analyticsService->generateDashboardData();
Cache::put($this->cacheKey, $data, $this->ttl);
$duration = microtime(true) - $startTime;
// Log performance metrics
Log::info('Cache generation completed', [
'cache_key' => $this->cacheKey,
'duration_seconds' => round($duration, 2),
'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
]);
} catch (\\Exception $e) {
Log::error('Cache generation failed', [
'cache_key' => $this->cacheKey,
'error' => $e->getMessage(),
'duration_seconds' => round(microtime(true) - $startTime, 2),
]);
throw $e;
}
}
Best Practices Summary
1- Cache Strategy
- Use meaningful cache keys with versioning
- Set appropriate TTL values
- Implement cache warming strategies
2- Error Handling
- Always provide fallback data
- Implement retry mechanisms
- Log failures for monitoring
3- User Experience
- Use 202 status codes appropriately
- Provide estimated completion times
- Implement progressive loading states
4- Performance
- Use dedicated queues for cache jobs
- Monitor job execution times
- Implement circuit breakers for failing operations
Conclusion
Laravel 12's async caching with Cache::asyncRemember()
revolutionizes how we handle expensive operations. By moving cache generation to background jobs, we can:
-
Improve response times
- No more blocking requests -
Enhance user experience
- Immediate responses with progressive loading -
Scale better
- Distribute load across queue workers -
Handle failures gracefully
- Fallback data and retry mechanisms
The key to success is implementing proper UX patterns, monitoring performance, and following REST best practices. Start small with one expensive operation and gradually expand to more complex scenarios.
Top comments (0)