認可とアクセス制御

前回終了時のソースコード

Laravelを使ったアプリケーション開発のソースコードは以下のようにしてダウンロードすることができます。

$ git clone https://kiku3.tsbio.info/git/study-laravel.git study_laravel

この章開始時点のソースコードは chapt7 ブランチにあります。

$ git switch chapt7

ソースコードを自分で書いていく場合は、自分用のブランチをつくるとよいです。

$ git switch -c my7 chapt7

Laravelのコードはsrcディレクトリからの相対パスになっています。

エラーへの対処

  • Laravelのパッケージがない

    composer install を実行してください。開発段階に応じて不足するパッケージがあるときは、再度 composer install が必要な場合があります。

  • ディレクトリのパーミッションが正しくない

    tempnam()が実行できないなどのエラーが出るときは、storageとbootstrap/cacheディレクトリの所有者とパーミッションを確認してください(第1章を参照)

  • Vite manifest not found

    開発中なら npm run dev、本番環境なら npm run buildを実行してください。

  • npm run devが動かない

    npm install を実行してください。

認可 (Authorization) とアクセス制御

前章で、ユーザーの認証 (authentication) ができるようになりました。 次はどのユーザーは何ができるのかを認可 (authorization) するしくみをつくります。 Laravelでは役割 (role) と権限 (permission) でユーザーのアクセスを制御します。

権限はアプリケーションの機能(主にデータベースのテーブルに対する操作)に対して設定します。 oligoテーブルに対して、selectできる権限、insertできる権限、というよう感じです。 例えばselectであれば、Oligo::all() という形でプログラムの中に現れるので、この部分を実行できるかどうかというように設定します。

oligoテーブルに対してselectできる権限をoligo.readと名付けたとします。 この名称自体がなにかの効果を持つものではないことに注意してください。 権限名をプログラムに関連付けるのはプログラマーの責任です。 Oligo::all()があるのはOligoControllerのindexメソッドです。 oligo.readの権限をもつユーザーにのみ Oligo::all() を実行できるようにするために、if($user->can('oligo.read')) Oligo::all(); というようにコードを書きます。 認可は、ルーティングでコントローラーのメソッドへのアクセスを制御する方法メソッド内でコードの実行を制御する方法ビューで表示を制御する方法、などで実現できます。

ユーザーごとに権限を設定していくのは大変なので、いくつかの権限をセットにして役割 (role) とします。 この役割をユーザーにわりあてることで、ユーザーと権限が結び付けられ、権限の有無を判定できるようにします。

spatie/laravel-permissionというパッケージは permissions、rolesなどのテーブルをつくり、usersと結合できるようにします。

spatie/laravel-permissionをインストールする

spatie/laravel-permissionをインストールします。 続いてテーブルを作成します (migrate)。

# composer require spatie/laravel-permission
# artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
# ./artizan migrate

ユーザーモデルの修正

Spatieが提供する権限の確認のためのメソッドをModel/Userに追加します。

app/Models/User.php
<?php
 
namespace App\Models;
 
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;  // 認可に関するメソッドが使えるようになる
 
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable, HasRoles; // HasRolesを追加
 
    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

テストデータを登録

tinkerで試します。

# ./artisan tinker
> use Spatie\Permission\Models\Permission
> $ow = Permission::create(['name' => 'oligo.write'])  # oligo.writeという権限を追加
= Spatie\Permission\Models\Permission {#294
  guard_name: "web",
  name: "oligo.write",
  updated_at: "2026-08-28 01:48:37",
  created_at: "2026-08-28 01:48:37",
  id: 25,
}
> $or = Permission::create(['name' => 'oligo.read']) # oligo.readという権限を追加
= Spatie\Permission\Models\Permission {#7546
  guard_name: "web",
  name: "oligo.read",
  updated_at: "2026-08-28 01:48:48",
  created_at: "2026-08-28 01:48:48",
  id: 26,
}
> use Spatie\Permission\Models\Role
> $role = Role::create(['name' => 'oligo.user']) # oligo.userという役割を追加
= Spatie\Permission\Models\Role {#8044
  guard_name: "web",
  name: "oligo.user",
  updated_at: "2026-08-28 01:49:36",
  created_at: "2026-08-28 01:49:36",
  id: 2,
}
> $role->syncPermissions($or)  # oligo.userにoligo.readを付与
= Spatie\Permission\Models\Role {#8044
  guard_name: "web",
  name: "oligo.user",
  updated_at: "2026-08-28 01:49:36",
  created_at: "2026-08-28 01:49:36",
  id: 2,
}
> use App\Models\User
> $user = User::first()
= App\Models\User {#8891
  id: 2,
  name: "test user",
  email: "test@example.com",
  email_verified_at: null,
  #password: "\$2yxxx",
  #remember_token: null,
  created_at: "2026-08-26 02:14:30",
  updated_at: "2026-08-26 02:14:30",
}
> $user->syncRoles($role)  # test userにoligo.userという役割を付与
> $user->can('oligo.read') # test userはoligo.read権限を持つか? -> true
= true
> $user->can('oligo.write') # test userはoligo.write権限を持つか? -> false
= false

アクセス制御

ルーティング段階でのアクセス制御

ミドルウェアに can:oligo.read を追加する。 oligo.writeにするとアクセスできなくなる。

routes/oligo.php
<?php
 
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController;
 
// オリゴDNAのデータベース
Route::middleware(['auth', 'verified', 'auto.login:test user', 'can:oligo.read'])
    /* canはAuthorizeミドルウェアの別名として登録されている
     * oligo.readをoligo.writeに変えると /oligo にアクセスできなくなる
     */
    ->prefix('oligo')
    ->as('web.oligo.')
    ->group(function (){
        Route::get('/', [OligoController::class, 'index'])->name('index');
        Route::get('search', [OligoController::class, 'search'])->name('search');
        Route::get('create', [OligoController::class, 'create'])->name('create');
        Route::post('/', [OligoController::class, 'store'])->name('store');
 
        // 編集フォームとデータ更新
        Route::get('edit', [OligoController::class, 'edit'])->name('edit');
        Route::put('/{item}', [OligoController::class, 'update'])->name('update');
    });

データ取得段階でのアクセス制御

RequestからUserを取得して、canを使う。 または Gateのauthorizeメソッドを使用する。

app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Models\Oligo;
use App\Services\OligoService;
use App\Http\Requests\OligoRequest;
use App\Http\Requests\OligoSearchRequest;
use App\Http\Requests\OligosRequest;
 
class OligoController
{
    public function __construct(
        private OligoService $service
    ) {
    }
 
    public function index(){
 
        /*
         * oligo.read権限が付与されていればデータを取得できる。
         */
        if(request()->user()->can('oligo.read')){
            $items = Oligo::all();
        }else{
            $items = [];
        }
 
        return view('oligo.index', compact('items'))
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligosRequest $request){
 
        /*
         * oligo.write権限がなければHTTP レスポンス 403を出して終了する
         */
        \Illuminate\Support\Facades\Gate::authorize('oligo.write');
        
        try{
            $oligos = $request->validOligos;
            $data = $this->service->create($oligos);
 
            return response()->json(array_merge($data, [
                'status' => 'success', 
                'input' => $oligos,
            ]), 200);
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
 
    // 検索
    public function search(OligoSearchRequest $request)
    {
        try{
            $query = $request->validated();
	    $query['use_regex'] = $query['use_regex'] ?? false;
            return response()->json([
                'status' => 'success',
                'data' => $this->service->search($query),
            ], 200);
 
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
 
    public function edit(){
        return view('oligo.edit')
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-edit")
        ;
    }
 
    public function update(OligoRequest $request, Oligo $item){
        try{
            $item->update($request->validated());
 
            return response()->json([
                'status' => 'success', 
                'item' => $item,
            ], 200);
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
}

ビューでのアクセス制御

resources/views/oligo/module.blade.php
@extends('layouts.app')
 
@section('module')
  @php($module = 'oligo')  {{-- モジュール名を変数に設定 --}}
  <x-module id='{{ $module }}'>
    <!-- モジュールのメニュー -->
    <nav>
      <ul class='feature-tabs' role='tablist'>
        @foreach([
          {{-- フィーチャーごとの設定 --}}
          'index' => ['label' => '一覧', 'permission' => 'read'],
          'create' => ['label' => '追加', 'permission' => 'write'],
          'edit' => ['label' => '編集', 'permission' => 'write'],
        ] as $page => $conf)
          {{-- 権限の有無で項目を表示・非表示にする --}}
          @can("$module.${conf['permission']}")
            <li class='feature-tab'
              :class="{
                'bg-white': activeFeature === '{{ $module }}-{{ $page }}',
                'bg-gray-400 hover:bg-gray-200': activeFeature !== '{{ $module }}-{{ $page }}'
              }"
            >
              <a href='{{ route("web.{$module}.{$page}") }}' id='{{ $module }}-{{ $page }}-tab' role='tab'
                @click.prevent="selectTab(event)"
              >
                {{ $conf['label'] }}
              </a>
            </li>
          @endcan
        @endforeach
        <li class='feature-tab bg-gray-400' x-text="activeFeature">
        </li>
      </ul>
    </nav>
    
    <div class='features'
      {{-- oligoモジュール全体で使用できるようにここにx-dataを定義する --}}
      x-data="{
        items: [], // テーブルから抽出されたデータ。今後更新や削除の機能でも使用できるように、ここ(#features)に定義する。
        query: {}, // 検索の条件。x-modelとして設定することで入力フォームの値がそのままJavascriptで使用できる
 
        // 検索 fetchは非同期(async)に実行されるので、それにそろえる
        async search(){
          const parameter = new URLSearchParams(this.query);  // 同じx-data内なので this.queryとなる。これをGETパラメーター(?key1=value1&key2=value2)に変換する
          console.log('parameter:', parameter.toString());  // 動作確認
          const options = {
            method: 'GET',
            headers: {
              'Accept': 'application/json',  // 返り値の設定。Laravelが判断するのに使う。
              'Content-Type': 'application/json',
            },
          };
 
          return fetch('{{ route("web.{$module}.search") }}?' + parameter.toString(), options)
            .then(response => response.json())
            .then(response => {
              console.log('search done', response);  // 動作確認
              return this.append(response.data.items);  // itemsを置き換えるappendを呼ぶ
            })
            .catch(error => {debug(error)})
          ;
        },
 
        // 検索結果をitemsに反映させる。今後、新しい検索結果で置き換えるのではなく、追加するように修正していくので、appendとしてある。
        append(items){
          console.log('append:', items);
          this.items = items;
        },
 
        // チェックがいれられたものだけを取得する。
        get checked(){
          return this.items.filter(item => item.checked);
        },
        get hasChecked(){
          return this.checked.length > 0;
        }
      }"
    >
      @yield('feature')
    </div>
  </x-module>
@endsection

テストデータの投入

システムの動作に必要なデータや、テスト用のデータをいれることをseeder (種をまく人) といいます。 今回はテスト用のユーザーとロール、パーミッションのデータを投入します。

パーミッションはプログラムの中に書き込んであるので、テーブルにいれてロールに付与できるようにしておかないと意味がありません。 本番環境にも必要なので、新しい機能を追加するたびにseederに設定するとよいです。 今のところ、DatabaseSeederにまとめてありますが、PermissionSeederなどに分離するとよいです。

ユーザーとロールはテスト用に必要ですが、本番環境では異なる名前のほうがよい場合があります。 特にユーザーはこのままいれないようにしてください。

database/seeders/DatabaseSeeder.php
<?php
 
namespace Database\Seeders;
 
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
 
class DatabaseSeeder extends Seeder
{
    use WithoutModelEvents;
 
    /**
     * Seed the application's database.
     * php artisan db:seed
     */
    public function run(): void
    {
        // Permissionのキャッシュをクリア(シーダー実行時の必須処理)
        app()[PermissionRegistrar::class]->forgetCachedPermissions();
 
        $permissions = [
            'material@web' => [
                'oligo.read', 'oligo.write',
                'seed.read', 'seed.write',
                'plant.read', 'plant.write'
            ],
            'system@admin' => [
                'user.read', 'user.write',
                'role.read', 'role.write',
                'permission.read', 'permission.write'
            ]
        ];
 
        $roles = [
            'material user@web' => $permissions['material@web'],
            'system admin@admin' => $permissions['system@admin']
        ];
 
        $users = [
            [
                'name' => 'test user',
                'email' => 'test@example.com',
                'password' => 'secretuser',
                'roles' => ['material user'],
            ],
            [
                'name' => 'test admin',
                'email' => 'admin@example.com',
                'password' => 'secretadmin',
                'roles' => ['system admin'],
            ]
        ];
 
        // 権限を設定
        foreach($permissions as $perm_group => $perms){
            [$label, $guard] = explode("@", $perm_group . '@');
            foreach($perms as $perm){
                /* findOrCreateはPermissionとRoleに固有のメソッドで、vendor/spatie/laravel-permission/src/Models/Permission.phpで次のように定義されている。
                 * public static function findOrCreate(BackedEnum|string $name, ?string $guardName = null):
                 * 1番目の引数はidもしくはnameで、一致するものがないときはcreateされる。
                 * 2番目の引数はguard_nameで、nullのときはconfig('auth.defaults.guard')が使われる。
                 */
                Permission::findOrCreate($perm, $guard);
            }
        }
 
        // 役割を設定
        foreach($roles as $role_key => $perms){
            [$role_name, $guard] = explode("@", $role_key . '@');
            $role = Role::findOrCreate($role_name, $guard);
            $role->syncPermissions($perms); // 権限もidまたはnameで指定できる
        }
 
        // ユーザーを作成
        foreach($users as $userData){
            $roleNames = $userData['roles'];
            unset($userData['roles']);
 
            /* firstOrCreateで、emailが一致するユーザーがいればそれを返し、
             * なければ、2番目の引数でユーザーを作成する
             */
            $user = User::firstOrCreate(['email' => $userData['email']], $userData);
 
            /* syncRolesはロール名で設定できるので、Role::whereInで取得する必要はないが、
             * guardがdefaultと違うときにエラーになるので Roleモデルをにしている
             */
            $roles = Role::whereIn('name', $roleNames)->get();
            $user->syncRoles($roles);
        }
    }
}

実行

$ ./artisan db:seed

権限に関するインターフェースの作成

以下は第7章、第8章の後に書いています。

ルートの設定

routes/user.php をコピーして role.phppermission.php を作成し、admin.phpで読み込みます。

コントローラーの作成

同じく app/Http/Controllers/UserController.php をコピーして RoleController.phpPermissionController.php を作成します。

role と permission をsync するためのupdateメソッドがオーバーライドしてあります。

app/Http/Controllers/RoleController.php
<?php
 
namespace App\Http\Controllers;
 
use Spatie\Permission\Models\Role;
use App\Services\RoleService;
use App\Http\Requests\RoleRequest;
use App\Http\Requests\RoleSearchRequest;
use App\Http\Requests\RolesRequest;
use App\Http\Requests\RolesUpdateRequest;
 
class RoleController extends MaterialController
{
    protected string $singleRequest = RoleRequest::class;
    protected string $multiRequest = RolesRequest::class;
    protected string $queryRequest = RoleSearchRequest::class;
 
    public function __construct(
        protected RoleService $service,
        protected Role $model
    ) {}
 
    // GETリクエスト(ビューを返す)
    public function index(){
        return $this->respond(array_merge(
            $this->service->search([]),
            ['roles' => $this->model->orderBy('name')->get()]
        ));
    }
 
    // roleの追加
    public function store(){
        try{
            $items = app($this->multiRequest)->valids;
            $this->service->create($items);
            return redirect(module_route('role'));
        }catch(\Throwable $e){
            return $this->json(['message' => $e->getMessage()], 400);
        }
    }
 
    // roleの修正
    public function update($id = null){
        try{
            $data = app(RolesUpdateRequest::class)->validated();
            return $this->service->syncUserRoles($data['users']);
        }catch(\Throwable $e){
            return $this->json(['message' => $e->getMessage()], 400);
        }
    }
}

リクエストの作成

RoleRequestとRolesRequestはOligoのコピーそのまま。

検索はusersテーブルとrolesテーブルを結合している(User::with('roles’))ので、検索の定義を二つのTableDefinitionからとってくるようにしている。 実際にはuser.nameとrole.nameだけなので、このように複雑にする必要はないが、今後同じような場面で使えるかもしれない。

app/Http/Requests/RoleSearchRequest.php
<?php
 
namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
use App\TableDefinitions\RoleTableDefinition;
use App\TableDefinitions\UserTableDefinition;
 
class RoleSearchRequest extends FormRequest
{
    public function rules(RoleTableDefinition $table): array
    {
        $merges_rules = [];
        foreach([
            'user' => app(UserTableDefinition::class)->searchRules(),
            'role' => $table->searchRules()
        ] as $table => $rules){
            foreach($rules as $key => $value){
                $merges_rules["${table}_$key"] = $value;
            }
        }
        return $merges_rules;
    }
}

複数ユーザーのロールをまとめて変更するためのRolesUpdateRequest。

app/Http/Requests/RolesUpdateRequest.php
<?php
 
namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
 
class RolesUpdateRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'users' => ['required', 'array'],
            'users.*.user_id' => ['required', 'integer', 'exists:users,id'],
            'users.*.roles' => ['present', 'array'],
            'users.*.roles.*' => ['integer', 'exists:roles,id']
        ];
    }
}

Permissionのほうも基本的に同じ

サービスの作成

roleはuserテーブルと、permissionはroleテーブルとリレーションをとるので、searchメソッドが少し変わってきます。

結局usersのnameとrolesのnameしか検索対象にしていないので、ここまで複雑にする必要もないかと思います。 素直に$builder->where('name’, ) /* users用の検索 /->whereHas('roles’, fn($q) => $q->where('name’, )) / roles用の検索 */ と書くほうがわかりやすくてよいです。

syncUserRolesは複数のユーザーのロールを設定(sync)しています。 syncはいったん全削除して、追加するということをしているので、SQLがたくさん発行されています。 そのため、トランザクションを設定しています。

最後の返り値を $this->search() とできるとかっこいいですね。

app/Services/RoleService.php
<?php
 
namespace App\Services;
 
use App\Models\User;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\DB;
use App\TableDefinitions\RoleTableDefinition;
use App\TableDefinitions\UserTableDefinition;
use App\TableSearch\SearchFactory;
 
class RoleService extends MaterialService
{
    public function __construct(
        protected Role $model,
        public RoleTableDefinition $table,
        public UserTableDefinition $user_table,
        protected SearchFactory $searchFactory
    ){}
 
    public function search(array $query)
    {
    	$builder = User::with('roles');
 
        foreach ($query as $column => $value) {
 
            if (is_string($value) && trim($value) === ''){
                continue;
            }
 
            // テーブルの定義から、列の情報を取得する
            switch($column){
            case 'user_name':
                $definition = $this->user_table->column('name');
                break;
            case 'role_name':
                $definition = $this->table->column('name');
                break;
            default:
                continue;
            }
 
            if (!$definition) {
                continue;
            }
 
            // 検索の種類を取得する
            $type = $definition['search']['type'] ?? null;
            if (!$type) {
                continue;
            }
 
            try{
                $searcher = $this->searchFactory->make($type);
                switch($column){
                case 'user_name':
                    $searcher->apply($builder, 'name', $value);
                    break;
                case 'role_name':
                    $builder->whereHas('roles',
                        fn($q) => $searcher->apply($q, 'name', $value)
                    );
                    break;
                }
            }catch(\Throwable $e){
                continue;
            }
        }
 
        return [
            'items' => $builder->orderBy('created_at', 'desc')->get(),
            'sql' => $builder->toSql(),
            'query' => $query
        ];
    }
 
    public function syncUserRoles(array $assignments)
    {
        #var_dump($assignments);
        $user_ids = DB::transaction(function () use ($assignments){
            $user_ids = [];
            foreach($assignments as $user_roles){
                $user = User::findOrFail($user_roles['user_id']);
                $roles = Role::whereIn('id', $user_roles['roles'])->get();
                $user->syncRoles($roles);
                $user_ids[] = $user->id;
            }
            return $user_ids;
        });
    	return [
            'users' => User::with('roles')
                ->whereIn('id', $user_ids)
                ->orderBy('created_at', 'desc')
                ->get()
        ];
    }
}

似たようなものですが、PermissionServiceはこのようにしました。

app/Services/PermissionService.php
<?php
 
namespace App\Services;
 
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\DB;
use App\TableDefinitions\PermissionTableDefinition;
use App\TableDefinitions\RoleTableDefinition;
use App\TableSearch\SearchFactory;
 
class PermissionService extends MaterialService
{
    public function __construct(
        protected Permission $model,
        public PermissionTableDefinition $table,
        public RoleTableDefinition $role_table,
        protected SearchFactory $searchFactory
    ){}
 
    public function search(array $query)
    {
    	$builder = Role::with(['users', 'permissions']);
 
        foreach ($query as $column => $value) {
 
            if (is_string($value) && trim($value) === ''){
                continue;
            }
 
            // テーブルの定義から、列の情報を取得する
            switch($column){
            case 'permission_name':
                $definition = $this->table->column('name');
                break;
            default:
                $definition = $this->role_table->column($column);
                break;
            }
 
            if (!$definition) {
                continue;
            }
 
            // 検索の種類を取得する
            $type = $definition['search']['type'] ?? null;
            if (!$type) {
                continue;
            }
 
            // 検索の種類に応じて、検索を適用する
            try{
                $searcher = $this->searchFactory->make($type);
                switch($column){
                case 'permission_name':
                    $builder->whereHas('permissions',
                        fn($q) => $searcher->apply($q, 'name', $value)
                    );
                    break;
 
                default:
                    $searcher->apply($builder, $column, $value);
                    break;
                }
 
            }catch(\Throwable $e){
                continue;
            }
        }
 
        return [
            'items' => $builder->orderBy('created_at', 'desc')->get(),
            'sql' => $builder->toSql(),
            'query' => $query
        ];
    }
 
    public function syncRolePermissions(array $assignments)
    {
        $role_ids = DB::transaction(function () use ($assignments){
            $role_ids = [];
            foreach($assignments as $role_permissions){
                $role = Role::findOrFail($role_permissions['role_id']);
                $permissions = Permission::whereIn('id', $role_permissions['permissions'])->get();
                $res = $role->syncPermissions($permissions);
                $role_ids[] = $role->id;
            }
            return $role_ids;
        });
    	return [
            'roles' => Role::with(['users', 'permissions'])
                ->whereIn('id', $role_ids)
                ->orderBy('created_at', 'desc')
                ->get()
        ];
    }
}
 
/*
  use App\Services\PermissionService;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
  $service = app(PermissionService::class);
  $service->syncRolePermissions([["role_id" => 16,"permissions" => [28, 27]]]);
 
  Role::with(['permissions'])->whereIn('id', [16])->get()->first()->permissions
 
        ],
    ],
]);
 
Role::find(16);
Permission::whereIn('id', [28, 27])->get();
 

 
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
 
*/

ロールのビューの作成

ユーザーとロールの関係を userRolesMapというオブジェクトにいれる。 これを操作するための関数といっしょに x-dataに登録している。

resources/views/role/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='{{ $module_id }}'>
    <div class='features'
      x-data="Material.makeModulePanel({
        query_template: @js([
          'user_name' => ['value' => '', 'mode' => 'regex'],
          'role_name' => ''
        ]),
        url: {
          search: '{{ module_route($module_id, "search") }}',
          store:  '{{ module_route($module_id, "store") }}',
          update: '{{ module_route($module_id, "index") }}',
        },
 
        roles: [],  // 利用可能な全ロール
        userRolesMap: {}, // 編集用のデータ { user_id: [roles]} の連想配列
 
        groupedRoles(){
          return Object.groupBy(
            this.roles, // 空文字列を除外
            role => role.guard_name
          );
        },
 
        updateRole(){
          console.log('post', JSON.stringify(this.checkedItems.map(
                item => ({
                  user_id: Number(item.id),
                  roles: this.userRolesMap[item.id] || []
                })
              )));
          return api.put(
            this.url.update,
            {
              users: this.checkedItems.map(
                item => ({
                  user_id: Number(item.id),
                  roles: this.userRolesMap[item.id] || []
                })
              ),
            }
          );
        },
 
        // --- ロール一括操作の判定ロジック ---
        havingCount(roleId) {
          if (this.checkedItems.length === 0) return 0;
//          this.checkedItems.forEach(item => console.log('havingCount:', this.userRolesMap, this.userRolesMap[item.id], roleId));
          return this.checkedItems.filter(
            item => (this.userRolesMap[item.id] || [])
              .includes(roleId)
          ).length;
        },
        isAllHas(roleId) {
            return this.checkedItems.length > 0 && this.havingCount(roleId) 
              === this.checkedItems.length;
        },
        isAnyHas(roleId) {
            const count = this.havingCount(roleId);
            return count > 0 && count < this.checkedItems.length;
        },
        
        // どうするのがよいか決めかねている
        remove(roleId){
          api.post('{{ module_route($module_id) }}', {ids:[roleId]});
        },
 
        // --- ロールの切り替え処理 ---
        toggleRole(roleId, isChecked) {
            this.checkedItems.forEach(item => {
                let currentRoles = this.userRolesMap[item.id] || [];
                if (isChecked) {
                    // 重複を防いで追加
                    if (!currentRoles.includes(roleId)) {
                        this.userRolesMap[item.id] = [...currentRoles, roleId];
                    }
                } else {
                    // 削除
                    this.userRolesMap[item.id] = currentRoles.filter(id => id !== roleId);
                }
            });
        },
        init() {
          this.resetQuery();
 
          this.$watch('items', (newItems) => {
            console.log('items change was detected');
            newItems.forEach(item => {
              if(! this.userRolesMap[item.id]){
                this.userRolesMap[item.id]
                  = (item.roles || []).map(role => role.id);
              }
            });
          })
        }
      })" {{-- Material.makeModulePanelを閉じる。x-dataを閉じる --}}
    >
      @yield('feature')
    </div>
  </x-module>
@endsection

一画面で、ユーザーとロールを設定できるようにしている。 左にユーザー一覧、右にロール一覧を配置し、チェックで関係をつくる。

ロールの追加は画面の書き換えるところが多いので、POSTして、リダイレクトで元の画面を書き換えるようにしている。

resources/views/role/index.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}' class='feature-index'>
    <div class='flex item-start gap-6'
      x-init="
        roles = @js($roles);
        items = @js($items);
        // デバッグ用items.forEach(item => item.checked = true);
      "
    >
      <div id='left_column' class=''>
        <h3>ユーザ選択</h3>
        <table class='material-list'>
          <thead>
            {{-- thead 1行目 見出し --}}
            <tr class='query'>
              <th></th>
              <th class='w-36'>ユーザー名</th>
              <th class='w-72'>変更前役割</th>
              <th class='w-72'>変更後役割</th>
            </tr>
 
            {{-- thead 2行目 フォーム --}}
            <tr @input.debounce.500="search()" class="query border-b-2 align-text-top">
              <th>検索</th>
 
              {{-- TableDefinitionクラスからindexのビューで使用するカラムを取得する --}}
                <td class="name">
                  <input x-model="query.user_name.value">
                  <label>
                    <input type="checkbox" x-model="query.user_name.mode" value=1 title="正規表現">
                    正規表現
                  </label>
                </td>
                <td class="role">
                  <input x-model="query.role_name" placeholder="role_name">
                </td>
            </tr>
 
            {{-- thead 3行目 ボタン --}}
            <tr>
              <td colspan="9" class='px-2 py-2'>
                <button @click="search()">  {{-- @inputで自動実行されるが、ボタンも加えておく --}}
                  検索
                  <x-ajax-state var='query' />
                </button>
 
                {{-- チェックボタンで選択・非選択をする --}}
                <button @click.prevent="
                  items.forEach(item => item.checked = !item.checked);
                  console.log('check reverse', items.length, checkedItems.length, hasChecked);
                ">
                  チェックを反転
                </button>
 
                <button @click="resetQuery();">条件クリア</button>
              </td>
            </tr>
 
            {{-- thead 4行目 検索結果 --}}
            <tr class='border-b-2'>
              <td x-show="items.length == 0" colspan="9">
                条件に合うものは見つかりませんでした。
              </td>
              <td x-show="items.length > 0" colspan="9">
                検索結果: <span x-text="items.length"></span>件
                (☑: <span x-text="checkedItems.length"></span>件)
 
                {{-- 以下は開発用につくってあるので、本番では削除したほうがよいかもしれない --}}
                query:
                <template x-for="key in Object.keys(query)" :key="key">
                  <template x-if="query[key] !== undefined && query[key] !== ''">
                    <span x-text="`${key}=`+JSON.stringify(query[key])"></span>
                  </template>
                </template>
              </td>
            </tr>
          </thead>
 
          <tbody>
            {{-- 検索結果 --}}
            {{-- Alpine.js (x-for)によって、items配列のそれぞれの要素をitemに取り出し、template要素を増やす --}}
            <template x-for="item in items" :key="item.id">
              <tr @click="item.checked = !item.checked">
                <td><input x-model="item.checked" @click.stop type='checkbox'></td>
                <td x-text="item.name" class="name"></td>
                <!-- 変更前役割 -->
                <td>
                  <template x-for="role in item.roles" :key="role.id">
                    <span x-text="role.name"
                      style="display:inline-block; background:#eee; padding:2px 6px; margin-right:4px; border-radius:4px;"
                    ></span>
                  </template>
                </td>
                <!-- 変更後役割 -->
                <td>
                  <template x-for="roleId in (userRolesMap[item.id] || [])" :key="roleId">
                    <span x-text="roles.find(r => r.id === roleId)?.name"
                      style="display:inline-block; background:#eee; padding:2px 6px; margin-right:4px; border-radius:4px;"
                    ></span>
                  </template>
                </td>
              </tr>
            </template>
          </tbody>
        </table>
 
        <button type='submit' title="ユーザーの役割を更新します"
          :disabled="checkedItems.length < 1"
          @click="updateRole().then(data => {
            console.log('after updateRole', data.users);
            const ids = new Set(data.users.map(user => Number(user.id)));
            items = items.filter(item => !ids.has(item.id));
            return append(data.users.map(user => {
              user.checked = true;
              return user;
            }));
          })"
        >
          更新
        </button>
      </div>  <!-- end of left_colum -->
 
      <div id='right_column' class='w-[30%]'>
        <!-- roleの一覧 -->
        <h3>作成済み役割</h3>
        <template x-for="(subroles, group_name) in groupedRoles()" :key="group_name">
          <fieldset class="border border-user p-4 rounded mb-4">
            <legend class="px-2 font-bold">
              <label>
                <input type='checkbox' checked='' class='accent-user'>
                Guard: <span x-text="group_name"></span>
              </label>
            </legend>
            <div class='flex flex-wrap gap-4 mt-2'>
              <template x-for="role in subroles" :key="role.id">
                <label class="inline-flex items-center gap-1">
                  <input type='checkbox'
                    :checked="isAllHas(role.id)"
                    x-effect="$el.indeterminate = isAnyHas(role.id)"
                    @click="toggleRole(role.id, ! isAllHas(role.id))"
                  />
                  <span x-text="role.name" class="name"></span>
                  @if(1)  {{-- 動作確認用 --}}
                    [<span x-text="havingCount(role.id)" ></span>
                    <span x-text="isAnyHas(role.id)" ></span>
                    <span x-text="isAllHas(role.id)" ></span>]
                  @endif
                </label>
              </template>
            </div>
          </fieldset>
        </template>
 
        <!-- roleの追加用フォーム -->
        <form x-data="{entries:'role1, role2', guard_name:'web'}"
          @submit.prevent="console.log('Store roles:', entries);
            htmx.ajax('POST', url.store, {
              source: $el,
              target:'#{{ $feature_id }}',
              swap: 'outerHTML',
              headers: {
                'HX-Target': 'feature-' + '{{ $feature_id }}',
              }
            });
          "
        >
          @csrf
          <fieldset class="border border-user p-4 rounded mb-4">
            <h4 class='mb-2'>Guard: 
              <x-select-guard x-model="guard_name" class="border-user"/>
            </h4>
            <legend class="px-2 font-bold">役割を追加</legend>
            <input type='input' x-model='entries' class='border-1 border-user p-1'>
            <button>追加</button>
 
            {{-- htmxでフォームを送信する --}}
            <template 
              x-for="(entry, index) in entries.split(',').map(s => s.trim())"
              :key="index"
            >
              <span>
                <input type="hidden" :name="`items[${index}][name]`" :value="entry">
                <input type="hidden" :name="`items[${index}][guard_name]`" :value="guard_name">
              </span>
            </template>
          </fieldset>
        </form>
      </div>  <!-- end of right_colum role5,role6,role7-->
    </div>
  </x-feature>
@endsection
@props(['items' => []])

パーミッションのビューの作成

基本的にロールと同じ。パーミッションは名前でグループ化して、一括チェックができたり、read < write の包含関係を自動化したいところだが、まだできていない。

resources/views/permission/index.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}' class='feature-index'>
    <h4>Guard: 
      <x-select-guard x-model="query.guard_name" @change="search()"
        class="border-user"
      />
    </h4>
 
    <div class='flex item-start gap-6'
      x-init="
        permissions = @js($permissions);
        console.log(@js($permissions));
        items = @js($items);
        // デバッグ用
        search().then(() => { items.map(item => item.checked = true);});
      "
    >
      <div id='left_column'>
        <h3>役割選択</h3>
        <table
          class='material-list'>
          <thead>
            {{-- thead 1行目 見出し --}}
            <tr class='query'>
              <th></th>
              <th class=''>役割</th>
              <th class=''>変更前権限</th>
              <th class=''>変更後権限</th>
            </tr>
 
            {{-- thead 2行目 フォーム --}}
            <tr @input.debounce.500="search()" class="query border-b-2 align-text-top">
              <th>検索</th>
 
              {{-- TableDefinitionクラスからindexのビューで使用するカラムを取得する --}}
                <td class="name">
                  <input x-model="query.name"
                      placeholder="admin"
                  >
                </td>
                <td class="role">
                  <input x-model="query.permission_name"
                    placeholder="oligo."
                  >
                </td>
            </tr>
 
            {{-- thead 3行目 ボタン --}}
            <tr>
              <td colspan="9" class='px-2 py-2'>
                <button @click="search()">  {{-- @inputで自動実行されるが、ボタンも加えておく --}}
                  検索
                  <x-ajax-state var='query' />
                </button>
 
                {{-- チェックボタンで選択・非選択をする --}}
                <button @click.prevent="
                  items.forEach(item => item.checked = !item.checked);
                  console.log('check reverse', items.length, checkedItems.length, hasChecked);
                ">
                  チェックを反転
                </button>
 
                {{-- チェックボタンで選択・非選択をする --}}
                <button @click.prevent="
                  items.forEach(item => item.checked = false);
                ">
                  全てチェックをはずす
                </button>
                
                <button @click="resetQuery();">条件クリア</button>
              </td>
            </tr>
 
            {{-- thead 4行目 検索結果 --}}
            <tr class='border-b-2'>
              <td x-show="items.length == 0" colspan="9">
                条件に合うものは見つかりませんでした。
              </td>
              <td x-show="items.length > 0" colspan="9">
                検索結果: <span x-text="items.length"></span>件
                (☑: <span x-text="checkedItems.length"></span>件)
 
                {{-- 以下は開発用につくってあるので、本番では削除したほうがよいかもしれない --}}
                query:
                <template x-for="key in Object.keys(query)" :key="key">
                  <template x-if="query[key] !== undefined && query[key] !== ''">
                    <span x-text="key"></span>=<span x-text="query[key]"></span>
                  </template>
                </template>
              </td>
            </tr>
          </thead>
 
          <tbody>
            {{-- 検索結果 --}}
            {{-- Alpine.js (x-for)によって、items配列のそれぞれの要素をitemに取り出し、template要素を増やす --}}
            <template x-for="item in items" :key="item.id">
              <tr @click="item.checked = !item.checked">
                <td><input x-model="item.checked" @click.stop type='checkbox'></td>
                <td x-text="item.name" class="name"></td>
 
                <!-- 変更前役割 -->
                <td>
                  <template x-for="permission in item.permissions" :key="permission.id">
                    <span x-text="permission.name"
                      style="display:inline-block; background:#eee; padding:2px 6px; margin-right:4px; border-radius:4px;"
                    ></span>
                  </template>
                </td>
                <!-- 変更後役割 -->
                <td>
                  <template x-for="permissionId in (rolePermissionsMap[item.id] || [])" :key="permissionId">
                    <span
                      x-text="permissions.find(p => p.id === permissionId)?.name"
                      style="display:inline-block; background:#eee; padding:2px 6px; margin-right:4px; border-radius:4px;"
                    ></span>
                  </template>
                </td>
              </tr>
            </template>
          </tbody>
        </table>
 
        <button type='submit' title="役割と権限を更新します"
          :disabled="checkedItems.length < 1"
          @click="updatePermission().then(data => {
            console.log('after updatePermission', data.roles);
            const ids = new Set(data.roles.map(role => Number(role.id)));
            items = items.filter(item => !ids.has(item.id));
            return append(data.roles.map(role => {
              role.checked = true;
              return role;
            }));
          })"
        >
          更新
        </button>
      </div>  <!-- end of left_colum -->
 
      <div id='right_column' class='w-[30%]'>
        <!-- permissionの一覧 -->
        <h3>作成済み権限</h3>
        <template x-for="(subperms, group_name) in groupedPermissions()" :key="group_name">
          <fieldset class="border border-user p-4 rounded mb-4">
            <legend class="px-2 font-bold">
              <label>
                <input type='checkbox' checked='' class='accent-user'>
                <span x-text="group_name"></span>
              </label>
            </legend>
            <div class='flex flex-wrap gap-4 mt-2'>
              <template x-for="perm in subperms" :key="perm.id">
                <label class="inline-flex items-center gap-1">
                  <input type='checkbox'
                    :checked="isAllHas(perm.id)"
                    x-effect="$el.indeterminate = isAnyHas(perm.id)"
                    @click="togglePermission(perm.id, ! isAllHas(perm.id))"
                  />
                  <span x-text="perm.name.split('.').pop() + ' ' + havingCount(perm.name)" class="name"></span>
                  @if(1)  {{-- 動作確認用 --}}
                    [<span x-text="havingCount(perm.id)" ></span>
                    <span x-text="isAnyHas(perm.id)" ></span>
                    <span x-text="isAllHas(perm.id)" ></span>]
                  @endif
                </label>
              </template>
            </div>
          </fieldset>
        </template>
 
        <!-- permissionの追加用フォーム -->
        <form x-data="{entries:'some.read,some.write'}"
          @submit.prevent="console.log('Store permissions:', entries);
            htmx.ajax('POST', url.store, {
              source: $el,
              target:'#{{ $feature_id }}',
              swap: 'outerHTML',
              headers: {
                'HX-Target': 'feature-' + '{{ $feature_id }}',
              }
            });
        ">
          @csrf
          <fieldset class="border border-user p-4 rounded mb-4">
            <legend class="px-2 font-bold">権限を追加</legend>
            <input type='input' x-model='entries' class='border-1 border-user p-1'>
            <button>追加</button>
            <input type='hidden' name='guard_name' x-model='query.guard_name'>
 
            {{-- htmxでフォームを送信する --}}
            <template 
              x-for="(entry, index) in entries.split(',').map(s => s.trim())"
              :key="index"
            >
              <span>
                <input type="hidden" :name="`items[${index}][name]`" :value="entry">
                <input type="hidden" :name="`items[${index}][guard_name]`" :value="query.guard_name">
              </span>
            </template>
          </fieldset>
        </form>
      </div>  <!-- end of right_colum role5,role6,role7-->
    </div>
  </x-feature>
@endsection
@props(['items' => []])