LaravelのPolymorphicリレーションシップを活用する実践パターン

LaravelのPolymorphicリレーションシップを活用する実践パターン

概要:Polymorphic(ポリモーフィック)リレーションは、複数の異なるモデルが「同じ関連(コメント・画像・いいね等)」を共有できる仕組みです。テーブル設計をシンプルに保ちながら、関連データの追加・拡張に強い構成を作れます。一方で、参照整合性・パフォーマンス・削除時の扱いなど落とし穴もあるため、設計意図と運用ルールを明確にした上で使うのが重要です。

Polymorphicリレーションで解決できる課題

・「コメント」は記事にも商品にも付く、のように“同じ子モデル”が複数親モデルに紐づく要件
・画像、添付ファイル、タグ、いいね、アクティビティログなどを横断的に扱いたい
・親ごとに中間テーブルを増やしたくない(posts_comments / products_comments…の増殖回避)
・機能追加(別の親モデル追加)をマイグレーション最小で対応したい

基本構造(morphTo / morphMany / morphOne)の全体像

Polymorphicは「子テーブルに 〇〇_id と 〇〇_type を持たせる」のが基本です。
例:comments テーブルが commentable_id と commentable_type を持ち、親(Post/Video等)を識別します。

# 代表的な関係
# Post 1 --- * Comment
# Video 1 --- * Comment
# comments.commentable_id   : 親ID
# comments.commentable_type : 親モデルのクラス名(マップで短縮も可能)

サンプル要件:PostとVideoに共通のCommentを付ける

この構成を題材に、作成〜取得〜最適化〜運用まで一気通貫で組み立てます。

マイグレーション(commentsテーブル)

Laravelには morphs() があり、*_id と *_type をまとめて作れます。インデックス設計もここで固めます。

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'); // commentable_id, commentable_type + index
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->text('body');
            $table->timestamps();

            // 大量データを想定するなら追加で複合インデックスも検討
            // $table->index(['commentable_type', 'commentable_id', 'created_at']);
        });
    }

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

モデル定義:Comment側(morphTo)

子側は morphTo() で「親が何であれ辿れる」ようにします。

namespace App\Models;

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

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

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

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

モデル定義:Post/Video側(morphMany)

親側は morphMany()。命名(commentable)を子側と一致させます。

namespace App\Models;

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

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

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

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

作成(関連経由で安全に保存する)

commentable_id / type を手書きせず、関連メソッド経由で作成します。型の入れ違い事故が減ります。

$post = Post::findOrFail($postId);

$post->comments()->create([
    'user_id' => auth()->id(),
    'body'    => 'はじめてのコメント',
]);

$video = Video::findOrFail($videoId);

$video->comments()->create([
    'user_id' => auth()->id(),
    'body'    => '動画にもコメント可能',
]);

取得(親→子 / 子→親)とN+1の回避

親→子は通常の with() でOK。子→親は morphTo を eager load します。

# 親からコメント一覧
$posts = Post::query()
    ->with(['comments.user'])
    ->latest()
    ->paginate();

# コメントから親(Post/Video)を辿る
$comments = Comment::query()
    ->with(['user', 'commentable'])
    ->latest()
    ->paginate();

morphToのEager Load最適化(型ごとにwithを分ける)

commentable が複数型の場合、型ごとに追加ロードを分けると表示が安定します(必要な関連だけを型別で読み込む)。

use Illuminate\Database\Eloquent\Relations\MorphTo;

$comments = Comment::query()
    ->with([
        'user',
        'commentable' => function (MorphTo $morphTo) {
            $morphTo->morphWith([
                Post::class  => ['author'],   // Postの時だけauthorも読む
                Video::class => ['channel'],  // Videoの時だけchannelも読む
            ]);
        }
    ])
    ->latest()
    ->get();

Polymorphicの型名を短縮する(morphMap)

DBにフルクラス名が入る運用は、リネームや名前空間変更で事故りやすいです。morphMap を使うと type を短いキーに固定できます。

# App\Providers\AppServiceProvider.php など
use Illuminate\Database\Eloquent\Relations\Relation;

public function boot(): void
{
    Relation::enforceMorphMap([
        'post'  => \App\Models\Post::class,
        'video' => \App\Models\Video::class,
    ]);
}

# comments.commentable_type には 'post' / 'video' が入る

エラーの発生条件:morphMap導入前後でtypeが混在する

・導入前:commentable_type に “App\Models\Post” のようなフルクラス名が入っている
・導入後:新規は “post” が入る
この状態で enforceMorphMap を有効化すると、フルクラス名側がマップに存在せず解決できないことがあります。
対応は「既存データの一括置換」「移行期間はenforceせずmapのみ」「環境差分のないデプロイ手順」を採用します。

# 例:既存データをキーへ置換(DBに応じてSQL調整)
UPDATE comments
SET commentable_type = 'post'
WHERE commentable_type = 'App\\\\Models\\\\Post';

UPDATE comments
SET commentable_type = 'video'
WHERE commentable_type = 'App\\\\Models\\\\Video';

削除の設計:親削除時に子をどうするか

Polymorphicは外部キー制約で「commentable_id/type」を参照整合できません。放置すると孤児コメントが残ります。
・原則:親削除時に子も削除(アプリ側で制御)
・要件次第:監査ログとして残す(親が消えても残す)なら、表示・検索から除外ルールを用意

# 例:Post削除時にcommentsも消す(モデルイベント)
class Post extends Model
{
    protected static function booted(): void
    {
        static::deleting(function (Post $post) {
            $post->comments()->delete();
        });
    }
}

スコープ活用:特定の親だけのコメントを高速に引く

クエリは whereMorphedTo / whereHasMorph を使うと読みやすく、型条件が明確になります。

# ある特定のPostに紐づくコメント
$comments = Comment::query()
    ->whereMorphedTo('commentable', $post)
    ->latest()
    ->get();

# Post型だけのコメント
$comments = Comment::query()
    ->whereHasMorph('commentable', [Post::class])
    ->latest()
    ->get();

# PostとVideoをまたいで、条件付きで絞る
$comments = Comment::query()
    ->whereHasMorph('commentable', [Post::class, Video::class], function ($q, $type) {
        if ($type === Post::class) {
            $q->where('status', 'published');
        }
        if ($type === Video::class) {
            $q->where('is_public', true);
        }
    })
    ->latest()
    ->get();

実務パターン:画像(morphOne/morphMany)とファイル管理

・プロフィール画像は morphOne(常に1枚)
・ギャラリーは morphMany(複数)
・添付ファイルは morphMany + 物理ストレージ(S3等)とセットで運用

# CommentFactory例(親はテスト内で渡す運用が安全)
public function definition(): array
{
    return [
        'user_id' => User::factory(),
        'body'    => $this->faker->sentence(),
    ];
}

# テスト側で関連経由生成
$post = Post::factory()->create();
$comment = $post->comments()->create(Comment::factory()->make()->toArray());

$this->assertInstanceOf(Post::class, $comment->commentable);

よくある落とし穴:パフォーマンス劣化の条件

・comments が肥大化しているのに commentable_type/id のインデックスが弱い
・一覧画面で commentable を都度読む(N+1)
・morphTo の型が多いのに不要な関連まで読み込む
対策:morphs のインデックス確認、created_at を含めた複合インデックス、morphWith の型別ロード、ページング、キャッシュ。

テスト戦略:FactoryでPolymorphicを安定生成する

テストは「Postにコメント」「Videoにコメント」「commentableを辿れる」「削除時の孤児が出ない」を押さえると事故が減ります。

# CommentFactory例(親はテスト内で渡す運用が安全)
public function definition(): array
{
    return [
        'user_id' => User::factory(),
        'body'    => $this->faker->sentence(),
    ];
}

# テスト側で関連経由生成
$post = Post::factory()->create();
$comment = $post->comments()->create(Comment::factory()->make()->toArray());

$this->assertInstanceOf(Post::class, $comment->commentable);

Polymorphicを使わない方がいい条件

・参照整合性をDB外部キーで強制したい(厳格なFKが必須)
・親ごとに属性や制約が大きく異なり、共通の子モデルとして扱うメリットが薄い
・集計が極端に重い(多型を跨いだ分析が中心)
この場合は、親ごとに中間テーブルを分ける、もしくは明示的な中間モデルを設けた方が運用が簡単になります。

まとめ:運用ルールまで含めて設計すると強い

・子テーブルは morphs で統一、命名は一貫させる
・typeは morphMap で固定して将来の変更に強くする
・親削除時の扱い(消す/残す)を最初に決め、孤児を出さない
・一覧は eager load と型別ロードでN+1を避ける
・インデックスとページングを前提に、肥大化しても崩れない構成にする