tomatophp/tomato-roles
Tomato Roles is a Laravel package for managing roles and permissions with a ready-made admin UI. Create roles, assign permissions to users, and control access across your app without building the CRUD yourself. It fits the TomatoPHP ecosystem and keeps role-based access simple to configure and maint...
Confirm the stack before anything else. tomatophp/tomato-roles is an ACL layer for Tomato Admin on Splade (Laravel + Livewire + Blade). It is not a general-purpose permission package — the value it adds is the pre-wired admin UI (role editor, permission attach, user-role assignment) on top of what is almost certainly spatie/laravel-permission. If you're on Filament, Nova, Inertia, or a custom SPA, you get nothing but transitively-pulled dependencies.
Read these three files first — they determine your entire risk profile:
| File | What it tells you |
|---|---|
composer.json |
Whether it wraps spatie/laravel-permission or rolls its own engine; which Laravel / Livewire / Splade versions it pins |
database/migrations/* |
Table names, key types (UUID vs bigint), guard column, team/tenant scoping — diff against your users table |
src/TomatoRolesServiceProvider.php + models |
How the panel plugin registers, where the HasRoles-style trait lives, what config keys exist |
That first row is the decision point. Wrapping Spatie = thin UI shell, low risk. Reimplementing = bespoke authorization core with zero external review. Two minutes of reading changes how you plan the next two weeks.
Install on a throwaway branch:
composer require tomatophp/tomato-roles
php artisan vendor:publish --tag=tomato-roles-config
php artisan vendor:publish --tag=tomato-roles-migrations # only if you need to change schema
php artisan migrate
Then register the panel plugin per the Tomato Admin convention and add the package's trait to your User model. Publish migrations only when you must — once published, you own that schema and every future upgrade becomes a manual diff.
Prove deny-by-default in 15 minutes. This is the go/no-go gate:
// database/seeders/RoleSeeder.php
$admin = Role::create(['name' => 'admin']);
$admin->givePermissionTo(['users.view', 'users.edit']);
$viewer = Role::create(['name' => 'viewer']);
$viewer->givePermissionTo('users.view');
// routes/web.php
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin/users', UserTable::class);
});
@can('users.edit')
<x-tomato-button wire:click="edit">Edit</x-tomato-button>
@endcan
Then write one test asserting a viewer gets 403 on the edit route and doesn't see the button. If deny-by-default holds at both the HTTP and Blade layers, you have the core loop. If it doesn't, stop — you'd be building on a broken foundation.
Your day-one loop is: seed roles → open the Tomato Admin user table → assign roles through the UI → confirm gated routes and menu items behave. That loop is roughly 80% of what you'll actually touch.
Treat role/permission definitions as data, not scattered code. Dotted resource.action strings in a seeder (or a config/permissions.php map) make every environment reproducible. That list is a public contract — renaming users.view breaks every middleware string, Blade directive, and test in the repo simultaneously.
Layer authorization deliberately, in this order:
// 1. Route-level — coarse gate
Route::middleware(['auth', 'permission:users.view'])->group(...);
// 2. Livewire / Controller — check before acting
abort_unless(auth()->user()->can('users.edit'), 403);
// 3. Blade — cosmetic only
@can('users.edit') ... @endcan
Blade @can hides UI; it protects nothing. Every @can you ship needs a matching middleware or server-side check behind it, or you've built an interface that lies about its security posture.
Prefer $user->can() over $user->hasRole(). Role names are volatile, permissions are the durable contract. A UserPolicy or Gate::define calling hasPermissionTo() keeps your domain logic decoupled from the ACL backend — which is precisely your swap path later.
Centralize the permission list in one class (an enum or constants file) referenced by middleware, Blade, policies, and tests. When you migrate to raw Spatie or a policy layer, that file is the only thing that changes.
Super-admin bypass via Gate::before, never scattered if ($user->isAdmin()):
Gate::before(fn ($user) => $user->hasRole('super-admin') ? true : null);
Return null — never false — so normal checks still run for everyone else.
Assign roles in application flows, not just the admin UI. Plan upgrades and migrations are the common case:
User::where('plan', 'pro')->each(fn ($u) => $u->assignRole('pro'));
Run parallel checks behind a feature flag for one release cycle and reconcile any ad-hoc is_admin booleans or policy methods against the new model before cutting over.
Test the negative cases and the cache round-trip — these are the ones that matter:
it('denies viewer from editing users', function () {
$user = User::factory()->create()->assignRole('viewer');
$this->actingAs($user)->get('/admin/users/1/edit')->assertForbidden();
});
it('clears cached permissions on role revocation', function () {
$user->assignRole('admin');
expect($user->can('users.edit'))->toBeTrue();
$user->removeRole('admin');
expect($user->fresh()->can('users.edit'))->toBeFalse();
});
Custom permission matrices (per-department scoping, row-level rules) are the part you'll fight. The trait usually bends fine; the built-in role editor UI is fixed and doesn't understand custom dimensions — budget for building your own admin surface there.
Pin exact versions — never float on ^1.1. With a single-maintainer package on a Splade/Livewire stack, a minor bump shows up as a Livewire component failure rather than a clean composer conflict. The v1.1.21 fix — removing an unsupported applyQuery parameter from a SelectFilter in UserTable — is exactly that category: a Splade filter-API change surfacing as a "package bug." Lock Laravel, Livewire, Splade, and this package as a tested matrix in composer.json.
Resolve "wraps Spatie or reimplements it" before committing. If it delegates, you inherit a battle-tested engine and this package is a UI shell — good risk profile. If it reimplements the schema and checks, you own a bespoke authorization core with essentially no external review. If it does wrap Spatie, pin Spatie explicitly in your own composer.json so your engine version isn't transitively dictated.
Cache invalidation is a security bug, not a perf bug. Test assign → assert allowed → revoke → assert denied without restarting the app. If the package doesn't expose a permission:cache-reset command (Spatie's name), add one and wire it into your role-edit flow. A stale cache means a fired employee keeps access until the TTL expires.
Guard semantics are the most common silent killer. Splade admins frequently use a non-default admin guard, and cross-guard permission leakage is the #1 production authorization bug in this ecosystem. Test explicitly:
web-guard user cannot satisfy an admin-guard hasPermissionTo().If your app is multi-tenant and this package is global-scope only, that's a hard stop unless you wrap it yourself.
Diff the published migrations against any pre-existing ACL tables before migrating. Choose UUID vs bigint keys up front — retrofitting after data exists is a real migration, not a config tweak.
UserTable is bigger than "role editor." The v1.1.21 fix lives in a filterable user list, so the shipped surface extends into user management. Budget smoke tests on admin list screens for every Splade/Livewire bump — and expect those queries to be the expensive ones at scale.
Debug failing checks in this order:
php artisan tinker
>>> $u = User::find(1);
>>> $u->getRoleNames(); // is the role attached?
>>> $u->getAllPermissions(); // does the role carry it?
>>> Gate::forUser($u)->allows('users.edit'); // what does the gate actually see?
getAllPermissions() includes it but allows() is false → guard mismatch.allows() is true but @can hides it → directive/panel-context issue (usually the wrong guard in Blade).Treat it as vendored, not depended-on. Two stars, zero dependents, no CI signal, ~21 patch releases on one maintainer. Keep a named fork owner and pre-write the exit runbook to raw spatie/laravel-permission or a policy layer. The active patch line (v1.1.20 → v1.1.21, external PR merged) reassures on abandonment, not correctness — and a bug in the permission-check path is silent privilege escalation, not a visual glitch.
There is no support channel. No Stack Overflow tag, no Discord, no paid tier. Your escape hatches are reading src/ and reading Spatie's docs. Keep the source open beside your IDE for week one.
Don't ship the ACL cutover inside a feature launch. Give it its own release so rollback is clean. Rough budget: 0.5 day to first working role assignment, 1–2 days to production confidence including tests, then 2–5 engineer-days per Laravel major and 5–10 per Splade/Livewire major for upkeep.
How can I help you explore Laravel packages today?