LaravelのPolymorphicリレーションシップを活用する方法
- 作成日 2026.03.11
- その他
Polymorphicリレーションシップは、1つの関連テーブルを複数のモデルで共有したいときに使う仕組み。代表例は「投稿にも商品にもコメントを付けたい」「ユーザーにも記事にも画像を紐付けたい」「複数モデルに対していいねを付けたい」といったケースになる。通常の外部キー設計では、モデルごとに別テーブルや別カラムが必要になりやすいが、Polymorphicを使うと関連先の型とIDを1組で持つことで、柔軟に拡張できる。一方で、typeカラムの扱い、N+1、削除時の整合、morph map未設定によるリネーム事故など、実務で詰まりやすいポイントも多い。
- 1. Polymorphicリレーションが向いている場面
- 2. 基本構造:{name}_id と {name}_type の2つで関連先を表す
- 3. 1対多の基本例:PostとProductにCommentを付ける
- 4. 1対1の基本例:UserやCompanyに共通のImageを持たせる
- 5. 多対多の基本例:PostとVideoにTagを付ける
- 6. morphMapを設定してtypeカラムを安全に運用する
- 7. N+1問題:Polymorphicでもeager loadを前提にする
- 8. 削除時の整合性:親を削除したら子をどう扱うかを決める
- 9. ソフトデリートとの組み合わせ
- 10. バリデーションと入力設計:type/idを外部入力にしない
- 11. よくあるエラーと発生条件
- 12. 実務での使い分けの目安
- 13. まとめ
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テーブルに一意制約が無く、同じタグが重複で付いてしまうケース。
そのため、primary か unique を入れて重複を防ぐ構成が実務では重要になる。
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_type や commentable_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の価値が出やすい。
-
前の記事
Laravelで.envを使った環境設定を管理する方法:安全・再現性・運用性を高める基本設計 2026.03.10
-
次の記事
LaravelでAPP_KEYが設定されていない場合の対処方法 2026.03.12
コメントを書く