When integrating Laravel with an external system—a headless CMS, a legacy database, an analytics platform—you often need multiple Laravel models to link to records in that external system. The naive approach is adding a cms_post_id column to each model. But that creates tight coupling and makes future changes painful.
The better pattern: use a polymorphic pivot table as a bridge layer between your domain models and the external system.
The Problem
You have Product, Category, and Tag models. Each can link to posts in an external CMS. The obvious solution:
// products table
cms_post_id (bigint)
// categories table
cms_post_id (bigint)
// tags table
cms_post_id (bigint)
Now every model needs CMS-specific logic. Testing requires the CMS. Migrating to a different CMS means updating every model. You’ve tightly coupled your domain to infrastructure.
The Solution: Polymorphic Bridge
Create an intermediate pivot table that maps any model to external records:
// Migration: create_cms_links_table.php
Schema::create('cms_links', function (Blueprint $table) {
$table->id();
$table->morphs('linkable'); // linkable_id, linkable_type
$table->unsignedBigInteger('cms_post_id');
$table->timestamps();
$table->unique(['linkable_id', 'linkable_type', 'cms_post_id']);
});
Now your models use morphToMany relationships to connect through the bridge:
// app/Models/Product.php
public function cmsPosts()
{
return $this->morphToMany(
CmsPost::class,
'linkable',
'cms_links',
'linkable_id',
'cms_post_id'
);
}
// app/Models/Category.php
public function cmsPosts()
{
return $this->morphToMany(
CmsPost::class,
'linkable',
'cms_links',
'linkable_id',
'cms_post_id'
);
}
Use it like any relationship:
$product->cmsPosts()->attach($cmsPostId);
$product->cmsPosts; // Collection of CmsPost models
Enforce Clean Type Names with Morph Maps
By default, Laravel stores the full class name in linkable_type: App\Models\Product. If you ever refactor namespaces or rename models, those database values break.
Fix this with Relation::enforceMorphMap() in a service provider:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Relations\Relation;
public function boot()
{
Relation::enforceMorphMap([
'product' => Product::class,
'category' => Category::class,
'tag' => Tag::class,
]);
}
Now linkable_type stores product instead of App\Models\Product. Your database is decoupled from your code structure.
Converting to One-to-One Relationships
If a model should only link to one CMS post, use ofOne() to convert the many-to-many into a one-to-one:
public function cmsPost()
{
return $this->morphToMany(
CmsPost::class,
'linkable',
'cms_links',
'linkable_id',
'cms_post_id'
)->one();
}
Now $product->cmsPost returns a single CmsPost instance (or null), not a collection.
Why This Pattern Wins
- Clean domain models: No CMS-specific columns polluting your core tables.
- Flexible: Need to link a new model? Just add the relationship—no migration to add columns.
- Swappable: Migrating from WordPress to Contentful? Update the
CmsPostmodel and the bridge table, your domain models stay untouched. - Testable: Mock the relationship, no need for the external CMS in tests.
Real-World Example: Analytics Platform
Suppose you’re tracking user actions in an external analytics platform. Each User, Order, and Event can have an analytics profile ID.
// Migration
Schema::create('analytics_links', function (Blueprint $table) {
$table->id();
$table->morphs('trackable');
$table->string('analytics_profile_id');
$table->timestamps();
$table->unique(['trackable_id', 'trackable_type']);
});
// Models
class User extends Model
{
public function analyticsProfile()
{
return $this->morphToMany(
AnalyticsProfile::class,
'trackable',
'analytics_links',
'trackable_id',
'analytics_profile_id'
)->one();
}
}
// Usage
$user->analyticsProfile()->attach($profileId);
$profileId = $user->analyticsProfile->id;
Your core models stay focused on your business logic. The analytics integration is isolated to the bridge table and the AnalyticsProfile model.
When Not to Use This
If the foreign ID is part of your domain—like user_id on an Order—put it directly in the table. This pattern is for external systems that your domain doesn’t inherently care about.
But when you’re integrating with CMSs, analytics platforms, search engines, or any third-party system where multiple models need to link out, the polymorphic bridge pattern keeps your domain clean and your code flexible.