DEV Community

Sebastian Cabarcas
Sebastian Cabarcas

Posted on

Migrating from spatie/laravel-permission to Redis: A Production Playbook

spatie/laravel-permission is the de facto standard for roles and permissions in Laravel, and for most apps it is exactly the right choice. This post is for the minority case: your authorization checks are a measurable share of your database load, or permission changes at scale keep triggering full cache reloads. I maintain laravel-permissions-redis, a package that keeps Spatie's API but moves the resolved per-user permission set into Redis. The docs have a step-by-step migration guide; this post is the production side of it: what to audit before you start, how to sequence the cutover, and how to roll back.

If you are not sure the migration is worth it, skip to "When you should not do this" at the end. I mean it. Migrations have a cost and this one only pays off under specific conditions.

What actually changes

Spatie caches the global permission registry, but each request still lazy-loads the user's role and permission relations from the database, about 4 queries across several tables. When anything changes, the whole cache is forgotten and rebuilt on the next request.

The Redis-first model resolves each user's full permission set once, stores it as a Redis SET, and answers hasPermissionTo() with an O(1) SISMEMBER. When a role or assignment changes, only the affected users get rewarmed. The database sees one query per request (the user lookup itself), and permission edits stop causing reload stampedes.

Two honest numbers before you commit to anything: in my benchmarks, per-request wall clock improves roughly 1.3-1.4x on SQLite and is close to a wash against a networked database. The win is not latency. The win is taking about 75% of authorization queries off your database and keeping the cache always warm. If your database is not the bottleneck, you will not feel it.

Step 0: audit your codebase first

Most migration pain comes from API surface you did not know you were using. Grep before you install anything:

grep -rn "getDirectPermissions\|getPermissionsViaRoles\|hasDirectPermission" app/
grep -rn "PermissionRegistrar" app/ tests/
grep -rn "teams\|team_id" config/permission.php
Enter fullscreen mode Exit fullscreen mode

What the results mean:

  • getDirectPermissions() / getPermissionsViaRoles(). These used to be a blocker. As of v4.1.0 both exist with the same names and return Permission models. One difference to know: they read the Eloquent relations (SQL), not the Redis cache, because distinguishing the source of a permission is an admin-screen concern, not a hot-path concern. Checks stay on Redis.
  • hasDirectPermission(). Does not exist. The one-line replacement is $user->getDirectPermissions()->contains('name', $permission).
  • PermissionRegistrar in tests becomes the WithPermissions trait or an InMemoryPermissionRepository binding. Mechanical change, but budget time for it.
  • Teams. Hard blocker. If you use Spatie's teams feature as actual teams, stay on Spatie. If your "teams" are really tenants, the package's multi-tenancy (per-tenant Redis key isolation) may fit, but evaluate that as its own project.

Also check your infrastructure: you need Redis (phpredis or predis), PHP 8.3+, and Laravel 12 or 13. Laravel 11 installs fine but is best-effort; CI only covers 12 and 13.

The migration itself

Both packages share the same 5-table schema, which is what makes this practical. The short version:

# 1. Install alongside Spatie (they coexist fine)
composer require scabarcas/laravel-permissions-redis

# 2. Publish the config
php artisan vendor:publish --provider="Scabarcas\LaravelPermissionsRedis\PermissionsRedisServiceProvider"

# 3. See what the migration would do, without touching anything
php artisan permissions-redis:migrate-from-spatie --dry-run

# 4. Run it for real: reuses your existing tables, adds the two
#    missing columns, and warms the Redis cache
php artisan permissions-redis:migrate-from-spatie
Enter fullscreen mode Exit fullscreen mode

Then the code changes, which are mostly find-and-replace:

  • Swap use HasRoles for use HasRedisPermissions on the User model.
  • Swap Spatie\Permission\Models\* imports for Scabarcas\LaravelPermissionsRedis\Models\*.
  • Middleware aliases (role, permission, role_or_permission) and Blade directives keep the same names. The @unlessrole / @unlesspermission closing tags differ: @endrole / @endpermission.
  • getAllPermissions() returns lightweight DTOs instead of Eloquent models. If you only read ->name, nothing changes.
  • Remove Spatie last, after your suite is green: composer remove spatie/laravel-permission.

The full migration guide has the complete method equivalence table and config mapping. The command is also safe to run in staging first, which is where you should run it.

The production cutover

This is the part most migration guides skip.

Deploy sequence. Ship the code change and run the migrate command in the same release, before traffic hits the new code: it copies nothing if the tables match, adds columns idempotently, and warms Redis. For large user tables, warm through the queue instead of blocking the deploy: php artisan permissions-redis:warm --queue.

Script your cache operations with --force. permissions-redis:flush prompts for confirmation, and a non-interactive prompt answers "no" and exits successfully. Your deploy script will think it flushed and it did not. As of v4.1.0, --force skips the prompt. This exact silent no-op is why the flag exists.

Verify the cutover. php artisan about now shows a Permissions Redis section (version, connection, prefix, TTL, tenancy status), and permissions-redis:stats shows how many user and role keys are live. Spot-check a few users: $user->getPermissionNames() against what you expect.

Rollback plan. This is the best property of the shared schema: your Spatie tables are never modified destructively (two nullable columns are added, which Spatie ignores). Rolling back is redeploying the previous release. The Redis keys live under their own prefix (auth: by default) and can be deleted without touching anything else.

Long-running workers. Queue workers and Octane hold state in memory. The package resets its in-memory caches between Octane requests behind a config flag (octane.reset_on_request). Turn it on if you run Octane; it is off by default.

Gotchas worth knowing before they find you

  • Guard isolation is stricter than Spatie's in one case. Passing integer role or permission IDs validates them against the target guard, and silently drops IDs that belong to another guard. String names already behaved this way.
  • TTL expiry is handled, but tune the cooldown. A cache miss triggers a warm-from-database with a per-user cooldown (resolver_warm_cooldown, default 1s) so a cold Redis after a restart cannot storm your database. If you run very high concurrency, read that config section.
  • syncPermissions() with no arguments syncs to empty (variadic since v4.1.0, matching syncRoles()). Same semantics as passing an empty array, but worth knowing if you generate calls dynamically.

When you should not do this

  • You do not run Redis. Do not add infrastructure for this. Spatie works with any cache driver.
  • You use the teams feature. Spatie's teams support is more mature than this package's multi-tenancy for team-based patterns.
  • Authorization is not a measurable cost. Check your query log first. If auth queries are noise in your metrics, the migration buys you complexity, not headroom.
  • You are on PHP < 8.3 or Laravel < 11. Spatie supports much older versions.

Where this leaves you

The v4.1.0 release closed the last API gaps that used to require workarounds in this migration (the direct vs role-inherited getters), added the --force flag, the about section, and PHP 8.5 to the CI matrix. The full changelog is here.

If you run the migration and something does not behave the way this playbook says, that is a bug in the package or in the docs, and I want to know: open an issue.

Top comments (0)