guava/simple-permissions
A lightweight permissions package for Laravel that lets you assign roles and permissions to users through a simple, readable API, with middleware and Blade directives for guarding routes and views without the overhead of larger ACL libraries.
guavacz/simple-permissions is a lightweight, roles-only alternative to spatie/laravel-permission. It stores roles in a roles table and wires them to any Eloquent model through a polymorphic pivot (model_has_roles).
composer require guavacz/simple-permissions
php artisan vendor:publish --tag="simple-permissions-config"
php artisan vendor:publish --tag="simple-permissions-migrations"
php artisan migrate
HasRoles trait to the model(s) that should own roles:use GuavaCZ\SimplePermissions\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
use GuavaCZ\SimplePermissions\Models\Role;
Role::firstOrCreate(['name' => 'admin']);
Role::firstOrCreate(['name' => 'editor']);
$user->assignRole('admin');
$user->hasRole('admin'); // true
$user->assignRole(['admin', 'editor']);
$user->hasAnyRole(['editor']); // true
Where to look first: config/simple-permissions.php (model + table overrides), GuavaCZ\SimplePermissions\Models\Role, and GuavaCZ\SimplePermissions\Traits\HasRoles. Those three files are the whole surface area.
Assignment is a pivot write, not a string column. Treat roles as relational data:
$user->assignRole('admin'); // string or Role instance
$user->assignRole(['admin', 'editor']); // array
$user->removeRole('editor');
$user->syncRoles(['admin']); // idempotent — best for seeders/jobs
syncRoles() is the safe choice for anything that may run twice (seeders, onboarding jobs, imports). assignRole() on an already-assigned role is harmless but syncRoles() makes intent explicit.
Reading roles:
$user->roles; // Collection of Role models
$user->roles->pluck('name'); // ['admin', 'editor']
$user->hasRole('admin');
$user->hasAnyRole(['admin', 'editor']);
$user->hasAllRoles(['admin', 'editor']);
Authorisation wiring. The package stops at roles — map them to abilities with Gates/Policies, which is the idiomatic Laravel path:
// AppServiceProvider::boot()
Gate::define('manage-users', fn (User $user) => $user->hasRole('admin'));
// Policy
public function update(User $user, Post $post): bool
{
return $user->hasRole('editor') || $user->id === $post->user_id;
}
In Blade, just call the trait method (no directive is shipped):
@if(auth()->user()->hasRole('admin'))
<a href="{{ route('admin.dashboard') }}">Admin</a>
@endif
Custom middleware for route protection:
class EnsureUserHasRole
{
public function handle(Request $request, Closure $next, string ...$roles)
{
abort_unless(array_intersect($roles, $request->user()?->roles->pluck('name')->all() ?? []), 403);
return $next($request);
}
}
Eager loading. There is no built-in cache; hasRole() resolves against the loaded relation or queries. In list views, always:
$users = User::with('roles')->paginate();
and avoid per-row hasRole() calls on unloaded relations.
Custom Role model. Override via config, not by editing vendor files:
// config/simple-permissions.php
'models' => ['role' => App\Models\Role::class],
Your model must extend the package's Role (or match its contract) so the trait's relationships keep working.
Testing. Seed roles in a seeder or setUp, and use RefreshDatabase; assignments are plain pivot rows, so factories compose cleanly:
$user = User::factory()->create();
$user->assignRole('admin');
roles cast issue — if on 1.1.0 you saw $user->roles return an unexpected type (raw array/JSON or a non-collection after assignment or serialization), bump the constraint, composer update, and re-run php artisan config:cache/cache:clear if you cache config.hasPermissionTo(); express abilities as Gates/Policies keyed off role names.assignRole() expects an existing role; seed with Role::firstOrCreate(['name' => '…']) before assigning, especially in fresh environments and CI.assignRole()/removeRole(), a previously loaded roles relation on the same instance can be stale. Call $user->refresh() or $user->load('roles') before re-checking in long-running jobs/tests.roles, and if you have hot checks in a request, resolve once and reuse ($roles = $user->roles->pluck('name')->all()).model_has_roles.model_type stores the model class (or morph alias). If you introduce Relation::enforceMorphMap(), existing rows must be backfilled or the checks silently return false — verify with a quick DB::table('model_has_roles')->get().guard_name; if you have multiple guards, create roles per guard and match the authenticated guard, or hasRole() will miss.Role row does not guarantee pivot cleanup unless your migration declares the FK cascade. Prefer syncRoles([]) on models, or verify the FK in the published migration before deleting.config/simple-permissions.php changes (custom model/table names) require php artisan config:clear/config:cache; table renames also require editing the published migration before the first migrate.$user->roles()->toSql() and DB::listen() are the fastest way to confirm guard/model-type mismatches; the generated query shows the exact model_type and guard_name being filtered.User/Role subclasses or a small service rather than forking the trait — the trait is intentionally minimal, and wrapping it keeps future patch upgrades (like 1.1.1) frictionless.How can I help you explore Laravel packages today?