How-To: Gate an Admin Route
Three layers of access control work together in the admin panel. Every new admin screen needs all three or you'll either leak data or block the wrong users.
1. Route-level gate
The route group is the hard gate — it returns 403 before the controller is even instantiated.
// routes/web.php
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
// Independent permission per feature area
Route::middleware('permission:legacy-import.access')->group(function () {
Route::get('legacy-import', [LegacyImportController::class, 'index']);
});
Route::middleware('permission:data-mappings.access')->group(function () {
Route::get('legacy-import/mappings/{type?}', [LegacyImportMappingController::class, 'index']);
});
Route::middleware('permission:import-history.access')->group(function () {
Route::get('legacy-import/history', [LegacyImportHistoryController::class, 'index']);
});
});One permission per concern, not per role
Don't write permission:admin and call it a day. CPR splits Data Migration into three independent permissions (legacy-import, data-mappings, import-history) so a data-entry role can clean mappings without seeing imports. Mirror that granularity.
2. Menu-level gate
The sidebar shouldn't surface links the user can't open. The menu is data-driven in resources/js/layouts/AdminLayout.vue (or wherever navItems is defined):
const navItems = computed(() => [
{
label: 'Data Migration',
icon: ArrowsRightLeftIcon,
children: [
{ label: 'Legacy Import', href: route('admin.legacy-import.index'),
show: $can('legacy-import.access') },
{ label: 'Data Mappings', href: route('admin.legacy-import.mappings'),
show: $can('data-mappings.access') },
{ label: 'Import History', href: route('admin.legacy-import.history'),
show: $can('import-history.access') },
].filter((item) => item.show),
},
])Hide the parent group entirely if all children are hidden — otherwise users see an empty section.
3. Action-level gate
Inside the page, hide write buttons users can't use.
<Link
v-if="$can('data-mappings.access')"
:href="route('admin.legacy-import.mappings.edit', mapping.id)"
>
Edit
</Link>
<button
v-if="$can('legacy-import.access')"
@click="runImport"
class="btn btn-primary"
>
Run Import
</button>⚠️ There is no admin Gate::before
CPR does not register a Gate::before hook that gives admin every permission for free. The admin role gets every permission only because RoleSeeder explicitly assigns them all.
Consequence: when you add a new permission, you have to update RoleSeeder (or run a tinker grant) even for admin. Without that, even admin users get 403.
See memory: permissions-sync-command-quirk.
Testing the gate
it('returns 403 when the user lacks the permission', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.legacy-import.index'))
->assertForbidden();
});
it('allows users with the permission', function () {
$user = User::factory()->create();
$user->givePermissionTo('legacy-import.access');
$this->actingAs($user)
->get(route('admin.legacy-import.index'))
->assertOk();
});Checklist
- [ ] Route inside
permission:<name>middleware group - [ ] Sidebar link hidden when
$can()is false - [ ] Action buttons hidden when
$can()is false - [ ] RoleSeeder grants the permission to every role that should have it (don't forget admin)
- [ ] Test both 403 (no permission) and 200 (with permission)
