Skip to content

Last updated:

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.

php
// 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):

ts
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.

vue
<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

php
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)

CPR - Clinical Patient Records