Looking to hire Laravel developers? Try LaraJobs

laravel-smart-cache maintained by mugilaananthan

Description
Dependency-aware cache invalidation for Laravel: cache entries are forgotten automatically when the Eloquent models they depend on change.
Last update
2026/10/24 13:23 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

Laravel Smart Cache

Dependency-aware cache invalidation for Laravel.

Tell SmartCache which Eloquent models a cache entry depends on. When one of those models is created, updated, deleted or restored, SmartCache forgets only the cache entries that depend on it. Everything else in your cache stays put.

use SmartCache\Facades\SmartCache;

$users = SmartCache::remember('users.all', 3600)
    ->dependsOn(User::class)
    ->get(fn () => User::all());

User::create($data); // 'users.all' is forgotten automatically

Table of contents

  1. What the package does
  2. The stale-cache problem
  3. Installation
  4. Basic usage
  5. Multiple dependencies
  6. How invalidation works
  7. Configuration
  8. Artisan commands
  9. Testing
  10. Roadmap
  11. Contributing
  12. License

What the package does

SmartCache is a thin layer on top of Laravel's cache. It does not replace it.

  • Values are stored in your normal Laravel cache store (Redis, Memcached, database, file, ...).
  • For each SmartCache entry it records which models the entry depends on.
  • It listens to Eloquent model events and forgets exactly the dependent keys.
  • Cache::get(), Cache::put(), Cache::remember() and Cache::forget() work as they always have. SmartCache only manages entries created through SmartCache::remember().
  • It never calls Cache::flush().

The stale-cache problem

// Somewhere in a controller
$users = Cache::remember('users.all', 3600, fn () => User::all());

// Somewhere else, weeks later
User::create($data);
// ...and nobody remembered Cache::forget('users.all').

For the next hour, every visitor sees an outdated list. As an application grows, one model ends up feeding many cache entries (users.all, users.active, dashboard, ...). Keeping every Cache::forget() call in sync by hand is easy to get wrong.

SmartCache moves that knowledge to the place where the cache is created:

SmartCache::remember('users.all', 3600)
    ->dependsOn(User::class)
    ->get(fn () => User::all());

After that, you never have to remember to invalidate it yourself.

Installation

Requirements: PHP 8.2+, Laravel 11 or 12.

composer require mugilaananthan/laravel-smart-cache

The service provider and the SmartCache facade register automatically through Laravel package discovery.

Optionally publish the config file:

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

Local development (path repository)

To try the package inside another Laravel project before it is published, put both projects side by side:

projects/
├── laravel-smart-cache/   ← this package
└── my-app/                ← your Laravel application

In my-app/composer.json add:

"repositories": [
    {
        "type": "path",
        "url": "../laravel-smart-cache"
    }
]

Then:

composer require mugilaananthan/laravel-smart-cache:@dev

Composer symlinks the package, so changes you make in laravel-smart-cache/ show up in the app immediately.

Renaming the vendor: the Composer name lives only in composer.json ("name"). The PHP namespace is SmartCache\ and doesn't depend on the vendor, so you can rename the vendor without touching any code.

Basic usage

use App\Models\User;
use SmartCache\Facades\SmartCache;

$users = SmartCache::remember('users.all', 3600)
    ->dependsOn(User::class)
    ->get(fn () => User::all());
  • The first call runs the closure, caches the result for 3600 seconds and records users.all → User.
  • Later calls return the cached value without running the closure.
  • Once any User is created, updated, deleted or restored, users.all is forgotten, and the next call rebuilds it.

The TTL accepts everything Laravel accepts: seconds, DateInterval, DateTimeInterface, or null / rememberForever() for no expiry:

SmartCache::rememberForever('settings')
    ->dependsOn(Setting::class)
    ->get(fn () => Setting::pluck('value', 'key'));

Values can also be read with the plain Laravel cache: Cache::get('users.all').

The full Laravel cache API

SmartCache has the same methods as Laravel's Cache. Put dependsOn() in front of any write. It works like Cache::tags(), and the methods take exactly Laravel's arguments:

SmartCache::dependsOn(User::class)->put('users.all', $users, 3600);
SmartCache::dependsOn(User::class)->put(['users.a' => $a, 'users.b' => $b], 3600);
SmartCache::dependsOn(User::class)->putMany(['users.a' => $a], 3600);
SmartCache::dependsOn(User::class)->add('users.all', $users, 3600);   // tracked only if stored
SmartCache::dependsOn(User::class)->forever('users.all', $users);
SmartCache::dependsOn(User::class)->remember('users.all', 3600, fn () => User::all());
SmartCache::dependsOn(User::class)->rememberForever('users.all', fn () => User::all());
SmartCache::dependsOn(User::class)->flexible('users.all', [60, 3600], fn () => User::all());

SmartCache::dependsOn([User::class, Order::class])->put('dashboard', $data, 3600);

Reads are called directly:

SmartCache::get('users.all', $default);
SmartCache::many(['users.all', 'dashboard']);
SmartCache::has('users.all');
SmartCache::missing('users.all');
SmartCache::pull('users.all');          // also removes its dependency records
SmartCache::increment('orders.count');  // the key stays tracked
SmartCache::decrement('orders.count');
SmartCache::forget('users.all');        // also removes its dependency records

Two Laravel methods are deliberately missing:

  • SmartCache::put() (or add, forever, ...) without dependsOn() throws an exception that tells you to declare dependencies. An untracked write is exactly how stale data happens.
  • SmartCache::flush() doesn't exist. Use SmartCache::clear(), which forgets only SmartCache entries.

Why remember()->dependsOn()->get()?

Another shape was considered:

SmartCache::remember('users.all', 3600, fn () => User::all())->dependsOn(User::class);

It looks shorter, but remember() would have to run the closure and store the value before any dependency is known. Making that correct needs lazy evaluation or destructor tricks, and forgetting ->dependsOn() would fail silently. With get() as the final call, every dependency is declared up front, nothing is stored until get() runs, and a missing dependency is an error.

Multiple dependencies

Call dependsOn() several times, or pass an array or several arguments. All three forms below are equivalent:

SmartCache::remember('dashboard', 3600)
    ->dependsOn(User::class)
    ->dependsOn(Order::class)
    ->dependsOn(Payment::class)
    ->get(fn () => [
        'users' => User::count(),
        'orders' => Order::count(),
        'revenue' => Payment::sum('amount'),
    ]);

SmartCache::remember('dashboard', 3600)
    ->dependsOn([User::class, Order::class, Payment::class])
    ->get(fn () => DashboardService::getData());

SmartCache::remember('dashboard', 3600)
    ->dependsOn(User::class, Order::class, Payment::class)
    ->get(fn () => DashboardService::getData());

A change to any of User, Order or Payment invalidates dashboard. A change to Product does not.

Table dependencies

You can also depend on a table instead of a model class:

SmartCache::remember('report', 3600)
    ->dependsOn('table:orders')
    ->get(fn () => DB::table('orders')->sum('total'));

This is invalidated whenever any Eloquent model whose table is orders changes.

How invalidation works

User::create([...])
      │
      ▼
Eloquent fires "eloquent.created: App\Models\User"
      │
      ▼
SmartCache listener → identifiers: model:App\Models\User, table:users
      │
      ▼
Dependency store lookup
      model:App\Models\User → users.all, users.active, dashboard
      │
      ▼
Cache::forget('users.all'), Cache::forget('users.active'), Cache::forget('dashboard')
      │
      ▼
products.list, orders.all and every non-SmartCache key: untouched

Dependency identifiers

Dependencies are normalized to stable identifiers:

You write Stored as
User::class model:App\Models\User
$user (a model instance) model:App\Models\User
'table:users' table:users

When a model changes, both its model: identifier and its table: identifier are invalidated. Anything else (for example a bare 'users') throws an InvalidArgumentException, so typos fail loudly.

Where metadata lives

The dependency map is stored in the same Laravel cache store as the values, under keys prefixed with smart-cache::

smart-cache:index        → all tracked dependencies
smart-cache:dep:{sha1}   → cache keys of one dependency
smart-cache:key:{sha1}   → dependencies of one cache key

So if your cache store is Redis, the metadata is in Redis too, and it is shared across all web servers and queue workers. Redis is never required. Metadata writes are serialized with a Laravel atomic lock when the store supports locks.

Invalidation works across requests: the cache can be built in one request and the model changed in a later request, a queued job or an Artisan command.

Events handled

created, updated, deleted (including soft and force deletes) and restored, for every Eloquent model. You don't need to register models or add traits.

When a model changes inside a database transaction, SmartCache invalidates immediately and again after the commit. That way a request that rebuilds the cache before the commit can't leave stale data behind.

Changes Eloquent does not see

Mass updates and raw queries don't fire model events:

User::query()->where('active', false)->update(['archived' => true]); // no events
DB::table('users')->delete();                                         // no events

After those, invalidate explicitly:

SmartCache::invalidate(User::class);                 // one dependency
SmartCache::invalidate([User::class, 'table:orders']); // several

invalidate() returns the list of cache keys it forgot.

Other methods

SmartCache::forget('dashboard'); // forget one entry and its dependency records
SmartCache::clear();             // forget all SmartCache entries + metadata (never other keys)
SmartCache::enabled();           // bool

Configuration

config/smart-cache.php:

return [
    // Disable to cache values without tracking or invalidating anything.
    'enabled' => env('SMART_CACHE_ENABLED', true),

    // Cache store for SmartCache values; null = your default store.
    'store' => env('SMART_CACHE_STORE'),

    // Where dependency metadata lives:
    //   "cache" – in the store above (recommended; shared across processes)
    //   "array" – in memory for the current process only (tests)
    'dependency_store' => env('SMART_CACHE_DEPENDENCY_STORE', 'cache'),

    // Listen to Eloquent events for all models. Disable to only use SmartCache::invalidate().
    'auto_observe_models' => true,
];

Use a persistent, shared store (redis, memcached, database) in production. With the array cache store, values and metadata only last for a single request.

Artisan commands

smart-cache:status

php artisan smart-cache:status
Smart Cache Status

Enabled: YES

Tracked cache keys: 4
Tracked dependencies: 2

Order (model:App\Models\Order)
  dashboard
  user.orders.10

User (model:App\Models\User)
  users.all
  users.active
  dashboard

smart-cache:dependencies {dependency}

Lists the cache keys that depend on a model or table. You can refer to the dependency by its plural name, class basename, full class name, table or identifier:

php artisan smart-cache:dependencies users
php artisan smart-cache:dependencies User
php artisan smart-cache:dependencies "App\Models\User"
php artisan smart-cache:dependencies table:users

smart-cache:clear

php artisan smart-cache:clear

Forgets every entry that SmartCache manages and removes all dependency metadata. Other entries in your cache are not touched, and Cache::flush() is never called.

The commands show cache keys and dependency names only, never cached values.

Testing

The package is tested with PHPUnit and Orchestra Testbench against an in-memory SQLite database:

composer install
composer test        # vendor/bin/phpunit
composer format      # Laravel Pint (PSR-12 based)

The suite covers, among others:

  • updating, creating, deleting and restoring models invalidates dependent keys
  • multi-dependency keys are invalidated by any of their dependencies
  • unrelated keys (and plain Cache::put() keys) are not invalidated
  • several keys depending on the same model are all invalidated
  • metadata persists in the cache store across simulated requests (file store)
  • the Artisan commands, including that smart-cache:clear leaves unrelated keys alone

SmartCache only talks to Eloquent's event system and the Laravel cache. It never queries your database itself, so it works the same with MySQL, PostgreSQL, SQLite and SQL Server.

In your application's tests

With the array cache driver (Laravel's default in phpunit.xml), SmartCache works in tests out of the box:

public function test_dashboard_is_refreshed_after_new_order(): void
{
    $this->get('/dashboard');           // builds the SmartCache entry
    Order::factory()->create();         // invalidates it

    $this->assertFalse(Cache::has('dashboard'));
}

Roadmap

V0.1 (this release) supports explicit dependencies only. The following are planned and not implemented yet:

V0.2: Automatic dependency detection

SmartCache::remember('dashboard', 3600, function () {
    User::count();
    Order::count();
    Payment::sum('amount');
});

SmartCache would detect the queried tables automatically:

dashboard
 ├── users
 ├── orders
 └── payments

V0.3: Relationship-aware dependencies

User
 ↓
Orders
 ↓
Payments

A change to a Payment could invalidate caches that depend on the owning User's orders.

V0.4: Visual debugging dashboard

A web UI listing tracked keys and dependencies. Cache values will not be shown by default.

V0.5: Cache dependency graph

users ──────→ dashboard
   │
   ├────────→ users.all
   │
   └────────→ users.active

orders ─────→ dashboard
   │
   └────────→ user.orders

Future

  • Query-level / database-level dependency detection
  • Native Redis set-based dependency store (atomic SADD/SMEMBERS)
  • Row-level dependencies (model:App\Models\User#10)

Contributing

  1. Fork the repository and create a feature branch.
  2. composer install
  3. Add tests for your change. Pull requests that affect invalidation must include a test proving that unrelated keys survive.
  4. composer test and composer format
  5. Open a pull request describing the change.

Please report security issues privately to the maintainer rather than in public issues.

License

The MIT License (MIT). See LICENSE for details.