pindab0ter/constrained-morph-to-for-laravel
Type-constrained polymorphic relationships for Laravel Eloquent. Extend morphTo to accept only specified model types, ensuring type safety and data integrity while keeping the flexibility of a polymorphic database structure.
Install and confirm the version you get.
composer require pindab0ter/constrained-morph-to-for-laravel
You want 1.3.1 or later. 1.3.0 added morph-map compatibility and Laravel 13 support; 1.3.1 fixed whereMorphedTo() / orWhereMorphedTo() qualifying morph columns against the wrong table (an unknown-column error). Pin an exact version rather than a caret range — the package is pre-2.0 and still correcting query-builder edge cases, so take upgrades as reviewed changes.
Requirements: PHP 8.2+, Laravel 10.48+/11.x/12.x/13.x. There is no service provider, no config file, no migration. Nothing to publish.
Add the trait and convert one relation.
use Illuminate\Database\Eloquent\Model;
use pindab0ter\ConstrainedMorphtoForLaravel\HasConstrainedMorphTo;
class Comment extends Model
{
use HasConstrainedMorphTo;
/** @return ConstrainedMorphTo<Post, $this> */
public function commentable()
{
return $this->constrainedMorphTo(
Post::class, // single type, or an array for a union
'commentable_type',
'commentable_id'
);
}
}
Signature order is ($types, $typeColumn, $idColumn, $relationName = null, $ownerKey = 'id'). Pass the last two only if the relation name or owner key differs from the defaults.
Where to look first.
README.md for the API surface — it's short and complete.CHANGELOG.md before every upgrade. The changelog is where the behavioural contracts actually live in this package.src/ConstrainedMorphTo.php (the MorphTo subclass) — read it once, it's small. You need to know exactly where the type check happens, because that decides how eager loading and whereHas behave for you.First use case. Comment/attachment/reaction/tag targets — low blast radius, read-heavy, and already tolerant of a missing parent in the UI. Do not debut this on payments, entitlements, permissions, or audit subjects.
First check before you commit. Write a throwaway test that creates a Comment with commentable_type = Image::class and asserts what $comment->commentable returns, then what Comment::with('commentable')->get() returns, then what Comment::whereHas('commentable', ...) returns. Those three answers determine whether the package fits your workload.
Convert relation by relation, not model by model. The trait is additive and per-relation, so incremental adoption is genuinely possible. A single model can have one constrained relation and one plain morphTo() side by side.
Single vs union constraints. Use the single-class form when the domain rule is "this column only ever points at a Post" — the annotation ConstrainedMorphTo<Post, $this> then gives you precise IDE completion. Use the array form when the schema genuinely allows several types:
/** @return ConstrainedMorphTo<Post|Video, $this> */
public function commentable()
{
return $this->constrainedMorphTo(
[Post::class, Video::class],
'commentable_type',
'commentable_id'
);
}
Keep the union list as the single source of truth. If a form request validates allowed types, derive the rule from the same array (pull it into a constant or a small allowedTypes() method on the model) rather than duplicating [Post::class, Video::class] in three places.
Use a morph map — this is a prerequisite, not a nicety. Constraining to raw FQCNs without a morph map just relocates the brittleness: a namespace move silently breaks every stored row. With a morph map in place, the constraint values and the stored *_type values are both aliases, and v1.3.0 makes the package work with them.
// AppServiceProvider::boot()
Relation::enforceMorphMap([
'post' => \App\Models\Post::class,
'video' => \App\Models\Video::class,
]);
Then constrainedMorphTo([Post::class, Video::class], ...) still takes class strings, but stored rows carry 'post' / 'video'. Confirm in a test that the constraint matches when the column holds an alias — this is the single highest-value test in your suite for this package.
Pair the read-path constraint with a write-path guard, in the same release. The package filters on read. It does not stop you persisting commentable_type = Image::class. Ship both together so the claim you make to reviewers is "rejected on write, filtered on read," not "can never happen."
// FormRequest rules
'commentable_type' => ['required', Rule::in(['post', 'video'])],
For non-HTTP write paths (jobs, imports, seeders, other services writing directly), add a model observer or a saving hook on the parent, or a DB-level check constraint where your engine supports it.
Eager loading. with('commentable') is where the constraint pays off — you avoid resolving types you never wanted. But verify the exact behaviour rather than assuming: check whether disallowed rows come back as null relations or are filtered at the query level, and check it against a mixed-type fixture. If the parent collection drives an aggregate count or a policy check, the difference matters.
whereMorphedTo() / orWhereMorphedTo(). These work on the constrained relation as of 1.3.1. If you have any existing whereMorphedTo() usage elsewhere in the app, run a regression pass — that's the surface with the most recent defect history, and the fix changed column qualification against the parent table.
Deprecation narrowing. This is the cleanest day-to-day use of the package that isn't just defensive. Shrinking a polymorphic column from [Post, Video, LegacyVideo] to [Post, Video] is a one-line change that immediately stops the old type leaking into new reads, giving you a transition window instead of a flag day:
null.Only do this if the null contract is acceptable during the window (see Gotchas).
Annotations. Use the generic form on every constrained relation — ConstrainedMorphTo<Post, $this> and ConstrainedMorphTo<Post|Video, $this>. Without it, static analysis degrades to Model|null and you lose the main ergonomic benefit. If you run PHPStan or Larastan, add the annotation in the same commit as the relation change; retrofitting later never happens.
null is ambiguous, and that is the biggest footgun. A disallowed type returns null, which is indistinguishable from a deleted parent, a missing row, or a soft-deleted parent. Correct for display and filtering. Wrong for anything where an absent relation must be loud. For money, permissions, audit subjects, or moderation state, wrap the accessor so a disallowed type throws or logs rather than silently vanishing:
public function commentableOrFail(): Post|Video
{
return $this->commentable ?? throw new RuntimeException(
"Comment {$this->id} has unresolvable commentable: {$this->commentable_type}#{$this->commentable_id}"
);
}
Also watch exists() and any if ($comment->commentable) branch — those now silently take the false path.
The write path is not guarded. Repeat: the package does not prevent bad types from being persisted. Everything above about validation and observers is not optional if you care about integrity.
Morph aliases vs class strings are a real mismatch risk. If enforceMorphMap() is configured but a legacy chunk of rows stores FQCNs (from before the map, or from a seeder written with ::class), those rows will not match a constraint expressed as aliases and will read as null. Audit the distinct values in every *_type column before rollout:
DB::table('comments')->select('commentable_type')->distinct()->pluck('commentable_type');
Then either normalise the data or accept a mixed representation deliberately.
Version-pin and vendor the code mentally. Three stars, zero dependents, single maintainer, MIT, tiny diff. Treat it as vendorable code: if upstream goes quiet, a mid-level engineer can own it permanently. Write that into the decision record rather than assuming support exists. Pin exactly ("1.3.1", not "^1.3") and read the changelog on every bump.
Don't assume untested Eloquent surfaces behave. The 1.3.1 column-qualification fix proves edge cases are still surfacing in released versions. Before relying on any of these, spike them with a mixed-type fixture in staging: load() / loadMissing() on a mixed collection, whereHas / orWhereHas, withCount, has(), associate() / dissociate(), touch(), cascading deletes, and soft-deleted parents. Where a surface misbehaves, fall back to a plain morphTo() plus an explicit ->whereIn('commentable_type', [...]) scoped by your own accessor.
associate() bypasses the constraint. It writes the type and id directly. If the allowlist is a domain rule, enforce it in associate() call sites (or override it) rather than trusting the read-path filter to catch the mistake later.
Composition with packages that intercept polymorphic relations. Activity logging, auditing, media libraries, soft-delete wrappers, and search indexers all touch morph relations. Confirm each still resolves constrained relations correctly — especially anything that calls getMorphClass(), iterates getRelations(), or replays relation loads through its own mechanism. Test with one such package before widening adoption.
Extension point, if you need one. Subclass ConstrainedMorphTo or add a model-level helper (e.g. constrainedMorphToOrFail()) rather than editing vendor code — the class is small enough that wrapping is cheap and keeps you upgradeable.
Debugging a null you didn't expect. Check, in order: (1) the raw *_type value versus your allowlist, accounting for morph aliases; (2) whether the id column actually points at an existing row; (3) whether the parent is soft-deleted; (4) whether the relation was loaded before the attributes were set. Nine times out of ten it's (1).
How can I help you explore Laravel packages today?