1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

現在私は、PHP/Laravel を採用した労務管理システムの構築案件に関わっています。

こうした業務システムにおいては、実際の業務フローや役割分担をそのままデータモデルに落とし込む、いわゆるセマンティック(意味論的)なモデリングが効果的です。
これにより、データの一貫性の維持や、業務単位での分析・可視化の効率化といった大きなメリットを享受できます。

しかし、Laravel が標準で提供する Eloquent ORM は Active Record パターンを採用しており、この構造ではセマンティックな業務モデルとの間に乖離が生じやすいという課題が顕在化しました。

たとえば、以下のような文脈依存の仕様が必要になります。

  • 自社の従業員一覧を閲覧したい総務部の従業員
  • 複数企業を横断的に管理する社労士
  • 特定部署の閲覧権限だけを持つ管理職

これらのユーザーは同じエンドポイントを利用していても、アクセス可能なデータのスコープや表示される情報の粒度が異なるべきです。

scope_example.png

しかし Eloquent の Active Record では以下のような課題に直面しました:

  • モデルはテーブルと 1:1 で対応するため、ユーザー文脈に応じた動的な制約やフィルタを持たせづらい
  • フィルタ処理やアクセス判定が Controller や View に分散し、責務が不明確になる(QueryBuilderでも同様)
  • 同じ「従業員モデル」でもアクセス者の立場によって処理が異なり、実装の一貫性を保ちにくい

このように、業務視点で設計したセマンティックモデルと、Eloquent の実装構造との間にズレ(乖離)が発生していたのです。

本記事では、ログインユーザーに関わるモデルにおいて、こうした乖離を解消するために導入したアクセス制御を横断的に扱うPermissionクラスの設計と、拡張型 Active Record パターンについて、最も乖離リスクが大きかったユーザーモデルを例にご紹介します。

※例は実際のものではなく、記事用に簡略化した記載となっておりますので、変なところがあれば教えていただけると助かります。m(_ _)m

拡張型 Active Record による仮想モデルの導入

上述のように、文脈依存のデータスコープやアクセス制御を Active Record パターン内に落とし込むことは、できなくもないがどこかで乖離が起きます。
そこで本プロジェクトでは、Laravel の Eloquent モデルを拡張し、Proxy 的にふるまう仮想モデルを導入しました。

この仮想モデルは、実テーブルに直接は対応しない構造を持ちつつも、ソースコード上では通常の Eloquent モデルのように扱えるよう設計されています。
具体的には、ログイン中のユーザーの文脈(所属企業、役割、認可範囲など)に基づき、あらかじめフィルタリングが施された他のモデル(たとえば Employee モデル)を透過的に返却する仕組みです。

たとえば CurrentUser::employees() のような呼び出しで、ログインユーザーがアクセス可能な従業員一覧を取得できるようになっており、Controller 側では特別な条件分岐を記述する必要がありません。

CurrentUser.php
<?php

namespace App\Models;

use Illuminate\Support\Facades\Auth;
use App\Models\Employee;
use App\Permission;

// CurrentUserテーブルは存在しない
class CurrentUser
{
    public static function info()
    {
        $user = Auth::user();
        return Employee::find($user->employee_id);
    }

    public static function getTargetCompanyId()
    {
        // 権限取得
        $permission = new Permission();
        $employee = self::info();

        $company_id = 0;
        if ($permission->isLabor()) {
            // 社労士の場合は現在閲覧対象としている会社のid
            $company_id = session()->get('company_id', 0);
        } else {
            $company_id = $employee->value('company_id');
        }
        
        return $company_id;
    }
    
    public static function employees()
    {
        // 権限取得
        $permission = new Permission();
        $company_id = self::getTargetCompanyId();
        $employee = Employee::where('company_id', $company_id);
        
        // ここで条件クエリを付与
        if ($permission->isEmployee()) {
            $employee = $employee->where('role_id', 100);
        }

        // 管理職じゃなければ、同レイヤーの従業員を取得するクエリを付与
        if ($permission->isManager() == false) {
            $employee = $employee->where('manager_flg', 0);
        }

        ...

        return $employees;
    }
}

テーブル例
table_example_2.png
※employee_id = 従業員番号、company_id = 法人番号

このアプローチの利点

  • 実行文脈に応じたクエリのスコープを事前にモデル内に内包することで、アプリケーション全体で一貫した挙動が得られる
  • Controller や View に余計なロジックを記述せずに済み、実装の可読性と保守性が向上
  • テスト時も、仮想モデルの戻り値として制限済みデータを想定できるため、業務文脈を意識した状態での検証が容易

Active Record の「データベースの1テーブル=1モデル」という原則からは一歩踏み出し、文脈に応じた代理モデル(Proxy)を設計層に持ち込むことで、セマンティックなモデリングとの乖離を最小化しています。

続いて、この構造と密接に連携する「Permissionクラス」について紹介します。

Permissionクラスによる文脈的アクセス権の統一管理

今回の拡張型 Active Record を機能させるうえで重要な役割を果たしているのが、Permission クラスです。
このクラスは、ログイン中のユーザーの認可情報を文脈としてアプリケーション全体に提供する統一インターフェースとして機能しており、ログイン直後にユーザーの所属、役職、権限範囲などの情報をサーバー側のセッションに保持します。

これにより、Controller/View/Livewire/Model などどの層からも条件分岐や権限チェックを同じインターフェースで呼び出せるようにしています。

Permission.php

<?php

namespace App;

use Illuminate\Support\Facades\Auth;

class Permission
{
    private $account_type = null;  // アカウント種別
    private $position = null;      // 役職
    private $company_id = null;    // 会社ID
    
    public function __construct($user_id = null)
    {
        if (empty($user_id)) {
            if (session()->has('permissions')) {
                $permissions = session()->get('permissions');
                $this->account_type = $permissions['account_type'];
                $this->position = $permissions['position'];
            } else {
                // セッションにデータがなければ新たに権限情報を取得
                // 権限情報をUserモデルが持っている場合
                $user = Auth::user();
                $this->account_type = $user->account_type;
                $this->position = $user->position;
                
                // セッションに保存
                session()->put('permissions', [
                        'account_type' => $this->account_type,
                        'position' => $this->position,
                    ]);
            }
        } else {
            // user_idの指定があれば、そのユーザーの権限をセット
            $user = User::find($user_id);
            $this->account_type = $user->account_type;
            $this->position = $user->position;
        }
    }

    // 従業員かどうか
    public function isEmployee()
    {
        return $this->account_type === 1;
    }
    
    // 社労士かどうか
    public function isLabor()
    {
        return $this->account_type === 1;
    }

    // 管理職かどうか
    public function isManager()
    {
        return $this->position === 1;
    }

    ...
}


// ログインした時に
$permission = new Permission();
$permission->isLabor(); // 現在ログインしているユーザーが社労士であればtrue

// 特定ユーザーの権限を取得したい時に
$permission_2 = new Permission(2);
$permission_2->isLabor(); // id = 2のユーザーが社労士であればtrue

  • $permission->isLabor()$permission->isEmployee() などのメソッドを通じて、ユーザーの立場を論理値で判定可能
  • 情報はセッションに保存されているため、毎回 DB にアクセスする必要がなく、高速かつ一貫したアクセス制御が実現
  • ログイン中のユーザーとは別に、指定したユーザー目線の権限情報も取得可能
  • 仮想モデル(例:CurrentUser)内部からも Permission クラスを参照することで、適切なデータスコープを動的に付与可能

Controllerのロジックをより薄く

たとえば、従来であれば Controller 側で「このユーザーが社労士なら複数企業のデータを、クライアントであれば自社のみ」といった条件分岐が必要でした。
しかし、Permission クラスと仮想モデルの連携により、Controller 側では単純に CurrentUser::employees() を呼び出すだけで、認可済みのデータが返却される構造になります。

Controller
public function index()
{
    $employees = CurrentUser::employees();
    $employees = $employees->get();
    
    return view('employee.list', compact('employees'));
}
Blade
@foreach($employees as $employee)
    <ul>
        <li>{{ $employee->full_name }}</li>
        <li>{{ $employee->age }}</li>
        <li>{{ $employee->position }}</li>
    </ul>
@endforeach

このようにして、条件分岐がコントローラーレイヤーに漏れ出さない構造を保ちながら、ビジネス文脈に沿ったデータアクセスが自然に行える設計を実現しています。

まとめ:導入による効果

このような設計を採用したことで、プロジェクト全体において保守性・拡張性・可読性が大きく向上しました。

1. 機能追加・仕様変更時の影響範囲を最小化

従来であれば、仕様変更に応じて複数の Controller や View、Livewire コンポーネントで条件分岐を個別に見直す必要がありました。
しかし現在は、仮想モデルと Permission クラス内のロジックを修正するだけで、アプリケーション全体に影響を波及させられる構造になっており、改修コストが大幅に削減されました。

2. Controller・Viewのロジックが単純化

Controller 側の実装は、常に「フィルタリング済みのモデルを呼び出す」だけの構成を維持できます。
これにより、if 文が多重化したり、アクセス制御ごとにロジックが分岐するような複雑な構造を避けることができ、コードの見通しが非常に良くなりました。仮に条件が必要だとしても、Permissionクラスから論理値を受け取ることで殆どの条件分岐を容易に行えます。

3. 設計の一貫性と再利用性の確保

Permission クラスの導入によって、ユーザーの状態や権限のチェック方法が統一され、開発者間での認識のズレも減少しました。
また、Controller やモデルからの呼び出しインターフェースが共通化されたことで、再利用性の高いロジックを安定的に運用できるようになっています。

このように、セマンティックなデータ設計を尊重しつつ、Laravelの構造と親和性の高い形で業務文脈に対応する仕組みを構築することで、現実的な開発体験と将来のスケーラビリティの両立を実現できました。

デメリットとして、条件が増えるごとにPermissionクラスが肥大化してしまうということが考えられますので、規模やプロジェクトによっては処理や責任をより細分化する必要があるかもしれません。

特に業務アプリや文脈依存の強いシステムをLaravelで設計する際には、よほど複雑なシステムでない限り有効なアプローチとなるため、ぜひ参考にしてみてください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?