🌊

spatie/laravel-permissionを使って雑に権限管理する初歩の初歩

2025/02/11に公開

例えばlaravel breezeを使うと認証画面とその後のDashboardまでは高速開発可能であるが、多くの認証型webアプリケーションの場合このユーザーを管理する必要が出てくる。通常ここはスーパーユーザーのみアクセス可能な領域となっているべきである。

そこで、ここでは素のlaravel11をセットアップし、spatie/laravel-permissionを雑に使ってここへのアクセスを管理者に限定するユーザー管理のアクセス制限を行ってみよう。いずれにせよ、この手のライブラリーは事前にプロトタイプを作って要件に適しているかどうかをチェックする必要が多分にあるはずだ。

laravel.buildからさっとbreezeを展開する

curl -s "https://laravel.build/spatie-permission?with=mysql" | bash
./vendor/bin/sail artisan migrate:fresh --seed
./vendor/bin/sail composer require laravel/breeze --dev
./vendor/bin/sail artisan breeze:install blade

ここではbladeを展開している


いつものログイン画面、しかしlaravel12のスターターキットが出るとこれともお別れかもしれないが

ユーザーseederの変更

現在

database/seeders/DatabaseSeeder.php
class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        // User::factory(10)->create();

        User::factory()->create([
            'name' => 'Test User',
            'email' => 'test@example.com',
        ]);
    }
}

このようになっている。test@example.comでログインできるのであるが、これだけだとイマイチピンとこないだろうから、admin@example.comに変更し、これを管理者アカウントとしよう。さらに

// User::factory(10)->create();

のコメントを外し10人ユーザーを作成する

    public function run(): void
    {
        User::factory(10)->create();

        User::factory()->create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
        ]);
    }

再度DBを作り直す

 ./vendor/bin/sail artisan migrate:fresh --seed

ユーザーを確認する

とか。admin@example.comとその他10人ほど作成された。

spatie/laravel-permissionのインストール

https://spatie.be/docs/laravel-permission/v6/installation-laravel#content-installing

まあ普通にcomposerから

composer require spatie/laravel-permission

ライブラリーをいれるのだが

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"

して、

  • config/permission.php
  • database/migrations/xxxx_create_permission_tables.php

を呼びこんでくる。このmigrationはかなり膨大なので読みといてもいいけど基本的には内容を理解しなくても使えるようになっているし、configは大抵の場合カスタムしなければdefaultで動作するので、ここは最初から深く掘る必要は無いと思われる。

使っていく

このライブラリーは細かい権限設定も可能なのであるが、ここでの例は、たとえばbreezeで作成されたユーザーを管理したい場合などで

などしてみてもいいのだが

のようにadmin的なprefixを付けてそれ以下をadminロールのみアクセス可能とするという単純なものをまず考えてみよう。

使い出す準備 (User モデルに HasRoles トレイトを追加)

このライブラリーを使うにあたっては、これを必ず行う必要がある。

app/Models/User.php
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;

+use Spatie\Permission\Traits\HasRoles;
+
 class User extends Authenticatable
 {
     /** @use HasFactory<\Database\Factories\UserFactory> */
     use HasFactory, Notifiable;

+    use HasRoles;
+
     /**
      * The attributes that are mass assignable.
      *

このように追加しておこう、これで基本的には準備ok

admin@example.comにadminロールを当てる

これはseederでまずやってしまう

<?php

namespace Database\Seeders;

use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        User::factory(10)->create();

        $adminUser = User::factory()->create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
        ]);

        $adminRole = Role::firstOrCreate(['name' => 'admin']);
        $adminUser->assignRole($adminRole);
    }
}

このようにしてseedしな直した後でtinker

このように、$userオブジェクトにhasRole()というメソッドが増えている。これにより単純にadminかadminじゃないかが理解できるようになっているわけだ。

ユーザーリソースフルコントローラーを作る

php artisan make:controller UserController -m User -r

としたあとで

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        return view('users.index', [
            'users' => User::all()
        ]);
    }

などとし、

routes/web.php
<?php

use App\Http\Controllers\ProfileController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/dashboard', function () {
    return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});

Route::middleware(['auth', 'verified'])->group(function () {
    Route::resource('users', UserController::class);
});

require __DIR__.'/auth.php';

などとする。さらに

resources/views/users/index.blade.php
<x-app-layout>
<x-slot name="header">
    <h2 class="font-semibold text-xl text-gray-800 leading-tight">
        {{ __('Users') }}
    </h2>
</x-slot>
<div class="py-12">
    <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
        <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
            <div class="p-6 bg-white border-b border-gray-200">
                <table class="min-w-full divide-y divide-gray-200">
                    <thead>
                        <tr>
                            <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                                Name
                            </th>
                            <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                                Email
                            </th>
                            <th class="px-6 py-3 bg-gray-50"></th>
                        </tr>
                    </thead>
                    <tbody class="bg-white divide-y divide-gray-200">
                        @foreach ($users as $user)
                        <tr>
                            <td class="px-6 py-4 whitespace-no-wrap">
                            {{ $user->name }}
                            </td>
                            <td class="px-6 py-4 whitespace-no-wrap">
                            {{ $user->email }}
                            </td>
                            <td class="px-6 py-4 whitespace-no-wrap text-right text-sm leading-5 font-medium">
                                <a href="{{ route('users.show', $user->id) }}" class="text-indigo-600 hover:text-indigo-900">View</a>
                            </td>
                        </tr>
                        @endforeach
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</div>
</x-app-layout>

としてユーザー管理的なviewをでっち上げる。これでhttp://your-server/users にアクセスしてみると

こんな感じなるだろう。これに関しては今回作りこまないので、ここから先はどうでもいい

middlewareで制御する

このユーザー一覧的なインターフェースにアクセス可能なのはここまでの流れだと当然role=adminに限定したいわけだが、今はどのようなユーザーでもアクセスできる。


どのようなユーザーでもアクセス可能

/admin プレフィックスを付けてrole:adminのみ許可する

routes/web.php
Route::middleware(['auth', 'verified', 'role:admin'])->prefix('admin')->group(function () {
    Route::resource('users', UserController::class);
});

このようにすると、http://your-server/usersはhttp:/your-server/admin/usersに移動するのだが

middlewareを呼びこんでいないのでエラーとなる。これはlaravel11かそれ以前かで異なるのであるが、laravel11であれば

bootstrap/app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->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,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

のようにbootstrap/app.phpに追加。それ以前はapp/Http/Kernel.php。これでアクセス可能になるだろう

別のユーザーでアクセスしてみる

確認用のユーザーを作ってもいいんだけど、面倒なのでfactoryされたユーザー、どれでもいいので

> User::all('email')
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
= Illuminate\Database\Eloquent\Collection {#2310
    all: [
      App\Models\User {#6239
        email: "admin@example.com",
      },
      App\Models\User {#6240
        email: "crawford90@example.com",
      },
      App\Models\User {#6241
        email: "dach.osborne@example.net",
      },
      App\Models\User {#6242
        email: "felipa42@example.net",
      },
      App\Models\User {#6243
        email: "fzieme@example.org",
      },
      App\Models\User {#6244
        email: "heller.kyle@example.net",
      },
      App\Models\User {#6245
        email: "lubowitz.thalia@example.net",
      },
      App\Models\User {#6246
        email: "nschaefer@example.net",
      },
      App\Models\User {#6247
        email: "olga58@example.org",
      },
      App\Models\User {#6248
        email: "pamela.gulgowski@example.net",
      },
      App\Models\User {#6249
        email: "waters.marquise@example.net",
      },
    ],
  }

ランダムに作られているから適当にチョイスする。ここではwaters.marquise@example.netでログインして /admin/users にアクセスしてみると

403になる

Dashboardにリンクを作る

再びadmin@example.comでログインしよう

resources/views/layouts/navigation.blade.php
                     <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                         {{ __('Dashboard') }}
                     </x-nav-link>
+                    <x-nav-link :href="route('users.index')" :active="request()->routeIs('users.*')">
+                        {{ __('Users') }}
+                    </x-nav-link>
                 </div>
             </div>

@@ -70,6 +73,9 @@
             <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                 {{ __('Dashboard') }}
             </x-responsive-nav-link>
+            <x-responsive-nav-link :href="route('users.index')" :active="request()->routeIs('users.*')">
+                {{ __('Users') }}
+            </x-responsive-nav-link>
         </div>

         <!-- Responsive Settings Options -->

こんな感じでレイアウトにメニューを増やすと...

このように「Users」へのリンクが生える。しかしこれに関していえば、これももちろんadminロールのユーザーのみ表示したいわけで一般ユーザーからは消しさりたいわけだ。

これはbladeを使っている場合はとんでもなく簡単で@roleというディレクティブが生えている

resources/views/layouts/navigation.blade.php
                     <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                         {{ __('Dashboard') }}
                     </x-nav-link>
+                    @role('admin')
+                    <x-nav-link :href="route('users.index')" :active="request()->routeIs('users.*')">
+                        {{ __('Users') }}
+                    </x-nav-link>
+                    @endrole
                 </div>
             </div>

@@ -70,6 +75,11 @@
             <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                 {{ __('Dashboard') }}
             </x-responsive-nav-link>
+            @role('admin')
+            <x-responsive-nav-link :href="route('users.index')" :active="request()->routeIs('users.*')">
+                {{ __('Users') }}
+            </x-responsive-nav-link>
+            @endrole
         </div>

         <!-- Responsive Settings Options -->

これで以下のように一般ユーザーでログインするとリンクが消滅するはずだ


Usersへのリンクが消滅した

これはbladeの機能なのでInertia.jsを用いる場合は多少工夫が必要である。とくにlayoutなどの部分に関連する機能はapp/Http/Middleware/HandleInertiaRequests.phpあたりで渡してあげないといけないだろう。

以上、簡単な解説

このようにspatie/laravel-permissionに関しては「文字ベース」でロールや権限を定義するため自由自在である反面自由すぎる所もあるのでしっかりと設計する必要がある。また今回はロールしか定義していないが、「記事を編集できる/できない」のようなパーミッションベースでの指定もできるから、興味があればオフィシャルドキュメントをあたっていただきたい

おまけ、複雑な例

database/seeders/DatabaseSeeder.php
    public function run(): void
    {
        // ロールを3つ作成
        $roles = [
            'admin' => Role::firstOrCreate(['name' => 'admin']),
            'editor' => Role::firstOrCreate(['name' => 'editor']),
            'user' => Role::firstOrCreate(['name' => 'user']),
        ];

        // `admin` ユーザーを作成し、adminロールを付与
        $adminUser = User::factory()->create([
            'name' => 'Admin User',
            'email' => 'admin@example.com',
        ]);
        $adminUser->assignRole($roles['admin']);

        // `special` ユーザーを作成し、すべてのロールを付与
        $specialUser = User::factory()->create([
            'name' => 'Special User',
            'email' => 'special@example.com',
        ]);
        $specialUser->syncRoles($roles); // 全ロールを適用

        // 10人の一般ユーザーを作成し、ランダムなロールを割り当てる
        User::factory(10)->create()->each(function ($user) use ($roles) {
            // ランダムに1つのロールを割り当てる
            $randomRole = collect($roles)->random();
            $user->assignRole($randomRole);
        });
    }

このように

  • admin
  • editor
  • user

という3つのロールを当て、ランダムに割り当てる。

User::with('roles')->get()->map(function ($user) {
    return [
        'email' => $user->email,
        'role' => $user->roles->pluck('name')->implode(', '),
    ];
});

このようにすると


こうなる。

specialユーザーに見られるように、ロールを複数持つ事も可能なので、この辺こそが設計力を試される所だろう。

また、どういうロールがシステムに登録されているかは

Spatie\Permission\Models\Role::all()->pluck('name');

とかでわかる。

この辺りの複雑な取得は以下を参照されたい

https://spatie.be/docs/laravel-permission/v6/basic-usage/basic-usage#content-eloquent-calls

Discussion