scabarcas/laravel-permissions-redis
A Laravel package that adds a Redis-backed permissions layer for fast authorization. Store and check roles and permissions with less database overhead, ideal for high-throughput apps needing quick access checks and simple integration with Laravel’s authorization ecosystem.
Install and wire it up in five minutes.
composer require scabarcas/laravel-permissions-redis
php artisan vendor:publish --provider="Scabarcas\PermissionsRedis\PermissionsRedisServiceProvider"
php artisan migrate
Before you run anything, open composer.json for the package (or vendor/scabarcas/laravel-permissions-redis/composer.json) and confirm the Laravel/PHP constraints actually match your app. With 39 stars, 0 dependents and a single maintainer, you do not want to discover a version mismatch after you have already written @can directives across fifty Blade files.
Where to look first, in this order:
config/permissions-redis.php — connection name, key prefix, TTL, and whether a DB fallback is enabled. This file dictates almost every gotcha below.ServiceProvider — it tells you which contracts are bound (usually a PermissionRegistrar-style class and a repository) and therefore what you can swap out.User — that is the public API surface you will actually type every day.First use case: kill the N+1 on your authorization checks. The whole point of this package is that $user->can('posts.edit') should not hit MySQL. So the first thing to verify is that it actually does not:
// app/Models/User.php
use Scabarcas\PermissionsRedis\Traits\HasPermissions;
class User extends Authenticatable
{
use HasPermissions;
}
Then seed a role and prove the cache is being used:
$user->assignRole('editor');
DB::enableQueryLog();
$user->hasPermissionTo('posts.edit'); // first call populates Redis
$user->hasPermissionTo('posts.publish'); // should log zero queries
dd(DB::getQueryLog());
If you see a query on the second call, the package is not doing its job for your setup — check the connection config before going further.
Keep the database as the source of truth, Redis as a projection. The pattern that survives production is: roles/permissions/pivots live in MySQL; Redis holds a derived, per-user set of permission strings. Treat every write path as "write DB, then invalidate/rehydrate Redis." Never write permissions only into Redis — you will lose them on eviction and have no way to rebuild an audit trail.
Wrap the package behind your own authorization service. With 0 dependents and a low opportunity score, this is not a package you want leaking into 200 call sites. Bind your own interface and delegate:
// app/Contracts/Authorizer.php
interface Authorizer
{
public function allows(User $user, string $ability): bool;
public function flushFor(User $user): void;
}
// app/Services/RedisAuthorizer.php
class RedisAuthorizer implements Authorizer
{
public function allows(User $user, string $ability): bool
{
return Cache::store('permissions')->remember(
"perms:{$user->id}",
now()->addHours(12),
fn () => $user->getAllPermissions()->pluck('name')->all()
) && in_array($ability, ..., true);
}
public function flushFor(User $user): void
{
Cache::store('permissions')->forget("perms:{$user->id}");
}
}
If you later swap to spatie/laravel-permission, only one class changes.
Hook invalidation into model events, not into your controllers. The failure mode is always the same: somebody runs a raw DB::table('role_user')->insert(...) in a seeder or an admin script, and the cache goes stale. Register observers on your Role/Permission models and add an explicit php artisan permissions:flush call to any script that touches the pivots directly.
// AppServiceProvider::boot()
Role::saved(fn ($role) => $role->users->each->flushPermissionCache());
Role::deleted(fn ($role) => ...);
Warm the cache after deploy, not on first request. A cold Redis plus a traffic spike is the worst-case thundering herd. Add a post-deploy step:
php artisan migrate --force
php artisan permissions:warm --chunk=500
Use it through the normal Gate, not a bespoke API. Register a Gate that delegates, so @can, $this->authorize(), policies and middleware all work unchanged:
Gate::before(fn ($user, $ability) => $user->isSuperAdmin() ? true : null);
Gate::define('posts.edit', fn (User $u) => app(Authorizer::class)->allows($u, 'posts.edit'));
Route middleware is then just ->middleware('can:posts.edit') — no new mental model for the team.
Multi-tenancy: namespace the key by tenant. If you run several tenants through one Redis DB, include the tenant ID in the key prefix and flush by prefix on tenant-level role changes. Do not rely on flushall.
Redis is a cache, so design for its absence. Every read path should degrade to a DB query rather than throwing. Wrap the cache read in a try/catch on RedisException and log; an authorization outage that 500s every page is far worse than a slow one.
php artisan migrate:fresh does not clear Redis. Your test suite will pass locally and fail in CI, or worse, pass in CI and fail in staging. Add Redis::connection('permissions')->flushdb() to your RefreshDatabase teardown, or point tests at the array store.
Key collisions with Laravel's own cache are the #1 silent bug. If the package default connection is cache and your app also caches there, a cache:clear during deploy nukes authorization state (usually harmless) but a tagged flush can also wipe unrelated keys. Give this package its own Redis database (REDIS_PERMISSIONS_DB=3) in config/database.php and its own connection name.
Watch for stale Eloquent models in whatever gets serialized. If the package serializes Permission models into Redis, a schema change (adding description, renaming a column) will deserialize into broken objects until TTL expiry. Flush on deploy whenever you touch those tables.
Super-admin bypass belongs in Gate::before, not in the package. Do not try to encode "everything" as a * permission row if the package does not document wildcard support — you will get inconsistent behaviour between @can, middleware and direct hasPermissionTo calls.
Debug with redis-cli, not with dd(). redis-cli -n 3 MONITOR | grep perms while you click through the app tells you instantly whether a request is a hit, a miss, or writing nonsense keys. SCAN 0 MATCH "perms:*" COUNT 100 is safer than KEYS on a shared instance.
Version-pin and read the diff before upgrading. A single-maintainer package with 39 stars and no dependents has no downstream ecosystem to catch regressions. "scabarcas/laravel-permissions-redis": "1.2.*" rather than ^1.2, and skim the changelog before bumping — the authorization layer is not where you want to be an early adopter.
The extension points worth knowing: the bound repository/registrar contract (swap for a DB-only implementation in tests or as an escape hatch), the cache store name in config (point it at a dedicated connection), and the trait (you can override hasPermissionTo in your User model to add logging or a feature flag). Those three cover 95% of the customisation you will ever need.
Finally, budget for the exit. Given the low adoption numbers, decide up front what "this package is abandoned" looks like for you: an Authorizer interface, a config-only dependency, and a single seeder that can rebuild every permission. Doing that on day one costs an hour; doing it after two years of @can sprawl costs a sprint.
How can I help you explore Laravel packages today?