vormkracht10/laravel-permanent-cache
A Laravel package for permanent caching: keep data in the cache indefinitely without expiration. Useful for settings, configuration, and other rarely changing values you want stored persistently. Integrates with Laravel's native cache system through a simple API.
1. Install and publish.
composer require vormkracht10/laravel-permanent-cache
php artisan vendor:publish --tag=permanent-cache-config
Pin the version in composer.json ("0.3.17", no caret) — this is a 0.x package and patch bumps are not semver-protected.
2. Confirm your cache store before writing any code. Permanent caching is only as good as the driver underneath it. array loses everything per request, file fragments across servers and breaks with multiple app nodes, database adds a query per read (defeating part of the point). For anything real, use redis or memcached, and make sure the prefix is set per environment so staging and production never share keys.
CACHE_STORE=redis
CACHE_PREFIX=myapp_prod
3. Where to look first, in this order:
config/permanent-cache.php (or whatever the publish tag drops) — the cache prefix, key-generation strategy, and any enable/disable toggle live here. Read it before touching a model.vendor/vormkracht10/laravel-permanent-cache/src/ — the README is thin, the trait is short, and you need to know exactly which Eloquent events it hooks (saved, deleted, and whether it covers restored, forceDeleted).4. First use case — pick something boring and read-heavy. The ideal first candidate is a model that changes rarely and is read on nearly every request:
Do not start with User or Order. Those are high-write, often user-scoped, and the fastest way to discover a cache-invalidation bug in production.
use Vormkracht10\PermanentCache\Traits\CachesPermanently;
class Category extends Model
{
use CachesPermanently;
}
Then exercise it: load a category page, change the record in tinker, reload, confirm the new value appears. If it does, the invalidation wiring works. If it doesn't, stop and fix that before adding a second model.
Pattern 1 — trait on a reference-data model. This is the package's core loop: reads go through Cache::rememberForever(), and created/updated/deleted events flush the relevant key. The win is that you write normal Eloquent code and the caching is invisible:
// Controller / Blade — unchanged, now served from cache
$categories = Category::where('active', true)->orderBy('name')->get();
Be aware of what is actually being cached here. If the trait caches the model instance/collection result, then the query is the cache key. Two different where clauses are two different entries. That's fine for a handful of fixed queries; it is a footgun if you let a filterable API hit the model directly.
Pattern 2 — wrap the expensive query yourself, use the trait for invalidation. For anything with variable input (search, filters, pagination), don't rely on the model's implicit caching. Use the trait so writes invalidate, but build keys deliberately:
$key = sprintf('categories:active:%s:page:%d', $locale, $page);
$categories = Cache::rememberForever($key, fn () => Category::query()
->where('active', true)
->where('locale', $locale)
->orderBy('name')
->paginate(24)
);
If the package exposes a prefix/key helper (check the trait), reuse it so your manual keys and the package's invalidation land on the same namespace.
Pattern 3 — warm the cache after deploy or after a bulk change. rememberForever is lazy: the first request after a flush pays the full cost. That request is often a cold-start user or a health check. Warm it explicitly:
// app/Console/Commands/WarmCache.php
public function handle(): void
{
Category::all(); // primes the default key
$this->warmPricing();
$this->warmNavigation();
}
Wire it into your deploy script (after config:cache, before the traffic switch) and into the scheduler nightly.
Pattern 4 — background refresh for expensive, slow-changing data. The package's tagline is "update them in the background." That's a pattern, not a magic feature: you schedule a job that recomputes and repopulates the entry, so no user ever triggers the cold path.
// app/Console/Kernel.php
$schedule->job(new RefreshCategoryCache)->everyFiveMinutes();
$schedule->job(new RefreshPricingCache)->hourly();
Keep the job idempotent and make it flush-then-warm, not just warm — otherwise it will happily refresh a stale key.
Pattern 5 — flush on demand from an admin action. The package ships manual flush helpers. Use them from an admin controller or an artisan command after imports:
// After an admin bulk import, CSV upload, or settings save
Category::flushCache(); // verify exact method name in the trait
Pattern 6 — testing. Set CACHE_STORE=array in phpunit.xml so tests get a clean store per test. For tests that specifically assert invalidation, flush between assertions and assert both the cached and refreshed values:
public function test_category_cache_invalidates_on_update(): void
{
$category = Category::factory()->create(['name' => 'Old']);
$this->get('/categories')->assertSee('Old');
$category->update(['name' => 'New']);
$this->get('/categories')->assertSee('New'); // proves the event fired
}
Write this test for every model you add the trait to. It is the only thing standing between you and a permanent stale cache.
Pattern 7 — integration with API resources. Cache the built payload rather than the model when the serialization is expensive (nested resources, computed attributes):
return Cache::rememberForever("api:categories:{$locale}", fn () =>
CategoryResource::collection(Category::active()->get())->resolve()
);
This avoids re-hydrating models and re-running accessors on every request, and it serializes cleanly to Redis.
Writes that bypass Eloquent do not invalidate the cache. This is the single biggest failure mode. DB::table('categories')->update(...), raw SQL in a migration, a ->update() on a query builder chain that doesn't fire model events, a CSV seeder using insert(), a second service writing to the same database, or a DBA running a fix at 2am — none of these trigger the model events the trait listens to. The cache stays permanently stale until you manually flush.
Mitigations:
save(), update() on a model instance, delete()), not the query builder.DB::table( and ->insert( on trait-enabled tables.Mass updates via the query builder are the classic case. Category::where('active', false)->update(['sort' => 0]) fires no events. Iterate with ->each() on model instances if you need invalidation, or flush explicitly afterwards.
rememberForever means exactly that — there is no TTL safety net. A key that goes stale does not self-heal. Your monitoring story is: Redis memory usage, key count, and a periodic audit of what's cached. Set maxmemory-policy allkeys-lru on Redis so an out-of-control key space degrades gracefully instead of OOMing.
Never cache user-scoped or permission-scoped data under a global key. If your controller does Category::where('tenant_id', auth()->user()->tenant_id)->get() and the trait caches under a key derived only from the model, tenant A can be served tenant B's data. Either scope the key explicitly (Pattern 2), or don't use the trait on multi-tenant models.
Relationship and nested-cache staleness. If Category caches and Product embeds category data, updating the category must invalidate the product's cached payload too. The trait cannot know about that. Either flatten the data so there's one cache per concern, or add an observer on Category that flushes dependent keys.
Config caching and deploy order. php artisan config:cache is required in production, but it freezes the published config. If you change the package's prefix or enable/disable flag, you must re-run config:cache — forgetting this is a common "why did nothing change?" moment. Also flush the application cache after config:cache, not before, or the warm step writes into the old namespace.
Octane. Long-lived workers hold the container and any package singletons across requests. If the package binds a singleton that memoizes keys or state, verify behaviour under octane:start in staging. Also ensure cache flushes from a request are visible to already-running workers — with Redis they are; with array they are not.
Multi-server and the file driver. The file driver is per-node. On a load-balanced setup, a write on node A invalidates node A's cache, and node B serves stale data indefinitely. Use a shared store. This is not a package bug, but rememberForever makes it catastrophic rather than transient.
Queue workers already hold a booted app. If a worker flushes a key, web nodes see it (shared Redis), but the worker's own in-process state may be stale. Restart workers after deploy (queue:restart) as a matter of habit.
Debugging a suspected stale read. In order:
Cache::has($key) / Cache::get($key) in tinker — is it actually in the cache?redis-cli --scan --pattern "*yourprefix*" — are the keys named what you expect, and is the prefix what you think?Category::observe(...) or a DB::listen to confirm the write path is model-based.Cache::flush() and retest — if it works after flush, it's an invalidation problem, not a key-building problem.Check the exact surface before you design around it. The README documents a trait, automatic invalidation, a configurable prefix, and "simple helpers for manually flushing." Method names, event coverage (restored? replicated? forceDeleted?), and whether the trait caches per-query or per-instance are not documented in the release notes. Read src/ — it's a small package and the read is 20 minutes. Assume nothing about Cache::forget semantics.
Pin and track. At 0.x, ^0.3 admits breaking patch changes. Pin exactly, and add a scheduled composer outdated --direct review. Budget a re-validation pass on each Laravel major — v0.3.17 added Laravel 12 support, which is a good sign of maintenance but also means the constraint may have been widened without a full test matrix behind it. Smoke-test the trait's invalidation path on your target Laravel version before rolling out.
Extension points worth knowing. If the package's key generation is a method on the trait, override it in your model to inject tenant/locale. If it's a config class, bind your own implementation. That's the cleanest way to get scoped keys without duplicating cache logic — but confirm the method is protected and not final before you commit to that design, since a 0.x release can change it.
How can I help you explore Laravel packages today?