redisync / core
Smart caching middleware for PHP using Redis and MySQL/PostgreSQL
Requires
- php: ^8.1
- nyholm/psr7: ^1.8 || ^2.0
- predis/predis: ^1.1 || ^2.0
- psr/http-factory: ^1.0 || ^2.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- doctrine/dbal: ^3.6
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.0
Suggests
- doctrine/dbal: Required if you use DatabaseManager (pick a version compatible with your stack, e.g., ^3.8 for Laravel + Carbon 3)
- guzzlehttp/psr7: Alternative PSR-7 implementation (v1 or v2)
- illuminate/support: For Laravel auto-discovery & facades when used inside Laravel (^10|^11)
- laminas/laminas-diactoros: Alternative PSR-7/17 implementation
README
High-performance HTTP caching for PHP with Redis storage and optional DB-driven invalidation/write-through (MySQL/PostgreSQL via Doctrine DBAL).
Zero-friction HTTP caching for PHP apps: PSR-15 middleware, Redis-backed, DB-aware invalidation.
Quick nav: Install · Configuration · Middleware · Facade · Logging · Write-through · Laravel · CLI · Notes · API contracts · Troubleshooting · Proof
✨ Features
- PSR-15 middleware: automatic HTTP cache hit/miss flow.
- PSR-7/17 support via nyholm/psr7.
- GET/HEAD-only caching by default, optional bypass with the X-Bypass-Cache header.
- Cache headers: X-RediSync-Cache (HIT/MISS) and Age on hits (PSR-15 and Laravel).
- Conditional requests: automatic ETag generation and If-None-Match → 304.
- Cache-Control aware: respects no-store and private (won't serve/store).
- Vary safety: bypasses cache when Authorization or Cookie exist to avoid leakage.
- Safe caching via status whitelist (default: [200]) and Content-Type allow list.
- TTL map by path pattern/regex for per-endpoint TTL control.
- CLI for cache ops: clear-cache, list-keys, key-info, warmup.
- Doctrine DBAL-based DatabaseManager with invalidation hooks.
- Write-through DB helper: update cache immediately after successful DB writes.
- remember() helper: compute-or-cache convenience API (vanilla and Laravel facades).
- PSR-3 logging hooks: cache hit/miss/store, bypass reasons, DB write-through.
🔧 Install
Add to your project:
composer require redisync/core
Requirements: PHP 8.1+, Redis (via Predis 1.x or 2.x). Optional: Doctrine DBAL and DB drivers (pdo_mysql/pdo_pgsql) if you use DatabaseManager.
⚙️ Configuration
Configure programmatically (ENV not required):
use RediSync\Cache\CacheManager; $cache = CacheManager::fromConfig([ 'host' => '127.0.0.1', 'port' => 6379, 'database' => 0, 'prefix' => 'redisync:' ]);
DatabaseManager (optional):
use RediSync\Database\DatabaseManager; $db = DatabaseManager::fromDsn('mysql://user:pass@127.0.0.1:3306/app?charset=utf8mb4'); // Cache invalidation after data changes $db->onInvalidate(function (string $sql, array $params) use ($cache) { if (str_starts_with(strtoupper(ltrim($sql)), 'UPDATE USERS') || str_starts_with(strtoupper(ltrim($sql)), 'DELETE FROM USERS') || str_starts_with(strtoupper(ltrim($sql)), 'INSERT INTO USERS') ) { $cache->clearByPattern('users:*'); } });
🧩 Middleware Usage
use Nyholm\Psr7\Factory\Psr17Factory; use RediSync\Middleware\CacheMiddleware; use RediSync\Utils\KeyGenerator; $psr17 = new Psr17Factory(); $middleware = new CacheMiddleware( cache: $cache, keys: new KeyGenerator('http', ignoredParams: ['nonce', '_ts']), ttl: 300, responseFactory: $psr17, streamFactory: $psr17, statusWhitelist: [200], allowedContentTypes: ['application/json'], ttlMap: [ '/public/*' => 60, '#^/users/\\d+$#' => 300, ], ); // Add it to your PSR-15 stack (Mezzio, Slim, etc.). Middleware caches only GET/HEAD by default. // Conditional requests: send If-None-Match; 304 is returned when ETag matches (ETag is auto-generated if missing). // Cache-Control: requests with no-store bypass; responses with no-store/private are not stored. // Vary safety: Authorization/Cookie on the request bypass the cache to protect user-specific content. // To force-bypass: send header X-Bypass-Cache: 1. Responses include X-RediSync-Cache: HIT|MISS and Age.
HTTP semantics: ETag, 304, no-store/private, vary
- ETag: If the origin response doesn't include ETag, RediSync computes one from the body. Clients sending
If-None-Match
get304 Not Modified
when it matches. - no-store/private: A request with
Cache-Control: no-store
bypasses cache. A response withno-store
orprivate
is not stored by RediSync (shared cache). - Vary safety: Requests carrying
Authorization
orCookie
headers bypass cache to avoid leaking personalized content. - Headers: On cache HITs RediSync adds
X-RediSync-Cache: HIT
andAge
. On MISS it setsX-RediSync-Cache: MISS
.
🧩 Facade usage
Vanilla PHP (framework-agnostic)
use RediSync\Cache\CacheManager; use RediSync\Facades\RediSync; $cache = CacheManager::fromConfig(['host' => '127.0.0.1', 'port' => 6379, 'database' => 0, 'prefix' => 'app:']); RediSync::setInstance($cache); // get / set RediSync::set('users:1', ['id' => 1, 'name' => 'Ada'], 300); $data = RediSync::get('users:1'); // remember (compute-or-cache) $user = RediSync::remember('users:1', 300, function () { // expensive work or DB fetch return ['id' => 1, 'name' => 'Ada']; }); // Evict: set with null deletes the key (by design) RediSync::set('users:1', null); // equivalent to delete // Bulk invalidation example // $cache->clearByPattern('users:*');
📜 Logging (PSR-3)
RediSync logs key events with any PSR-3–compatible logger: cache.hit
, cache.miss
, cache.set
, cache.delete
, cache.clear_by_pattern
, httpcache.hit|miss|store|conditional_304|not_cacheable|bypass
, db.execute
, db.fetch_*
, db.write_through.cache_updated
.
Vanilla PHP (Monolog):
use Monolog\Logger; use Monolog\Handler\StreamHandler; use RediSync\Cache\CacheManager; use RediSync\Facades\RediSync; $logger = new Logger('app'); $logger->pushHandler(new StreamHandler('php://stdout')); $cache = CacheManager::fromConfig(['host' => '127.0.0.1', 'port' => 6379]); $cache->setLogger($logger); RediSync::setInstance($cache); RediSync::setLogger($logger); // optional facade shortcut
Laravel: LoggerInterface is automatically injected from the container. The ServiceProvider forwards the framework logger to CacheManager and DatabaseManager; no extra setup required.
Write-through DB to Cache
Update cache immediately after a successful DB write (inside a transaction):
use RediSync\Database\DatabaseManager; use RediSync\Cache\CacheManager; $db = DatabaseManager::fromDsn('sqlite:///:memory:'); // ... create table/users ... $affected = $db->writeThrough( 'UPDATE users SET name = :n WHERE id = :id', ['n' => 'alice', 'id' => 1], $cache, // Build cache entries from the write result function (int $affected, array $params, \Doctrine\DBAL\Connection $conn): array { if ($affected > 0) { return [ ['key' => "users:{$params['id']}", 'value' => ['id' => $params['id'], 'name' => $params['n']], 'ttl' => 300] ]; } return []; } );
Shortcut: you can also pass a simple associative array as the plan and use a default TTL:
$db->writeThrough( 'DELETE FROM users WHERE id = :id', ['id' => 1], $cache, [ 'users:1' => null ], // set null or use clearByPattern in an onInvalidate callback 60 );
Laravel Quickstart
Auto-discovery registers a Service Provider, Facades, and redisync.cache
middleware.
- Facade (controller) using remember():
use RediSync\Bridge\Laravel\Facades\RediSync; // static facade public function show(int $id) { $user = RediSync::remember("users:$id", 300, fn() => \App\Models\User::findOrFail($id)->toArray()); return response()->json($user); }
- Route cache (GET):
use Illuminate\Support\Facades\Route; Route::middleware('redisync.cache')->get('/api/users/{id}', [UserController::class, 'show']);
- HTML cache (view) via RediSyncCache (array/string payloads):
use Illuminate\Support\Facades\Auth; use RediSync\Bridge\Laravel\Facades\RediSyncCache as Cache; public function getProfile() { $u = Auth::user(); if (! $u) return redirect('404'); $k = "users:profile:{$u->id}"; if ($h = Cache::get($k)) return response($h); $h = view('profile', ['user' => $u])->render(); Cache::set($k, $h, 300); return response($h); }
- Data cache (array) via RediSyncCache:
use Illuminate\Support\Facades\Auth; use RediSync\Bridge\Laravel\Facades\RediSyncCache as Cache; public function getProfileData() { $u = Auth::user(); if (! $u) return redirect('404'); $k = "users:data:{$u->id}"; $d = Cache::get($k) ?: $u->toArray(); if (! Cache::get($k)) Cache::set($k, $d, 300); return view('profile', ['user' => $u]); }
- Invalidation (events):
// app/Providers/AppServiceProvider.php public function boot(\RediSync\Cache\CacheManager $cache): void { \App\Models\User::saved(fn() => $cache->clearByPattern('users:*')); \App\Models\User::deleted(fn() => $cache->clearByPattern('users:*')); }
Notes: Uses Laravel Redis config automatically. By default, JSON 200 responses are cached for ~300s. Bypass with header X-Bypass-Cache: 1
.
HTTP semantics in Laravel middleware:
- GET/HEAD cache with
X-RediSync-Cache
(HIT/MISS) andAge
on hits. If-None-Match
supported; returns304 Not Modified
when matching the stored ETag (computed if absent).- Respects
Cache-Control: no-store
on requests andno-store
/private
on responses (won't store). - Requests containing
Authorization
or cookies bypass the cache for safety.
Write-through in Laravel
// In a service or controller where you have the DB connection DSN use RediSync\Bridge\Laravel\Facades\RediSyncCache as Cache; use RediSync\Database\DatabaseManager; $db = DatabaseManager::fromDsn(env('DATABASE_URL')); $db->writeThrough( 'INSERT INTO posts (title) VALUES (:t)', ['t' => $title], app(\RediSync\Cache\CacheManager::class), fn(int $affected, array $p, \Doctrine\DBAL\Connection $c) => $affected ? [ ['key' => 'posts:latest', 'value' => /* recompute */ [], 'ttl' => 120] ] : [] );
🛠️ CLI
Use the bundled CLI for quick cache operations. The tool reads Redis config from config/config.php
.
vendor/bin/redisync help
Commands:
- clear-cache [pattern]
- Delete keys by pattern (default:
*
). - Example:
vendor/bin/redisync clear-cache users:*
- Delete keys by pattern (default:
- list-keys [pattern] [limit]
- List keys (default pattern
*
, limit100
). - Example:
vendor/bin/redisync list-keys api:* 50
- List keys (default pattern
- key-info
- Show TTL/type/size/exists.
- Example:
vendor/bin/redisync key-info users:1
- warmup [ttl]
- Read keys from STDIN and set placeholder values with TTL (default 60).
- Example:
printf "a\nb\n" | vendor/bin/redisync warmup 30
📷 Proof
📝 Notes
- Middleware caches only GET/HEAD requests by default.
- Use status whitelist and Content-Type filters for safe caching.
- TTL map allows per-path TTL control.
API contracts and errors
- Cache null semantics:
set($key, null)
evicts the key to avoid ambiguity withget()
returning null. - Exceptions: Redis/DB errors currently bubble up from underlying libraries. There’s no wrapper exception layer in 1.x; handle with try/catch in your app as needed.
Troubleshooting installs (Laravel/Carbon and Doctrine DBAL)
If your app uses Laravel 11 + Carbon 3, you may see a conflict involving doctrine/dbal
and carbonphp/carbon-doctrine-types
when installing redisync/core
.
What changed: RediSync no longer hard-requires doctrine/dbal
. It's optional and only needed if you plan to use DatabaseManager
.
-
Install RediSync first:
composer require redisync/core
-
If you need DB features, require a DBAL version compatible with your stack. For example:
composer require doctrine/dbal:^3.8
If Composer still reports conflicts, align DBAL with the versions compatible with your Laravel/Carbon lock (check composer why doctrine/dbal
and composer why-not doctrine/dbal:^3.10
).