EloquentでUUIDを主キーとして使う

  • PHP
  • Laravel
  • Eloquent
  • UUID

この記事は、構成と文章の整理にAIを使っています。
あくまで読みやすくするための補助で、内容そのものをAIにすべて任せて書いているわけではありませんが、ところどころ相談しながら整えています。

外部に公開するIDに連番のintを使うと、レコード数が推測できたり、他人のIDを適当にインクリメントして覗けたりする。今回はそれを避けたくて、Eloquentのモデルでauto incrementなintのidではなく、UUIDのstring主キーを使うことにした。

何をしたか

よくあるやり方として、__construct()の中でUUIDを生成してattributesに突っ込む実装を試した。

namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid;
class Post extends Model
{
protected $primaryKey = 'uuid';
protected $keyType = 'string';
public $incrementing = false;
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['uuid'] = Uuid::uuid4()->toString();
}
}

$primaryKey / $keyType / $incrementing の3点セットは主キーをUUIDにする定番の設定で、ここは問題ない。

気づいたこと

新規作成時はこれで動くが、既存レコードをDBから取得する時にも同じコンストラクタが呼ばれていることに気づいた。Post::find($uuid)のような取得処理でも、内部的にはnew Post()相当の処理が走っている。

Eloquentのソースを読む限り、newFromBuilder()がnewInstance([], true)経由でインスタンスを作り、そのあとにsetRawAttributes()でDBの実データを上書きしているらしい(実際にvar_dumpして追いかけたわけではないので未確認)。

sequenceDiagram
    participant Q as クエリ結果
    participant M as Model::newFromBuilder()
    participant N as Model::newInstance([], true)
    participant C as __construct()
    Q->>M: 行データ
    M->>N: 空の配列でインスタンス生成
    N->>C: 呼び出し
    C->>C: 新しいUUIDを生成してattributesにセット
    M->>M: setRawAttributes()でDBの実データに上書き

流れ的には最終的にDBの値で上書きされるので、取得したレコードのUUIDがおかしくなるわけではない。ただし、

  • レコードを1件取得するたびに無意味なUUID生成が走る
  • new Post(['uuid' => '既存のuuid', ...])のように明示的にUUIDを渡しても、コンストラクタ内で無条件に上書きされる
  • $this->attributes['uuid']への直接代入なので、キャストやミューテータを経由しない

という問題がある。特に2番目は、既存データをコード上で再構築するようなケースで気づきにくいバグになりそう。

対処

creatingイベントを使う実装に変更した。新規作成時(save()でINSERTされる時)だけ呼ばれるので、hydrate時の無駄な生成は発生しない。

namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid;
class Post extends Model
{
protected $primaryKey = 'uuid';
protected $keyType = 'string';
public $incrementing = false;
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->uuid = $model->uuid ?: Uuid::uuid4()->toString();
});
}
}

$model->uuid ?: ...にしているので、既にUUIDが設定されている場合はそれを尊重する。属性への代入も$this->attributes直接ではなく$model->uuid = ...にしたので、キャストやミューテータの経路も通る。

コンストラクタで完結させる実装はシンプルに見えるが、Eloquentのライフサイクル的にはhydrateとcreateを区別できないので、UUID生成のような「新規作成時だけ」やりたい処理はイベントに乗せた方が素直だった。