LaravelのPolymorphicリレーションシップを活用する実践パターン
- 作成日 2026.02.20
- その他
概要:Polymorphic(ポリモーフィック)リレーションは、複数の異なるモデルが「同じ関連(コメント・画像・いいね等)」を共有できる仕組みです。テーブル設計をシンプルに保ちながら、関連データの追加・拡張に強い構成を作れます。一方で、参照整合性・パフォーマンス・削除時の扱いなど落とし穴もあるため、設計意図と運用ルールを明確にした上で使うのが重要です。
- 1. Polymorphicリレーションで解決できる課題
- 2. 基本構造(morphTo / morphMany / morphOne)の全体像
- 3. サンプル要件:PostとVideoに共通のCommentを付ける
- 4. マイグレーション(commentsテーブル)
- 5. モデル定義:Comment側(morphTo)
- 6. モデル定義:Post/Video側(morphMany)
- 7. 作成(関連経由で安全に保存する)
- 8. 取得(親→子 / 子→親)とN+1の回避
- 9. morphToのEager Load最適化(型ごとにwithを分ける)
- 10. Polymorphicの型名を短縮する(morphMap)
- 11. エラーの発生条件:morphMap導入前後でtypeが混在する
- 12. 削除の設計:親削除時に子をどうするか
- 13. スコープ活用:特定の親だけのコメントを高速に引く
- 14. 実務パターン:画像(morphOne/morphMany)とファイル管理
- 15. よくある落とし穴:パフォーマンス劣化の条件
- 16. テスト戦略:FactoryでPolymorphicを安定生成する
- 17. Polymorphicを使わない方がいい条件
- 18. まとめ:運用ルールまで含めて設計すると強い
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を避ける
・インデックスとページングを前提に、肥大化しても崩れない構成にする
-
前の記事
Laravel×Vue.jsでSPAを構築する:API分離・認証・ルーティング・デプロイまでの実務パターン 2026.02.18
-
次の記事
記事がありません
コメントを書く