innoge/laravel-policy-soft-cache
Laravel package that adds a soft cache layer for authorization policies, storing policy check results to avoid repeated Gate evaluations and speed up permission checks. It integrates with Laravel's authorization system to reduce policy overhead while keeping your existing policy code unchanged.
Install (1.6.0)
composer require innoge/laravel-policy-soft-cache
Release 1.6.0 is a compatibility release: it adds Laravel 13 support and contains nothing else (single PR, no changelog entries for behaviour changes). There are no new features, deprecations, or breaking changes versus 1.5.0, so upgrading is a composer update away — the only thing that can bite you is the transitive PHP/Laravel constraint bump that comes with Laravel 13, not this package's own code.
First three things to look at
php artisan vendor:publish and read the package's config file before writing any code. The knobs that matter day-to-day are whether caching is globally enabled, which cache store it uses, and the TTL. Confirm the actual publish tag from the service provider if --tag names differ.Gate::before/Gate::after) or expects you to opt in per policy. That distinction decides your whole integration approach.First use case
Pick the policy ability that is measurably expensive — typically view, update, or viewAny on a model whose authorization requires a join across team/project membership tables. Leave the trivial slug-comparison policies alone; caching them adds a cache round-trip for nothing.
Verify it works in five minutes
DB::listen(fn ($q) => dump($q->sql));
Gate::forUser($user)->allows('update', $post);
Gate::forUser($user)->allows('update', $post); // second call should show no new queries
If you see the query twice, the policy isn't actually being wrapped — check the trait is applied and the config is enabled for that guard.
Opt-in per policy, not globally. The safest rollout is to enable soft caching on the handful of policies that need it rather than flipping a global switch. A single stale decision on an account-deletion or billing policy is far more expensive than the queries you saved.
Key design is the whole game. Whatever the package does by default, sanity-check that the cache key includes: ability name, policy/model class, model primary key, the authenticated user's key, and the guard name. Omitting the guard is the classic bug in multi-guard apps (admin vs. web users sharing a cache entry). Also include a version/epoch token if you ever need a global flush on cache stores that lack tag support.
Pair it with explicit invalidation. "Soft" caching means you accept a window of staleness, but you should still bust the entry on the events that matter:
static::saved(fn ($model) => /* forget cached decisions for $model */);
static::deleted(fn ($model) => /* same */);
Critically, also bust when team/project membership or role assignments change — not just when the model itself is saved. A user removed from a team should lose access immediately, not at TTL expiry. This is the number one source of "why can this person still edit this?" tickets.
Workflow for a new expensive ability
Escape hatch for critical paths. Keep a way to bypass the cache — a direct, uncached Gate check — and use it in jobs, webhooks, and any flow where a stale true has financial or security consequences. Treat cached authorization as a UI-optimisation layer, not the final enforcement point.
Queue/console contexts. Authorization inside queued jobs runs without an authenticated user and often with a different guard. Either skip caching there or make sure the key includes the acting user explicitly, otherwise a job may read a decision cached for a real request user.
Testing. Disable the cache in your test suite via config (or run the array store with a fresh instance per test) so tests read as plain authorization assertions. Add a small number of dedicated tests that specifically verify caching behaviour with Cache::spy() or DB::listen().
false, true, or a Response object with a message/status. Caching a Response and replaying it loses nuance if any code reads deniedMessage(). Prefer caching the boolean decision and letting the uncached path build the message when you need to display it.Gate::before bypasses. Super-admin short-circuits in before() run ahead of the policy. Make sure you understand whether they're cached or always evaluated — an admin whose privileges were revoked should not be reading a cached true.redis/memcached support tags; file and database do not. If the package's invalidation relies on tags, a config change to the file driver in production silently degrades busting into a no-op. Pin the store explicitly and test invalidation on the store you actually deploy.config:cache and Octane. Run php artisan config:cache after changing the package config, and restart Octane workers — long-lived workers will otherwise keep the old resolved config and any in-process state.Cache::spy() for assertions in tests, and a temporary DB::listen or query log in a staging environment for production-shaped debugging. If you can't see hit/miss, add a tiny log line around the wrapper rather than guessing.How can I help you explore Laravel packages today?