smorken/roles
A Laravel package for role-based access control. Assign roles and permissions to users, guard routes with middleware, and check abilities in Blade or application code. Simple configuration and caching make authorization quick to set up and maintain.
1. Confirm it installs before you read any further.
This package is Bitbucket-hosted and not on Packagist. Add it as a VCS repository rather than assuming composer require smorken/roles works:
"repositories": [
{ "type": "vcs", "url": "https://bitbucket.org/scott_morken/roles.git" }
]
Then composer require smorken/roles. If this step fails, stop — the rest of the assessment is moot. Pin to a specific commit (dev-master#<sha>) because there are no tags to constrain against, and commit the composer.lock.
2. Read the source, in this order (~30–60 min). There is no meaningful documentation beyond the README, and no test suite to learn from. The four files that define the entire contract are:
src/*ServiceProvider.php — what's registered, whether config/migrations are publishable, whether middleware aliases are wired.HasRoles-style trait — the full public API surface you'll actually call (hasRole, assignRole, removeRole, etc.).config/roles.php — model class bindings, table names, cache settings.database/migrations/* — exact schema, especially whether it creates roles, permissions, role_user, permission_role, or a subset.Write down the method names before you write any application code. Guessing at them from the description is how you end up debugging a silent false.
3. Publish and migrate.
php artisan vendor:publish --provider="Smorken\Roles\RolesServiceProvider"
php artisan migrate
Audit the migration before running it if you already have roles/role_user tables from a previous experiment or a half-built in-house solution. migrate failing halfway is the most annoying possible first-run experience.
4. Add the trait to your User model.
use Smorken\Roles\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
If your User already uses a trait with a colliding method name (e.g. a hand-rolled hasRole), PHP will fatal on the conflict. Alias it explicitly rather than deleting your existing method on day one.
5. First real use case: gate an admin route.
The highest-value, lowest-risk first step is a single middleware-gated admin area. Seed an admin role, attach it to your own user, protect /admin/*, and confirm both the allow and the deny path manually. Do the deny path first — it's the one that matters and the one a broken install will silently invert.
6. Then wire the UI. Blade directives are the second thing you'll touch, and they're where the package starts feeling worth the integration cost: hiding nav items and buttons is tedious by hand and easy to get inconsistent with your route middleware.
Wrap it in a single seam from day one. The package's biggest weakness isn't its code, it's that it's a single-maintainer dependency sitting on a security boundary. Insulate it before it spreads:
// app/Support/AccessControl.php
class AccessControl
{
public static function userHasRole(?User $user, string $role): bool
{
return $user?->hasRole($role) ?? false;
}
}
Then use AccessControl::userHasRole() in controllers, and let the Blade directives call the same helper via a custom directive if you want UI gating to route through your seam too. The point is that swapping to spatie/laravel-permission or native Gates becomes a one-file change instead of a grep-and-replace across fifty call sites.
Route gating: prefer middleware over in-controller checks.
Register the package's role middleware as an alias in app/Http/Kernel.php, then apply it at the route group level:
Route::middleware(['auth', 'role:admin'])->prefix('admin')->group(function () {
Route::get('/dashboard', [AdminController::class, 'index']);
});
This has three advantages over $this->authorize()-style checks inside each action: it's declarative, it's visible in route:list, and it fails closed by default (a new controller method added to the group is protected automatically rather than forgotten).
Belt and braces: middleware and a Gate. Route middleware protects the HTTP layer; it does nothing for queued jobs, console commands, or anything calling your service layer directly. Register a Gate that delegates to the same check so the rest of your app has a framework-native entry point:
Gate::define('admin', fn (User $user) => AccessControl::userHasRole($user, 'admin'));
Now $this->authorize('admin'), Gate::allows('admin'), and can:admin middleware all work, and Blade's built-in @can('admin') works too — you may not even need the package's custom directives.
Seeding: make roles idempotent.
firstOrCreate on the role name, never create. You will run the seeder more than once — locally, in CI, in staging — and a unique constraint on roles.name turns a re-run into a failed deploy.
$admin = Role::firstOrCreate(['name' => 'admin']);
$admin->permissions()->sync($permissionIds); // sync, not attach
User::where('email', '[email protected]')->first()->assignRole('admin');
Use sync for permission-to-role assignment so the seeder is declarative: the DB ends up matching the seeder regardless of prior state.
Treat roles as data, not code — but only for names.
Role names belong in a config file or an enum so typos ("Admin" vs "admin") fail loudly and grep finds every usage. The role records belong in the database. A config/roles.php array like:
'roles' => ['admin', 'manager', 'member'],
'permissions' => ['posts.create', 'posts.delete', 'users.manage'],
gives you a seeder source, a validation source for admin forms, and a single diffable artifact for code review when the access model changes. That last point matters more than it sounds — an authorization change buried in a migration is invisible in review.
Caching: bust it on write, not on a timer. The package caches role lookups (this is one of its stated features, and it's the right call for performance). The pattern that works is to invalidate inside a single service method that is the only place role assignments happen:
class RoleService
{
public function assign(User $user, string $role): void
{
$user->assignRole($role);
Cache::forget("user:{$user->id}:roles"); // key depends on package internals
}
}
Find the actual cache key and TTL in the source before you rely on this. If the package invalidates on write already, great — verify it with a test rather than trusting it, because "user still has access after I removed the role" is the incident this whole pattern exists to prevent.
Permissions: use them as labels, not as a policy engine.
If the package ships permission_role, use permissions for coarse groupings (posts.manage covering create/edit/delete) rather than trying to model resource-level rules. The moment you need "can this user edit this post", you've left the package's competence and should be writing a Laravel Policy — with the Policy delegating its pre-check (can they manage posts at all?) to the role layer. That hybrid is clean and honest about each layer's job.
Testing: build the characterization suite the package doesn't have. This is the highest-leverage thing you can do beyond the wrapper. Six tests cover the surface area:
public function test_user_without_role_is_denied()
{
$user = User::factory()->create();
$this->actingAs($user)->get('/admin/dashboard')->assertForbidden();
}
public function test_user_with_role_is_allowed() { /* ... */ }
public function test_role_removal_revokes_access_immediately() { /* ... */ }
public function test_middleware_registered() { /* assertForbidden, not 500 */ }
That third one is the cache-staleness guard, and the fourth catches a package that fails open when unconfigured. Both are worth more than the rest combined. Run them on every Laravel/PHP bump — they're your upgrade gate.
Blade: mirror the route, don't invent new rules.
Every @role('admin') in a view should correspond to a role:admin on the route serving that view's actions. When they drift, you get the worst UX outcome: a visible button that 403s. A quick review heuristic — if a view references a role name that appears in no route middleware or Gate, one of the two is wrong.
No version tags means no meaningful constraint.
"smorken/roles": "dev-master" will silently pull new commits on composer update. Always pin "dev-master#<full-sha>", and treat that SHA as the version number in your docs. Better: fork the repo into your own namespace and tag 1.0.0-internal — that removes Bitbucket credentials from CI and every dev machine, and removes the "author deleted the repo" failure mode entirely. This is a half-hour of work and it's the single most valuable thing you'll do with this package.
composer install breaks for everyone at once if Bitbucket access lapses.
VCS repository entries need credentials in CI. App tokens expire, people leave, SSH keys rotate. The symptom is not "package missing" — it's every pipeline in every consuming repo failing simultaneously, usually on a Friday. Committed composer.lock plus a vendored fork or private registry (Satis / path repository) is the fix. Don't skip it because "it worked locally."
Deny paths are what you must test.
Authorization bugs fail in two directions, and only one gets noticed. A false-positive (denied user gets in) is invisible until it's an incident; a false-negative (granted user blocked) generates a bug report within minutes. That asymmetry means your manual testing will naturally over-cover the harmless direction. Write the negative tests deliberately, and specifically test the unauthenticated and no-roles-assigned cases — those are where a permissive default or a null user handling bug shows up.
Trait method collisions are a fatal, not a warning.
If User already has hasRole(), or you later adopt another package that adds one, PHP fatals at class-load time — which means the app won't boot, in production, after a deploy. Alias defensively:
use HasRoles { hasRole as packagedHasRole; }
and define your own hasRole() that calls through. Slightly ugly, completely worth it.
Config model bindings, not hardcoded classes.
The package advertises configurable models, which is the correct design — but it means your application code should never reference the package's Role class directly. If you do, you've hardcoded the thing the config exists to let you swap, and a later swap (or your own App\Models\Role extending it) means touching every reference. Bind through config, or resolve from the container.
migrate collision on existing tables.
If you have any prior roles table — from a deleted experiment, a previous developer, a half-migrated branch — the package's migration will fail or, worse, succeed against a schema that doesn't match what the trait expects, producing confusing query errors later. SHOW TABLES LIKE '%role%' before running, and diff the package migration against what exists.
Cache staleness is the classic support ticket. "I removed their admin role and they can still get in." Causes, in order of likelihood: (1) the cache wasn't invalidated on write; (2) it was invalidated but on a different key than the read path uses; (3) a long TTL and no explicit bust. Diagnose fast by clearing the cache and re-testing — if access is immediately correct, it's cache and never anything else. Then write the regression test.
Don't put it on the critical path of the Gate/Policy system.
Laravel's own Gate and Policy machinery is mature, well-documented, and already in your app. Use this package to answer one narrow question — what roles does this user hold? — and let Gates, Policies, and authorize() do the authorization reasoning on top. A package that answers hasRole() is a useful primitive. A package that becomes your entire authorization model is one you will rip out within a year, painfully, because it can't express per-resource rules.
Escalation path is you. No issue tracker you can escalate to, no maintainer, no community answers when a Laravel major breaks the trait. Everything you need to know lives in the source, which you can read in an hour — so read it now, while it's a curiosity rather than a production incident. Write down the two or three internal behaviors you depend on (cache keys, invalidation, relation names, migration schema), because those are exactly what an upstream change or a framework bump will break, and exactly what your characterization tests should pin.
Plan the exit at entry.
Global roles degrade into role explosion — admin, super_admin, org_admin, team_lead_eu — and then one day you need "admin of this workspace," which is a different data model, not a config flag. That's the point where you replace rather than extend. Having the AccessControl seam and the six characterization tests in place means that replacement is a sprint, not a rewrite. Set that expectation with your team on the day you adopt it, so nobody is surprised later.
How can I help you explore Laravel packages today?