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 文字列カラムに逃げないことが、半年後の自分を救います。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。