Laravelの$castsプロパティは、目立たないものの、データの扱い方を形作る便利な機能の一つです。配列にbooleandatetimeのような組み込み型を指定するだけで、Eloquentはデータベースの生の文字列を、コントローラーやビューに届く前に、より扱いやすい形式へと自動的に変換してくれます。これにより、コードをクリーンに保つことができます。しかし、その配列の中で独自のヘルパー関数を直接呼び出そうとすると、フレームワークに拒否されます。'username' => 'encrypt_data()' のような記述は機能しません。Laravelは、ネイティブのキャストキーワードか、CastsAttributesインターフェースを実装したクラスのいずれかを期待しています。この要件が存在するのは、キャストのレイヤーがEloquentのハイドレーション(hydration)とシリアライゼーション(serialization)のサイクルの深い部分で動作しているためです。実行時に存在しないかもしれない任意の関数文字列ではなく、明確な契約(contract)を持つ予測可能なオブジェクトが必要なのです。

$casts 配列内でヘルパー関数が失敗する理由

Eloquentがモデルをロードするとき、各カラムをどのように変換するかを決定するために$casts配列をスキャンします。フレームワークは、stringintegerarrayencryptedなどの特定のプリミティブ型や、CastsAttributesを実装した完全修飾クラス名(fully-qualified class names)を認識します。文字列をコードとして評価することはありません。そのため、'encrypt_data()' は未知のキャスト型として扱われ、エラーが発生します。たとえLaravelがそれを解析できたとしても、モデルのインスタンス、属性キー、現在の値、および周囲の属性を、正しい順序で関数に渡す確実な方法はありません。インターフェースは、まさにカスタムロジックとEloquentの内部処理との間の「ハンドシェイク(やり取り)」を標準化するために存在しています。

このギャップを埋めるには、2つの確実な方法があります。一方は長期的な再利用を重視し、もう一方は単一のモデル内での迅速な修正を必要とする場合のスピードを重視しています。

方法 1: カスタムキャストクラスを作成する

同じ変換が複数のフィールドに適用される場合や、複数のモデルにまたがる場合は、専用のキャストクラスを作成するのがよりクリーンな選択です。専用のファイルに存在し、単体テストを分離して行うことができ、モデルから繰り返しのボイラープレートコードを排除できます。

まず、app/Casts/CustomEncrypt.php を作成することから始めます。名前空間は、通常 App\Casts のように、オートロードの設定に合わせる必要があります。クラスは Illuminate\Contracts\Database\Eloquent\CastsAttributes を実装する必要があり、これにより getset の2つのメソッドの定義が強制されます。

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class CustomEncrypt implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return decrypt_data($value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return encrypt_data($value);
    }
}

メソッドのシグネチャが重要です。Eloquentは各メソッドに4つの引数を渡します。$model は、値をセットしている、あるいは保存しようとしているインスタンスであり、ロジックが他のフィールドに依存している場合にそれらを検査できます。$key は、現在キャストされているカラム名です。$value は、get の場合はデータベースから取得された生の文字列または null であり、set の場合はユーザーから提供された値です。$attributes は、その行の全カラムを含む生の配列全体です。毎回4つすべてを使う必要はありませんが、インターフェースの要件として定義されています。

上記の get メソッドでは、Eloquentがデータベースから行を取得した後、かつ値がモデルに格納される前に decrypt_data($value) が実行されます。set メソッドでは、INSERT または UPDATE の前に encrypt_data($value) が実行されるため、データベースに平文が保存されることはありません。

これを適用するには、モデルの $casts 配列内でクラスを参照します。

protected $casts = [
    'username' => CustomEncrypt::class,
    'password' => CustomEncrypt::class,
];

クラス定数を使用しているため、残りの処理はオートローダーが担当します。後にアプリケーションの暗号化スキームを切り替える必要が生じた場合、1つのファイルを編集するだけで、マッピングされたすべてのフィールドの動作が即座に変更されます。多くのテーブルにわたって機密データを管理する場合、この集約化のメリットは非常に大きいです。

方法 2: アクセッサとミューテータを使用する

1箇所でしか使わない変換のために、わざわざ新しいファイルを作りたくないこともあります。Laravelの Attribute クラスを使用すると、PHP 8以降のクロージャ構文を使用して、モデル上に直接 getset のロジックを定義できます。

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function username(): Attribute
{
    return Attribute::make(
        get: fn ($value) => decrypt_data($value),
        set: fn ($value) => encrypt_data($value),
    );
}

ここでは、メソッド名は対象となるカラムまたは属性と一致している必要があります。Eloquentがデータベースから username を読み取るとき、生の値を get クロージャに渡します。モデルの username プロパティに新しい値を代入すると、Eloquentがクエリを構築する前に set クロージャが値を暗号化します。

このアプローチは、キャストクラスのディレクトリを丸ごと用意するのが過剰に感じられるプロトタイプやレガシーなモデルにおいて真価を発揮します。デメリットは、繰り返しが発生することです。後になって emailphonebackup_code にも同じ処理が必要だと判断した場合、それらのクロージャを複数のメソッドやモデルにコピーすることになります。そのノイズは蓄積していきます。また、モデル全体を起動(boot)せずにユニットテストでそのロジックを再利用することも難しくなります。

2つの使い分け

再利用性、テスト、そしてモデルを軽量に保つことを重視する場合は、カスタムキャストクラスを使用してください。これにより、この変換がアプリケーションにおける「場当たり的なハック」ではなく、「第一級の概念」であることを他の開発者に示すことができます。

ロジックが完全に局所的であったり、実験的なものであったり、あるいは現在のスプリントを過ぎてまで使い続ける可能性が低い場合は、アクセサを使用してください。アクセサを使えば、コードベースに小さなファイルを散らばらせることなく、迅速に開発を進めることができます。ただし、同じパターンが2回、3回と現れたら、キャストクラスへとリファクタリングする準備をしておいてください。

実践的な留意点

カスタムキャストは強力ですが、注意していないと予期せぬ挙動を引き起こすことがあります。まず、get はデータベースから返された値(null を含む)をそのまま受け取ることを忘れないでください。もし decrypt_datanull 入力を許容しない場合は、以下のように対策を講じてください。

public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
    return is_null($value) ? null : decrypt_data($value);
}

次に、キャストは配列やJSONへのシリアライズ中に実行されます。モデルを API リソースとして返したり、toArray() を呼び出したりする場合でも、キャストの get ロジックは適用されます。これは通常、意図した通りの挙動ですが、復号された値を公開しており、その上にさらなる可視性制御を重ねる必要がある場合は、この点を覚えておく価値があります。

第三に、そして最も重要なことですが、キャストはデータの「取得後」に行われます。変換後の値に対してクエリを実行することはできません。例えば User::where('username', 'john_doe')->first() というクエリは、文字列 'john_doe' を直接データベースに送信します。これはあなたの復号ロジックを一切通りません。もしカラムが保存時に暗号化されている場合、暗号化された暗号文そのものを検索しない限り、そのクエリでは何も見つかりません。キャストはネイティブなデータベース関数やインデックス付きのプレーンテキスト列の代わりにはならないため、それに応じてデータベースへのアクセスパターンを計画してください。

また、カスタムキャストクラスは、ちょっとした設定を保持する場所としても非常に適しています。コンストラクタの引数(暗号モードやフォーマット文字列の受け渡しなど)が必要な場合、Laravel では $casts 配列を通じて 'field' => CustomEncrypt::class . ':arg' のような式を使ってサポートしています。ただし、これはここで説明した基本的なセットアップよりも一歩進んだ内容になります。

まとめ

$casts にヘルパー関数を直接記述することを制限しているのは、単なる恣意的なルールではありません。それは、明示的で、テスト可能で、再利用可能なコードへとあなたを導くためのものです。カスタムキャストクラスは、散らばったインラインロジックを、重複させることなくモデル間で共有できる信頼性の高いコンポーネントへと変えてくれます。一方でアクセサは、別ファイルを作成するのが過剰(儀式的)に感じられるような、迅速で局所的な修正を行うための手段として残しておけます。両方をマスターし、問題の範囲に応じて使い分けることで、アプリケーションが成長した後も Eloquent レイヤーの可読性を高く保つことができるでしょう。