baukasten / cache
Flexible multi-bucket caching system with support for Redis, file, and in-memory storage, featuring attribute-based method caching and tag-based invalidation
Requires
- php: ^8.1
- predis/predis: ^2.0
Requires (Dev)
- phpunit/phpunit: ^9
README
A flexible, framework-agnostic PHP caching library with support for multiple storage backends (Redis, File, Memory), attribute-based method caching, and tag-based invalidation.
Features
- Multiple Storage Backends: Redis, File-based, In-Memory
- Bucket System: Organize cache into separate buckets with different configurations
- Attribute-Based Caching: Cache method results using PHP 8.1+ attributes
- Tag-Based Invalidation: Group and invalidate cache entries by tags
- TTL Support: Set time-to-live for cache entries
- Custom Key Generators: Define custom strategies for cache key generation
- Framework Agnostic: Works with any PHP project
Requirements
- PHP 8.1 or higher
- Predis for Redis support
Installation
composer require baukasten/cache
Quick Start
Basic Configuration
Configure cache buckets by passing instantiated bucket objects:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\MemoryBucket;
use Baukasten\Cache\Buckets\RedisBucket;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'default' => [
'bucket' => new MemoryBucket(),
'default' => true // This bucket will be used when no bucket is specified
],
'redis' => [
'bucket' => new RedisBucket(
host: '127.0.0.1',
port: 6379,
prefix: 'myapp:',
defaultTtl: 3600
),
],
'file' => [
'bucket' => new FileBucket(
cachePath: '/tmp/cache',
defaultTtl: 3600
),
]
]);
// Without specifying a bucket, the default bucket is used
Cache::set('key', 'value'); // Stored in 'default' (MemoryBucket)
// Specify a bucket explicitly
Cache::set('key', 'value', null, 'redis'); // Stored in 'redis' bucket
Simple Single Bucket Setup
For simple use cases, just configure one bucket as default:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'cache' => [
'bucket' => new FileBucket('/var/www/cache'),
'default' => true
]
]);
// All operations now use the file bucket
Cache::set('user:123', $userData);
$user = Cache::get('user:123');
Usage Examples
1. Basic Cache Operations
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\MemoryBucket;
// Setup
Cache::config([
'default' => [
'bucket' => new MemoryBucket(),
'default' => true
]
]);
// Store a value (TTL in seconds)
Cache::set('user:123', ['name' => 'John', 'email' => 'john@example.com'], 3600);
// Retrieve a value
$user = Cache::get('user:123');
// Check if a key exists
if (Cache::exists('user:123')) {
echo "User is cached";
}
// Delete a specific key
Cache::delete('user:123');
// Clear all cache entries
Cache::clear();
2. Using Multiple Buckets
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\MemoryBucket;
use Baukasten\Cache\Buckets\FileBucket;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'memory' => [
'bucket' => new MemoryBucket(),
'default' => true // Default for operations without bucket specified
],
'persistent' => [
'bucket' => new FileBucket('/var/cache/myapp')
],
'shared' => [
'bucket' => new RedisBucket(
host: 'redis.example.com',
port: 6379,
prefix: 'myapp:'
)
]
]);
// Uses default (memory) bucket
Cache::set('temp:data', $data);
// Explicitly use file bucket for persistent cache
Cache::set('config:settings', $settings, 86400, 'persistent');
// Use Redis for shared cache across servers
Cache::set('session:' . $sessionId, $sessionData, 1800, 'shared');
// Retrieve from specific buckets
$settings = Cache::get('config:settings', null, 'persistent');
$sessionData = Cache::get('session:' . $sessionId, null, 'shared');
// Clear specific bucket
Cache::clear('persistent');
3. Default Bucket Behavior
When you don't specify a bucket, operations use the default bucket:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'main' => [
'bucket' => new FileBucket('/tmp/cache'),
'default' => true
]
]);
// These all use the 'main' bucket automatically
Cache::set('key1', 'value1');
Cache::set('key2', 'value2', 600);
$value = Cache::get('key1');
Cache::delete('key2');
Cache::clear();
4. Remember Pattern (Cache or Compute)
The remember pattern retrieves from cache or computes and stores the value:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'cache' => [
'bucket' => new RedisBucket(),
'default' => true
]
]);
// Get from cache, or execute callback and cache the result
$user = Cache::remember('user:123', function() {
// This expensive operation only runs if not cached
return fetchUserFromDatabase(123);
}, 3600);
// With specific bucket
$products = Cache::remember('products:active', fn() => Product::active()->get(), 600, 'cache');
5. Tag-Based Caching and Invalidation
Tags allow you to group cache entries and invalidate them together:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'cache' => [
'bucket' => new FileBucket('/tmp/cache'),
'default' => true
]
]);
// Set entries with tags (5th parameter is tags array)
Cache::set('user:123', $user1, 3600, null, ['users', 'user:123']);
Cache::set('user:456', $user2, 3600, null, ['users', 'user:456']);
Cache::set('product:1', $product, 3600, null, ['products', 'featured']);
Cache::set('product:2', $product2, 3600, null, ['products']);
// Invalidate all entries tagged with 'users'
Cache::invalidateTag('users'); // Both user:123 and user:456 are deleted
// Invalidate multiple tags at once
Cache::invalidateTags(['products', 'featured']);
// Tags work with remember pattern too
$data = Cache::remember('expensive:computation', function() {
return performExpensiveComputation();
}, 3600, null, ['computations', 'expensive']);
// Later, invalidate all computations
Cache::invalidateTag('computations');
Attribute-Based Method Caching
Use the #[Cached]
attribute to automatically cache method results.
Option 1: Using Proxy (Recommended - Call Methods Directly)
use Baukasten\Cache\Attributes\Cached;
use Baukasten\Cache\CacheInterceptor;
class UserService
{
#[Cached(ttl: 3600, bucket: 'redis', tags: ['users'])]
public function getUser(int $id): User
{
// This will only execute if not cached
return User::find($id);
}
#[Cached(ttl: 3600, keyGenerator: 'generateUserCacheKey')]
public function getUserWithRole(int $id, string $role): User
{
return User::where('id', $id)->where('role', $role)->first();
}
private function generateUserCacheKey(string $method, array $args): string
{
[$id, $role] = $args;
return "user:{$id}:{$role}";
}
}
// Create a proxy that intercepts method calls
$service = new UserService();
$cachedService = CacheInterceptor::proxy($service);
// Call methods directly - caching happens automatically!
$user = $cachedService->getUser(123); // Executes and caches
$user = $cachedService->getUser(123); // Returns from cache
$user = $cachedService->getUserWithRole(123, 'admin'); // Custom key
Option 2: Using Execute Method
// Execute specific methods with caching
$service = new UserService();
$user = CacheInterceptor::execute($service, 'getUser', [123]);
Wrapping Callables
use Baukasten\Cache\CacheInterceptor;
// Wrap a callable with caching
$cachedFunction = CacheInterceptor::wrap(
callback: fn($id) => fetchUserFromDatabase($id),
ttl: 3600,
bucket: 'redis',
keyGenerator: fn($id) => "user:{$id}",
tags: ['users']
);
// Call it like a normal function
$user = $cachedFunction(123); // Fetches from database
$user = $cachedFunction(123); // Returns from cache
6. Bulk Operations
Efficiently handle multiple cache entries at once:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'cache' => [
'bucket' => new RedisBucket(),
'default' => true
]
]);
// Get multiple values at once
$values = Cache::getMultiple(['user:1', 'user:2', 'user:3'], 'default_value');
// Returns: ['user:1' => $value1, 'user:2' => $value2, 'user:3' => 'default_value']
// Set multiple values at once
Cache::setMultiple([
'user:1' => $userData1,
'user:2' => $userData2,
'user:3' => $userData3
], 3600);
// Set multiple with tags
Cache::setMultiple([
'product:1' => $product1,
'product:2' => $product2
], 3600, null, ['products', 'active']);
// Delete multiple values at once
Cache::deleteMultiple(['user:1', 'user:2', 'user:3']);
7. Enable/Disable Cache Globally
Control caching globally without modifying your code:
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'cache' => [
'bucket' => new RedisBucket(),
'default' => true
]
]);
// Cache is enabled by default
Cache::set('key', 'value');
$value = Cache::get('key'); // Returns 'value'
// Disable caching globally (useful for debugging or testing)
Cache::disable();
// All cache operations now bypass the cache
Cache::set('key2', 'value2'); // Returns false, nothing is stored
$value = Cache::get('key'); // Returns null (default value)
$value = Cache::get('key', 'default'); // Returns 'default'
Cache::exists('key'); // Returns false
// Check if cache is enabled
if (Cache::isEnabled()) {
echo "Cache is enabled";
}
// Re-enable caching
Cache::enable();
// Cache works again
$value = Cache::get('key'); // Returns 'value' (the original cached value)
Use cases:
- Development/Debugging: Disable cache temporarily to test with fresh data
- Testing: Ensure tests run with or without cache
- Maintenance: Disable cache during deployments or data migrations
- Performance Testing: Compare performance with and without cache
Bucket Types
Bucket Type Constants
Each bucket has a TYPE
constant that identifies its type:
use Baukasten\Cache\Buckets\MemoryBucket;
use Baukasten\Cache\Buckets\FileBucket;
use Baukasten\Cache\Buckets\RedisBucket;
echo MemoryBucket::TYPE; // 'memory'
echo FileBucket::TYPE; // 'file'
echo RedisBucket::TYPE; // 'redis'
// You can also get the type from an instance
$bucket = new FileBucket('/tmp/cache');
echo $bucket->getType(); // 'file'
Memory Bucket
Best for: Request-scoped caching, temporary data that doesn't need persistence.
Characteristics:
- Extremely fast (in-memory storage)
- Data is lost when the process ends
- No external dependencies
- Perfect for development or single-request caching
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\MemoryBucket;
Cache::config([
'memory' => [
'bucket' => new MemoryBucket(
defaultTtl: null // Optional: default TTL in seconds
),
'default' => true
]
]);
// Use case: Cache data for the current request only
Cache::set('current_user', $user);
Cache::set('view_data', $data, 60);
File Bucket
Best for: Persistent caching on single-server applications, storing cache across requests.
Characteristics:
- Persists data to disk
- Survives process restarts
- No external dependencies
- Slower than memory, faster than database queries
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'file' => [
'bucket' => new FileBucket(
cachePath: '/path/to/cache/directory', // Required: where to store cache files
defaultTtl: 3600 // Optional: default TTL in seconds
),
'default' => true
]
]);
// Use case: Cache API responses, computed data, configurations
Cache::set('api:weather', $weatherData, 1800); // Cache for 30 minutes
Cache::set('site:config', $config); // Cache indefinitely (or until defaultTtl)
Redis Bucket
Best for: High-performance caching, multi-server applications, distributed systems.
Characteristics:
- Very fast (in-memory)
- Persists data (with Redis persistence)
- Shared across multiple servers
- Requires Redis server
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'redis' => [
'bucket' => new RedisBucket(
host: '127.0.0.1', // Redis host
port: 6379, // Redis port
password: null, // Optional: Redis password
database: 0, // Optional: Redis database number
prefix: 'myapp:', // Optional: key prefix for isolation
defaultTtl: 3600 // Optional: default TTL in seconds
),
'default' => true
]
]);
// Use case: Shared cache across multiple web servers
Cache::set('user:session:' . $sessionId, $sessionData, 1800);
Cache::set('global:stats', $stats, 300);
Choosing the Right Bucket
Use Case | Recommended Bucket | Why |
---|---|---|
Single request caching | Memory | Fastest, no persistence needed |
Development/Testing | Memory or File | Simple setup, no dependencies |
Single server production | File | Persistent, no external services |
Multi-server production | Redis | Shared across servers |
High-traffic applications | Redis | Best performance at scale |
Session storage | Redis | Shared, fast, with TTL |
API response caching | File or Redis | Depends on traffic and server setup |
Custom Buckets
Create custom storage backends by implementing BucketInterface
:
use Baukasten\Cache\Buckets\BucketInterface;
use Baukasten\Cache\Cache;
class MyCustomBucket implements BucketInterface
{
public const TYPE = 'custom';
public function getType(): string
{
return self::TYPE;
}
// Implement all interface methods
public function set(string $key, mixed $value, ?int $ttl = null, array $tags = []): bool
{
// Your implementation
}
// ... other methods
}
// Register custom bucket
Cache::registerBucket('custom', new MyCustomBucket(), true);
Advanced Usage
Direct Bucket Access
// Get bucket instance for advanced operations
$bucket = Cache::bucket('redis');
$bucket->set('key', 'value');
Configuration Array Structure
use Baukasten\Cache\Buckets\MemoryBucket;
use Baukasten\Cache\Buckets\FileBucket;
use Baukasten\Cache\Buckets\RedisBucket;
[
'bucket_name' => [
'bucket' => new MemoryBucket() | new FileBucket(...) | new RedisBucket(...),
'default' => true|false, // Set as default bucket (optional)
]
]
The bucket configuration is now simplified - you pass instantiated bucket objects directly:
// Memory bucket (no configuration needed)
new MemoryBucket(defaultTtl: null)
// File bucket
new FileBucket(
cachePath: '/cache/path',
defaultTtl: 3600
)
// Redis bucket
new RedisBucket(
host: '127.0.0.1',
port: 6379,
password: null,
database: 0,
prefix: '',
defaultTtl: 3600
)
Real-World Examples
Example 1: Caching API Responses
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'api' => [
'bucket' => new RedisBucket(prefix: 'api:'),
'default' => true
]
]);
function getWeatherData(string $city): array
{
$cacheKey = "weather:{$city}";
return Cache::remember($cacheKey, function() use ($city) {
// This expensive API call only happens on cache miss
$response = file_get_contents("https://api.weather.com/forecast/{$city}");
return json_decode($response, true);
}, 1800, null, ['weather', 'external-api']);
}
// Use it
$weather = getWeatherData('London');
// Later, force refresh all weather data
Cache::invalidateTag('weather');
Example 2: Multi-Tenant Application
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
use Baukasten\Cache\Buckets\RedisBucket;
// Different buckets for different data types
Cache::config([
'tenant-data' => [
'bucket' => new RedisBucket(
host: 'redis.example.com',
prefix: 'tenant:',
defaultTtl: 3600
),
'default' => true
],
'static-assets' => [
'bucket' => new FileBucket('/var/cache/assets', 86400)
]
]);
class TenantService
{
public function getTenantSettings(int $tenantId): array
{
$cacheKey = "settings:{$tenantId}";
return Cache::remember($cacheKey, function() use ($tenantId) {
return $this->database->getTenantSettings($tenantId);
}, 3600, null, ['tenants', "tenant:{$tenantId}"]);
}
public function updateTenantSettings(int $tenantId, array $settings): void
{
$this->database->updateTenantSettings($tenantId, $settings);
// Invalidate only this tenant's cache
Cache::invalidateTag("tenant:{$tenantId}");
}
public function clearAllTenantsCache(): void
{
// Invalidate cache for all tenants
Cache::invalidateTag('tenants');
}
}
Example 3: Database Query Caching
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'queries' => [
'bucket' => new FileBucket('/var/cache/db', 600),
'default' => true
]
]);
class ProductRepository
{
public function getFeaturedProducts(): array
{
return Cache::remember('products:featured', function() {
// Expensive database query
return $this->db->query("
SELECT * FROM products
WHERE featured = 1 AND active = 1
ORDER BY priority DESC
")->fetchAll();
}, 600, null, ['products', 'featured']);
}
public function getProductsByCategory(int $categoryId): array
{
return Cache::remember("products:category:{$categoryId}", function() use ($categoryId) {
return $this->db->query("
SELECT * FROM products WHERE category_id = ?
", [$categoryId])->fetchAll();
}, 600, null, ['products', "category:{$categoryId}"]);
}
public function onProductUpdated(int $productId): void
{
// Invalidate all product-related caches
Cache::invalidateTag('products');
}
}
Example 4: Session Management with Redis
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'sessions' => [
'bucket' => new RedisBucket(
host: 'redis.example.com',
prefix: 'session:',
defaultTtl: 1800 // 30 minutes
),
'default' => true
]
]);
class SessionManager
{
private const SESSION_TTL = 1800;
public function createSession(string $userId, array $data): string
{
$sessionId = bin2hex(random_bytes(16));
$sessionKey = "user:{$userId}:{$sessionId}";
Cache::set($sessionKey, [
'user_id' => $userId,
'data' => $data,
'created_at' => time(),
'last_activity' => time()
], self::SESSION_TTL, null, ['sessions', "user:{$userId}"]);
return $sessionId;
}
public function getSession(string $sessionId, string $userId): ?array
{
$sessionKey = "user:{$userId}:{$sessionId}";
return Cache::get($sessionKey);
}
public function refreshSession(string $sessionId, string $userId): void
{
$sessionKey = "user:{$userId}:{$sessionId}";
if ($session = Cache::get($sessionKey)) {
$session['last_activity'] = time();
Cache::set($sessionKey, $session, self::SESSION_TTL, null, ['sessions', "user:{$userId}"]);
}
}
public function destroySession(string $sessionId, string $userId): void
{
$sessionKey = "user:{$userId}:{$sessionId}";
Cache::delete($sessionKey);
}
public function destroyAllUserSessions(string $userId): void
{
// Invalidate all sessions for a specific user
Cache::invalidateTag("user:{$userId}");
}
}
Example 5: Computed Values and Expensive Operations
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\FileBucket;
Cache::config([
'computations' => [
'bucket' => new FileBucket('/var/cache/compute', 3600),
'default' => true
]
]);
class ReportGenerator
{
public function generateMonthlyReport(int $year, int $month): array
{
$cacheKey = "report:monthly:{$year}:{$month}";
return Cache::remember($cacheKey, function() use ($year, $month) {
// This takes 30+ seconds to compute
$data = $this->computeRevenueMetrics($year, $month);
$data['charts'] = $this->generateCharts($data);
$data['statistics'] = $this->calculateStatistics($data);
return $data;
}, 86400, null, ['reports', 'monthly', "year:{$year}"]);
}
public function generateDashboardStats(): array
{
return Cache::remember('dashboard:stats', function() {
return [
'total_users' => $this->countUsers(),
'active_sessions' => $this->countActiveSessions(),
'revenue_today' => $this->calculateDailyRevenue(),
'pending_orders' => $this->countPendingOrders(),
];
}, 300, null, ['dashboard']);
}
public function clearReportsForYear(int $year): void
{
Cache::invalidateTag("year:{$year}");
}
}
Example 6: Multi-Bucket Strategy
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\MemoryBucket;
use Baukasten\Cache\Buckets\FileBucket;
use Baukasten\Cache\Buckets\RedisBucket;
// Configure different buckets for different use cases
Cache::config([
'memory' => [
'bucket' => new MemoryBucket(),
'default' => true // Default for request-scoped caching
],
'persistent' => [
'bucket' => new FileBucket('/var/cache/app', 3600)
],
'shared' => [
'bucket' => new RedisBucket(
host: 'redis.example.com',
prefix: 'app:',
defaultTtl: 1800
)
]
]);
// Request-scoped: Use memory bucket (default)
Cache::set('current_user', $user);
Cache::set('request_data', $data);
// Server-scoped: Use file bucket for data that persists across requests
Cache::set('app_config', $config, 3600, 'persistent');
Cache::set('compiled_templates', $templates, 7200, 'persistent');
// Cluster-scoped: Use Redis for data shared across multiple servers
Cache::set('feature_flags', $flags, 600, 'shared');
Cache::set('rate_limit:' . $userId, $count, 60, 'shared');
Cache::set('global_announcements', $announcements, 1800, 'shared');
Example 7: Using Proxy for Service Caching
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
use Baukasten\Cache\Attributes\Cached;
use Baukasten\Cache\CacheInterceptor;
Cache::config([
'services' => [
'bucket' => new RedisBucket(prefix: 'service:'),
'default' => true
]
]);
class ProductService
{
#[Cached(ttl: 600, tags: ['products'])]
public function getProduct(int $id): array
{
return $this->database->query("SELECT * FROM products WHERE id = ?", [$id]);
}
#[Cached(ttl: 300, tags: ['products', 'search'])]
public function searchProducts(string $query): array
{
return $this->database->query("SELECT * FROM products WHERE name LIKE ?", ["%{$query}%"]);
}
#[Cached(ttl: 3600, tags: ['products', 'categories'])]
public function getProductsByCategory(int $categoryId): array
{
return $this->database->query("SELECT * FROM products WHERE category_id = ?", [$categoryId]);
}
public function updateProduct(int $id, array $data): void
{
$this->database->update('products', $id, $data);
// Invalidate caches after update
Cache::invalidateTag('products');
}
// This method has no #[Cached] attribute, so it won't be cached
public function logProductView(int $productId): void
{
$this->database->insert('product_views', ['product_id' => $productId, 'viewed_at' => time()]);
}
}
// Create a cached proxy - all methods with #[Cached] are automatically cached!
$productService = new ProductService();
$cachedService = CacheInterceptor::proxy($productService);
// Use the service naturally - caching happens automatically
$product = $cachedService->getProduct(123); // Executes query, caches result
$product = $cachedService->getProduct(123); // Returns from cache
$results = $cachedService->searchProducts('laptop'); // Executes and caches
$results = $cachedService->searchProducts('laptop'); // From cache
// Methods without #[Cached] work normally without caching
$cachedService->logProductView(123); // Always executes, never cached
// When updating, invalidate caches
$cachedService->updateProduct(123, ['price' => 999.99]); // Clears all product caches
Example 8: Conditional Caching for Testing/Development
use Baukasten\Cache\Cache;
use Baukasten\Cache\Buckets\RedisBucket;
Cache::config([
'cache' => [
'bucket' => new RedisBucket(),
'default' => true
]
]);
// Disable cache in development or during testing
if (getenv('APP_ENV') === 'development' || getenv('DISABLE_CACHE') === 'true') {
Cache::disable();
}
// Your application code works the same regardless
class DataService
{
public function getExpensiveData(): array
{
return Cache::remember('expensive-data', function() {
// This always runs in development (cache disabled)
// But uses cache in production (cache enabled)
return $this->fetchFromDatabase();
}, 3600);
}
}
// In PHPUnit tests
class MyTest extends TestCase
{
public function testWithoutCache(): void
{
Cache::disable();
$service = new DataService();
$result = $service->getExpensiveData();
// Data is fetched fresh, not from cache
$this->assertNotNull($result);
}
public function testWithCache(): void
{
Cache::enable();
$service = new DataService();
$result1 = $service->getExpensiveData();
$result2 = $service->getExpensiveData();
// Both should be identical (from cache)
$this->assertEquals($result1, $result2);
}
}
// In console commands
class ClearDataCommand
{
public function execute(): void
{
// Temporarily disable cache during data migration
Cache::disable();
try {
$this->migrateData();
$this->updateRecords();
} finally {
// Always re-enable cache
Cache::enable();
}
}
}