Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Smart Cache Laravel Package

iazaran/smart-cache

SmartCache is a Laravel cache optimizer for large payloads, adding automatic compression, driver-safe chunking, write deduplication, self-healing recovery, and diagnostics while keeping Laravel's familiar cache API.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and verify the environment first.

composer require iazaran/smart-cache
php -m | grep zlib   # strongly recommended for gzip compression

The package registers a facade and helper automatically. No config is required — defaults for compression, chunking, monitoring, cost tracking, deduplication, and self-healing are already enabled. Publish the config early anyway so you can see the thresholds you are actually running with:

php artisan vendor:publish --tag=smart-cache-config

(Config publish tag is not shown in the README excerpt; confirm the tag with php artisan vendor:publish and pick the SmartCache provider, then commit config/smart-cache.php.)

Where to look first

  1. config/smart-cache.php — the only place thresholds live (strategies.compression, strategies.chunking, model_invalidation.after_commit, swr.single_flight, events.enabled). Read this before your first benchmark.
  2. The facade: SmartCache\Facades\SmartCache. It implements Illuminate\Contracts\Cache\Repository, so anything accepting that contract works.
  3. The escape hatch SmartCache::repository() — this returns the native Laravel store and is your debugging tool when you need to see what is actually in Redis/file/database.
  4. The diagnostics surface (getCacheValueReport(), health checks, the dashboard) once you have real traffic.

First use case — the one that pays for itself

You almost certainly have one endpoint doing Cache::remember('something', 3600, fn () => Model::with(...)->get()) where the payload is 200 KB–2 MB. Change only that call site:

use SmartCache\Facades\SmartCache;

$users = SmartCache::remember('users', 3600, fn () => User::with('team')->get());

Do not start by replacing every Cache:: call in the app. The README's own recommendation is gradual adoption: change the facade import only where SmartCache is useful. Small scalar values are the case where this package adds cost without benefit.

A safe rollout order is: one heavy endpoint → measure hit latency and stored size → a second endpoint using tags → then consider the model trait.

Implementation Patterns

Gradual facade swap / drop-in repository

Because the facade implements the Laravel repository contract, the migration pattern is an import change plus a search for places that depend on cache-clearing semantics (see Gotchas):

// before
use Illuminate\Support\Facades\Cache;
Cache::put('report', $rows, 3600);

// after
use SmartCache\Facades\SmartCache;
SmartCache::put('report', $rows, 3600);

The helper smart_cache() is handy inside closures, jobs, and traits where importing a facade is noisy:

smart_cache(['products' => $products], 3600);
$products = smart_cache('products');

Keep SmartCache::repository('redis') in your pocket for package code or one-off operations that need the raw store, and use SmartCache::store('redis') / SmartCache::store('memcached') when you want a specific connection to keep all SmartCache optimizations.

Pattern: heavy read models behind remember()

The core day-to-day pattern. Anything shaped like a report, dashboard, API aggregation, or wide Eloquent result:

$stats = SmartCache::remember("dashboard:{$teamId}:{$range}", 900, function () use ($teamId, $range) {
    return AnalyticsService::report($teamId, $range);
});

Chunking and compression kick in transparently based on size, and the key structure of ID-keyed arrays survives the round trip — ->keyBy('id')->all() comes back with the same keys, not reindexed.

Pattern: lazy loading for genuinely huge sets

When a payload is tens of thousands of rows and consumers iterate rather than need it all at once:

config(['smart-cache.strategies.chunking.lazy_loading' => true]);

$dataset = SmartCache::get('100k_records'); // LazyChunkedCollection
foreach ($dataset as $record) { /* ≤3 chunks in memory */ }

Treat this as an opt-in for specific keys, not a global default, and remember the return type is not a plain array or Eloquent Collection.

Pattern: SWR for third-party and expensive sources

// stale-while-revalidate
$repos = SmartCache::swr('github_repos', fn () => Http::get($url)->json(), 300, 900);

// long stale window for config
$config = SmartCache::stale('site_config', fn () => Config::fromDatabase(), 3600, 86400);

// proactive refresh before expiry
$report = SmartCache::refreshAhead('daily_analytics', fn () => Analytics::generate(), 1800, 300);

All three refresh synchronously in the current request before returning stale data. That is fine for an admin dashboard and terrible for a 5-second report on a user-facing route. For that case, keep the callback in a dedicated invokable class and use the queue:

class RefreshDashboardStats
{
    public function __invoke(): array { /* ... */ }
}

$data = SmartCache::asyncSwr('dashboard_stats', RefreshDashboardStats::class, 300, 900, 'cache-refresh');
// also accepts 'App\Jobs\RefreshDashboardStats@handle'

asyncSwr() rejects closures by design. That is a feature — it fails at dispatch time instead of inside the queue serializer.

Pattern: stampede protection on hot keys

For a key that expires and triggers a thundering herd:

$data = SmartCache::rememberWithStampedeProtection('key', 3600, fn () => expensiveQuery());

SmartCache::throttle('api_call', 10, 60, fn () => expensiveApiCall());

SmartCache::withJitter(0.1)->put('popular_data', $data, 3600); // TTL 3240–3960s

Also set smart-cache.swr.single_flight = true if you use the SWR family in production behind multiple workers. On drivers implementing LockProvider (redis, memcached, database, dynamodb, file) only one worker regenerates the stale entry while the rest serve stale and return immediately.

Pattern: invalidation that matches how the data is read

Four mechanisms, chosen by coupling:

// tags — coarse, model-aligned
SmartCache::tags(['products'])->put("product_{$id}", $product, 3600);
SmartCache::flushTags(['products']);

// dependencies — downstream keys that must follow a source key
SmartCache::dependsOn('user_posts', 'user_profile');
SmartCache::invalidate('user_profile'); // clears user_posts too

// patterns — for keys you cannot enumerate (supports regex with delimiters)
SmartCache::flushPatterns(['user_*', 'api_v2_*', '/product_\d+/']);

// namespaces — tenant / API-version isolation
SmartCache::namespace('api_v2')->put('users', $users, 3600);
SmartCache::flushNamespace('api_v2');

The README gives the right heuristic: tag by the data used to build the response, not by the controller. List endpoints get coarse tags (products), item endpoints add instance tags (product_123).

Pattern: model-level auto-invalidation

use SmartCache\Traits\CacheInvalidation;

class User extends Model
{
    use CacheInvalidation;

    protected function cacheInvalidation(): array
    {
        return [
            'keys' => ['user_{id}_profile', 'user_{id}_posts'],
            'tags' => ['users', 'user_{id}', 'team_{team_id}'],
            'patterns' => ['users_list_*'],
            'dependencies' => ['team_{team_id}_summary'],
        ];
    }
}

Placeholders like {id} and {team_id} interpolate from model attributes. Invalidation is deferred to transaction commit by default (model_invalidation.after_commit = true) — that is the correct default and you should not disable it casually: it prevents another request from re-caching pre-commit data in the window between saved and COMMIT.

Pattern: deduplicate and memoize

// "Cache DNA" — identical content skips the value rewrite
SmartCache::put('app_config', Config::all(), 3600);

// per-request memoization for repeated reads in one request
$memo = SmartCache::memo();
$users = $memo->remember('users', 3600, fn () => User::all());
$users = $memo->get('users'); // in-memory

memo() is the single highest-value addition in a controller/service that reads the same key in several places.

Pattern: conditional caching and fallbacks

$data = SmartCache::rememberIf('external_api', 3600,
    fn () => Http::get($url)->json(),
    fn ($value) => !empty($value) && isset($value['status'])
);

$data = SmartCache::withFallback(
    fn () => SmartCache::get('key'),
    fn () => $this->fallbackSource()
);

rememberIf() is the right answer for flaky third-party APIs — never cache an empty error body. withFallback() belongs around any cache read that could throw on an unhealthy store.

Pattern: locks for regeneration

SmartCache::lock('expensive_operation', 10)->get(fn () => regenerateExpensiveData());

Prefer this over ad-hoc Cache::lock so lock keys live inside the same optimization/metadata model.

Observability as a workflow

SmartCache::getCacheValueReport();  // entries ranked by cost-aware value
SmartCache::suggestEvictions(5);   // lowest-value keys to drop

Enable events when you are diagnosing a stale-data bug:

config(['smart-cache.events.enabled' => true]);
Event::listen(CacheHit::class, fn ($e) => Log::info("Hit: {$e->key}"));
Event::listen(CacheMissed::class, fn ($e) => Log::warning("Miss: {$e->key}"));

The cost-aware score (cost × ln(1 + access_count) × decay) / size is a good signal for a "what should I delete" runbook. Run suggestEvictions() in a tinker session rather than wiring it to an automated job until you trust the ranking.

Gotchas and Tips

Clearing semantics are the one real migration hazard. The README calls it out explicitly: cache-clearing semantics are intentionally explicit in 1.x. Before swapping imports in tests or deploy scripts, audit every Cache::flush(), Cache::forget(), and any code that assumes tags behave like Redis tags. Write one test that asserts your flush path actually clears what you think it clears.

Metadata is not free. Even when no compression or chunking fires, monitoring, managed-key tracking, cost metadata, and write deduplication add keys and CPU. On Redis this means roughly 2–4 extra keys per cached entry. If most of your values are small scalars, you are paying overhead for nothing — the README says as much. Enable it where payloads are large, not globally.

Already-compressed payloads won't shrink. Encrypted blobs, pre-gzipped responses, JPEGs, and protobuf all bypass the benefit while still paying metadata cost. Check stored size before/after on a real key rather than trusting the 50 KB compressibility threshold to help.

asyncSwr() closures throw. Since v1.12.0 a Closure passed to asyncSwr() raises InvalidArgumentException. If you refactor from swr() to asyncSwr() and it blows up, that is the reason. Use an invokable class or "Class@method".

Synchronous refresh is the SWR footgun. swr(), stale(), and refreshAhead() run the refresh callback inline before returning. Stale reads are not free. Measure p99 on the stale path, and enable swr.single_flight so concurrent workers don't each regenerate the same entry.

Model events don't cover everything. saveQuietly(), query-builder update()/delete(), upsert(), mass insert(), and raw SQL skip Eloquent events entirely. Your trait will not fire. The explicit escape:

User::where('status', 'inactive')->update(['archived' => true]);
User::flushCacheTags(['users']);

Make this a code-review checklist item: any query-builder write touching a cached table needs a matching flush.

Long transactions + after_commit. The default is correct, but invalidation only fires at commit. In a queue job that opens a long transaction, the cache stays stale for the whole duration. If you disable the flag to get immediate invalidation, you re-introduce the stale-repopulation race — don't, unless you also stop caching inside the transaction window.

null is a cached value, not a miss. This preserves Laravel semantics, so get() returning null is ambiguous as always. Use has() when the distinction matters. Self-healing only regenerates on remember()-style reads — a bare get() on a corrupted chunked entry returns a miss with no callback to rebuild it.

Chunk loss means a miss, not an exception. Good behavior, but it hides driver-level problems. If your chunked keys keep "disappearing," you have an eviction policy or a maxmemory limit eating chunk keys before metadata. Watch Redis evicted_keys and check that chunk keys aren't being caught by a volatile-* policy.

TTL jitter makes TTL assertions flaky. withJitter(0.1) on a 3600s TTL yields 3240–3960s. Any test asserting exact remaining TTL will fail intermittently. Assert a range.

Write deduplication is not change detection. Identical content skips the value rewrite, but metadata may still refresh. Do not build "did this config change?" logic on top of it — compare content yourself.

Encryption matching is exact-key plus regex, nothing more. keys is a literal match list; patterns are regexes. There is no glob or namespace inheritance. Miss the pattern and the value is stored in plaintext with no warning — so pair it with a test that dumps the raw store via SmartCache::repository() and asserts the ciphertext.

LazyChunkedCollection is not an array. Any downstream is_array(), count() semantic, json_encode shape, or ->toArray() expectation can change. Audit consumers before enabling lazy loading on a key.

Single-flight needs a LockProvider. If your store is array (tests) or another non-lock driver, swr.single_flight silently degrades to the historical every-worker-refreshes behavior. Don't conclude the feature is broken — check the driver.

Driver-specific behavior still leaks. File and database drivers have their own size and index constraints; the chunking helps but a database cache table with a small value column will still truncate. Verify the column type (LONGTEXT/TEXT) on database caches before blaming the package.

Debugging recipe.

  1. SmartCache::has($key) — is it there at all?
  2. SmartCache::repository()->get($key) — what's raw in the store? (You'll see metadata + chunk keys.)
  3. Enable events.enabled and listen for hit/miss.
  4. For stale-data bugs, check whether the write path was an Eloquent event or a query-builder update.
  5. For "slow after adoption," benchmark a small value — you may be paying metadata for a payload that didn't need it.

Version and platform hygiene. PHP 8.1–8.5, Laravel 8–13. Matching the matrix does not extend upstream support lifetimes; pick a currently supported PHP release and a Laravel version inside the official support window. Run php artisan config:cache in deploy and re-verify thresholds survived, since config caching is the classic way for a tuned smart-cache.php to silently revert.

Benchmark before rollout, one key at a time. The package is honest that defaults are documented but not universally beneficial. The assessment that matters is your payloads on your driver: serialize time, gzip time, Redis MEMORY USAGE, and p50/p99 read latency for the specific keys you intend to migrate.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core