spatie/laravel-permissionでロールベースアクセス制御を実装するハンズオン
概要
Laravelで「管理者だけが記事を削除できる」「編集者は投稿と更新はできるが削除はできない」といったアクセス制御を実装するとき、認可ロジックを自前のカラムやif文で書き散らすと、権限が増えるたびに破綻します。
本記事では spatie/laravel-permission(公式ドキュメント: https://spatie.be/docs/laravel-permission )を使って、ロール(役割)とパーミッション(権限)を分離したRBAC(Role-Based Access Control)をゼロから実装します。インストールからマイグレーション、ロール・権限の付与、ミドルウェア / Blade / Policy での認可、テストまでを一通り手を動かして確認します。
対象読者は、Laravelの基本(ルーティング・コントローラ・Eloquent)が分かるエンジニアです。
環境
- PHP 8.3+
- Laravel 13.x
- spatie/laravel-permission 8.x
- 認証は Laravel Breeze など標準の
App\Models\Userを使用
バージョンによってAPIが変わる部分があるため、本記事は上記を前提とします。Laravel 13 は最低要件が PHP 8.3 に引き上げられています(8.3〜8.5 をサポート)。spatie/laravel-permission は 8.x が Laravel 11 以降に対応し、本記事で扱う HasRoles やミドルウェアのAPIは 8.x でも安定しています。
RBACの考え方
最初に用語を整理します。
- Permission(権限): 「何ができるか」の最小単位。例:
edit articles、delete articles - Role(ロール): 権限の束に名前を付けたもの。例:
editor=edit articles+publish articles - User: ロールを持ち、ロール経由で権限を得る。直接権限を付けることもできる。
ポイントは チェックは常に権限(permission)で行う ことです。「管理者だから削除できる」ではなく「delete articles 権限を持っているから削除できる」と書くと、後から「編集長ロールにも削除を許可したい」となったときにコードを一切変えずに済みます。ロールは権限をまとめる入れ物として使い、認可の条件には使わない——これが運用で効く設計指針です。
発生しがちな問題
よく見るのが、users テーブルに role という文字列カラムを1つ持たせ、コントローラで if ($user->role === 'admin') と書くパターンです。これは次の点で破綻します。
- 1ユーザーが複数の役割を持てない
- 「この権限だけ特別に付与」ができない
- 権限チェックが文字列マッチで散らばり、typoに気づけない
- 権限の一覧・棚卸しがコードgrepになる
RBACをテーブルで正規化すると、これらが解決します。
対応方法
1. インストール
composer require spatie/laravel-permission php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" php artisan migrate
これで roles / permissions / model_has_roles / model_has_permissions / role_has_permissions の5テーブルが作成されます。中間テーブルが多いですが、「ユーザー⇄ロール」「ユーザー⇄権限」「ロール⇄権限」の多対多を素直に表現したものです。
2. Userモデルにトレイトを追加
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
HasRoles を付けるだけで assignRole() / hasRole() / can() / givePermissionTo() などが生えます。
3. 権限とロールをシーダで定義
権限は「コードが参照する識別子」なので、シーダでコード管理するのが安全です。画面から自由作成させると、コード側の文字列と一致しなくなった瞬間に認可が壊れます。
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$permissions = [
'view articles',
'create articles',
'edit articles',
'delete articles',
'publish articles',
];
foreach ($permissions as $name) {
Permission::findOrCreate($name);
}
$editor = Role::findOrCreate('editor');
$editor->syncPermissions([
'view articles',
'create articles',
'edit articles',
'publish articles',
]);
$admin = Role::findOrCreate('admin');
$admin->syncPermissions(Permission::all());
}
}
findOrCreate を使うと再実行しても重複作成されず、syncPermissions は「この一覧で置き換える」挙動なので、冪等にシードできます。
4. ユーザーへの付与
$user->assignRole('editor');
$user->givePermissionTo('delete articles'); // 特定ユーザーにだけ追加付与
$user->removeRole('editor');
$user->revokePermissionTo('delete articles');
5. 認可チェック
コントローラ / サービス層
if ($user->can('delete articles')) {
// 削除処理
}
$this->authorize('delete articles'); // 失敗で403
can() はロール経由・直接付与のどちらで得た権限でも、まとめて判定してくれます。
Policy と組み合わせる
実務では「自分の記事だけ編集できる」のような所有者チェックが絡むので、Policyに権限判定を寄せるのが綺麗です。
<?php
namespace App\Policies;
use App\Models\Article;
use App\Models\User;
class ArticlePolicy
{
public function update(User $user, Article $article): bool
{
return $user->can('edit articles')
&& ($article->user_id === $user->id || $user->can('edit all articles'));
}
public function delete(User $user, Article $article): bool
{
return $user->can('delete articles');
}
}
$this->authorize('update', $article);
ルートミドルウェア(Laravel 13)
// bootstrap/app.php
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})
Route::delete('/articles/{article}', [ArticleController::class, 'destroy'])
->middleware('permission:delete articles');
Blade
@can('delete articles')
<button type="submit" class="btn-danger">削除</button>
@endcan
@role('admin')
<a href="{{ route('admin.dashboard') }}">管理画面</a>
@endrole
ボタンを隠すのはUXのためであって、セキュリティ境界はサーバ側(Policy / ミドルウェア)で必ず担保します。
実装例: 記事CRUDに適用する
<?php
namespace App\Http\Controllers;
use App\Models\Article;
class ArticleController extends Controller
{
public function __construct()
{
$this->authorizeResource(Article::class, 'article');
}
public function destroy(Article $article)
{
$article->delete();
return redirect()->route('articles.index')
->with('status', '記事を削除しました');
}
}
確認方法
tinker で手早く確認
$user = \App\Models\User::first();
$user->assignRole('editor');
$user->can('publish articles'); // true
$user->can('delete articles'); // false
$user->getAllPermissions()->pluck('name');
$user->getRoleNames();
Feature テストで保証する
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ArticleAuthorizationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
public function test_editor_cannot_delete_article(): void
{
$editor = User::factory()->create();
$editor->assignRole('editor');
$article = Article::factory()->create();
$this->actingAs($editor)
->delete(route('articles.destroy', $article))
->assertForbidden();
}
public function test_admin_can_delete_article(): void
{
$admin = User::factory()->create();
$admin->assignRole('admin');
$article = Article::factory()->create();
$this->actingAs($admin)
->delete(route('articles.destroy', $article))
->assertRedirect();
$this->assertDatabaseMissing('articles', ['id' => $article->id]);
}
}
注意点
- 権限キャッシュ: このパッケージは権限をキャッシュします。反映されないときは
php artisan permission:cache-reset。本番でロール/権限をコード変更したら、デプロイ後にキャッシュリセットを忘れずに。 - マルチガード: APIとWebでガードを分ける場合、ロール/権限にも
guard_nameが紐づきます。ガードがズレるとcan()が常にfalseになります。 - 大量ユーザーでの付与: ループ内で
assignRoleを叩くとクエリが増えます。バッチではsyncRolesや中間テーブルへの一括insertを検討します。 - 権限名は識別子として扱う: 表示寄りの名前でも実体はコードが参照するキーです。表示名はi18nで別管理し、キーを安易にリネームしないこと。
- UIと認可の二重防御: Bladeの
@canは見た目の制御。実際のガードはPolicy/ミドルウェアで必ず二重に掛けます。
まとめ
- 権限(permission)とロール(role)を分離し、チェックは常に権限で行うのがRBACのコツ
spatie/laravel-permissionはHasRolesトレイト + シーダ + ミドルウェア/Policy/Blade で素直に組める- 権限はコード管理・冪等シード、認可は必ずサーバ側、UIは補助——この3点で運用に耐える
- Feature テストで「できない」ケースを固定すると、権限追加のたびのデグレを防げる
ロールが増えても権限ベースで組んでおけば、変更は「どのロールにどの権限を束ねるか」のシーダ編集だけで済みます。最初の設計で role 文字列カラムに逃げないことが、半年後の自分を救います。
