LaravelのPolymorphicリレーションシップを活用する方法

LaravelのPolymorphicリレーションシップを活用する方法

Polymorphicリレーションシップは、1つの関連テーブルを複数のモデルで共有したいときに使う仕組み。代表例は「投稿にも商品にもコメントを付けたい」「ユーザーにも記事にも画像を紐付けたい」「複数モデルに対していいねを付けたい」といったケースになる。通常の外部キー設計では、モデルごとに別テーブルや別カラムが必要になりやすいが、Polymorphicを使うと関連先の型とIDを1組で持つことで、柔軟に拡張できる。一方で、typeカラムの扱い、N+1、削除時の整合、morph map未設定によるリネーム事故など、実務で詰まりやすいポイントも多い。

Polymorphicリレーションが向いている場面

向いているのは、複数のモデルに対して同じ種類の関連を持たせたい場面。
例としては次のようなものがある。
・Post と Product の両方に Comment を付ける
・User と Company の両方に Image を1枚ずつ持たせる
・Post と Video の両方に Tag を付ける
・複数モデルに対して ActivityLog を記録する
逆に、関連先が固定で今後も増えないなら、通常の外部キーの方がシンプルで安全なことが多い。

基本構造:{name}_id と {name}_type の2つで関連先を表す

Polymorphicでは、関連先を表すために2つのカラムを持つ。
たとえばコメントであれば、以下のようになる。
・commentable_id
・commentable_type
この組み合わせによって、「どのモデルの、どのレコードに紐づいているか」を表現する。
Laravel側では、子モデル側に morphTo()、親モデル側に morphMany()morphOne() を定義する。

1対多の基本例:PostとProductにCommentを付ける

最もよく使うのが1対多のPolymorphic。
まずは comments テーブルを作る。

// database/migrations/xxxx_xx_xx_create_comments_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->morphs('commentable');
            $table->text('body');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('comments');
    }
};

次に、Commentモデル側で morphTo() を定義する。

// app/Models/Comment.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    protected $fillable = ['body'];

    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

親側のモデルには morphMany() を定義する。

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

// app/Models/Product.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Product extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

保存例は以下。

$post = Post::findOrFail(1);

$post->comments()->create([
    'body' => 'とても参考になりました。',
]);

取得例は以下。

$comment = Comment::first();

$parent = $comment->commentable; // Post または Product

エラーの発生条件として多いのは、commentable_type に想定外の値が入っている場合や、親レコードが削除されていて null になる場合。

1対1の基本例:UserやCompanyに共通のImageを持たせる

1対1のPolymorphicは、プロフィール画像や代表画像のように、各モデルに1件だけ関連を持たせたいときに使いやすい。

// database/migrations/xxxx_xx_xx_create_images_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('images', function (Blueprint $table) {
            $table->id();
            $table->morphs('imageable');
            $table->string('path');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('images');
    }
};

// app/Models/Image.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Image extends Model
{
    protected $fillable = ['path'];

    public function imageable(): MorphTo
    {
        return $this->morphTo();
    }
}

// app/Models/User.php
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\MorphOne;

class User extends Authenticatable
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

// app/Models/Company.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;

class Company extends Model
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

保存例。

$user = User::findOrFail(1);

$user->image()->create([
    'path' => 'uploads/users/avatar1.png',
]);

1対1で起こりやすい問題は、アプリ側では1件想定なのにDB上では複数件入ってしまうこと。
厳密に1件にしたい場合は、更新時に既存画像を削除する処理を入れるか、業務ルールで1件に保つ運用を作る必要がある。

多対多の基本例:PostとVideoにTagを付ける

Tagのように、複数のモデルが複数のタグを持てる場合は、morphToMany()morphedByMany() を使う。

// database/migrations/xxxx_xx_xx_create_tags_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('tags', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->timestamps();
        });

        Schema::create('taggables', function (Blueprint $table) {
            $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
            $table->morphs('taggable');
            $table->primary(['tag_id', 'taggable_id', 'taggable_type']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('taggables');
        Schema::dropIfExists('tags');
    }
};

// app/Models/Tag.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphedByMany;

class Tag extends Model
{
    protected $fillable = ['name'];

    public function posts(): MorphedByMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos(): MorphedByMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

// app/Models/Video.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Video extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

利用例。

$post = Post::findOrFail(1);

$post->tags()->sync([1, 2, 3]);

ここで起きやすいのは、pivotテーブルに一意制約が無く、同じタグが重複で付いてしまうケース。
そのため、primaryunique を入れて重複を防ぐ構成が実務では重要になる。

morphMapを設定してtypeカラムを安全に運用する

Polymorphicで最も多い事故の1つが、*_type カラムにクラス名をそのまま保存してしまい、リネームやnamespace変更で既存データが壊れること。
これを避けるために morphMap を使う。

// app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Relations\Relation;
use App\Models\Post;
use App\Models\Product;
use App\Models\Video;
use App\Models\User;
use App\Models\Company;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Relation::morphMap([
            'post' => Post::class,
            'product' => Product::class,
            'video' => Video::class,
            'user' => User::class,
            'company' => Company::class,
        ]);
    }
}

これにより、DBには App\Models\Post ではなく post のような短い値が保存される。
発生条件としては、morphMap導入前に既存データがクラス名で保存されているケース。導入時には既存データの変換も必要になる。

N+1問題:Polymorphicでもeager loadを前提にする

Polymorphicも通常のリレーションと同じく、一覧処理ではN+1が起こる。
特にコメント一覧から親モデルをたどる処理などは、意識しないと遅くなりやすい。

$comments = Comment::with('commentable')->latest()->get();

また、親側から関連を取るときも同じ。

$posts = Post::with('comments')->latest()->get();

エラーではないが、件数が増えるとレスポンス悪化やタイムアウトにつながるので、パフォーマンス上の重要ポイントになる。

削除時の整合性:親を削除したら子をどう扱うかを決める

Polymorphicでは通常の外部キー制約が張れないため、親を削除しても子が残ることがある。
これは用途によって正解が違う。
・コメントも一緒に削除する
・画像は残して監査対象にする
・ソフトデリートで復元可能にする
など、ルールを決める必要がある。

一例として、親削除時に関連コメントも消す実装は以下。

// app/Models/Post.php
protected static function booted(): void
{
    static::deleting(function (Post $post) {
        $post->comments()->delete();
    });
}

発生条件としては、親だけ削除されて孤立したコメントや画像が残り、後で一覧や集計に混ざるケースが多い。

ソフトデリートとの組み合わせ

Polymorphicをソフトデリートと組み合わせる場合は、親だけソフトデリートされて子が残る構成が多い。
その場合、表示側で「削除済みの親にぶら下がるコメントをどう扱うか」を決めないと、整合が崩れて見える。
業務要件によっては、復元時に関連データも復元する処理をまとめて持つ方が安全。

バリデーションと入力設計:type/idを外部入力にしない

Polymorphicの関連付けでは、commentable_typecommentable_id をそのままフォームやAPIから受け取ると危険。
意図しないモデルへ紐付けられたり、不正入力の温床になる。
安全なのは、エンドポイント自体を分けて関連先を固定する方法。
たとえば、
POST /posts/{post}/comments
POST /products/{product}/comments
のようにすれば、typeを外から受け取らずに済む。

よくあるエラーと発生条件

Polymorphicでよくある問題は次の通り。
commentable が null
発生条件:type/id不整合、親レコード削除済み、morphMap不一致
・クラス名変更後に関連が解決できない
発生条件:typeカラムにクラス名を直接保存していた
・一覧が急に遅くなる
発生条件:with() を付けずに related モデルへアクセスしている
・同じTagが何度も付く
発生条件:pivotにunique/primaryが無い
・削除後も関連データが残る
発生条件:削除ポリシー未定義、cascadeをアプリ側で実装していない

実務での使い分けの目安

Polymorphicは便利だが、何でも1つにまとめれば良いわけではない。
使うべきなのは、
・関連の種類が明確に共通化できる
・今後関連先モデルが増える可能性がある
・コード側で削除・表示ルールを一元化できる
という場合。
逆に、モデルごとに挙動がかなり違うなら、別テーブルに分けた方が保守しやすいケースも多い。

まとめ

LaravelのPolymorphicリレーションシップは、複数モデルに共通の関連を持たせたいときに非常に強力。
実務で安定して使うには、
・基本構造を理解する
・1対多、1対1、多対多を使い分ける
・morphMapでtypeを安全に管理する
・N+1を避ける
・削除時の整合性を決める
・type/idを外部入力にしない
このあたりを最初から設計に含めることが重要になる。
単に動くだけで終わらせず、リネームや拡張、運用中の削除・復元まで見据えて実装すると、Polymorphicの価値が出やすい。