laravel/framework: [13.x] Fix `incrementOrCreate()` not setting the foreign key on relations
Contributed by xurshudyan
Part of the framework v13.35.0 release
Laravel patch releases rarely change behavior in ways that require thought, but PR #61849 is worth a close look if you use incrementOrCreate(). This bug fix addresses a silent data-integrity issue: calling incrementOrCreate() through an Eloquent relation (such as hasMany, morphMany, or belongsToMany) did not set the relation's foreign key. Every call therefore inserted a brand-new, unattached row with its count column reset, instead of finding the existing related record and incrementing it. Upgrading from v13.34.0 requires no code changes, but the behavior of code that "worked" before will visibly change — for the better.
What was broken
incrementOrCreate() is defined on the Eloquent builder. Relations don't define it themselves, and since Eloquent relations forward unknown method calls to their underlying query builder, $user->visits()->incrementOrCreate(['page' => '/home']) ended up executing Builder::incrementOrCreate(). That implementation runs its firstOrCreate() directly against the builder query, bypassing the relation's own firstOrCreate() — exactly the step where Laravel merges in the foreign key (user_id), the morph columns (visitable_type / visitable_id), or attaches the pivot record.
The consequence was orphan data:
$user->visits()->incrementOrCreate(['page' => '/home']);
$user->visits()->incrementOrCreate(['page' => '/home']);
// Before this fix: two rows with user_id = null and count = 1
// After this fix: one row with user_id = $user->id and count = 2
On a morphMany, both morph columns came out null; on a belongsToMany, the related model was created but never attached to the parent, so the pivot table stayed empty.
The fix: relation-aware incrementOrCreate()
The PR adds incrementOrCreate() to two relation base classes: HasOneOrMany and BelongsToMany. Because HasOne, HasMany, MorphOne, and MorphMany all extend HasOneOrMany, every "child record" style relation picks up the method, and BelongsToMany gets a version that creates and attaches in one go.
Both implementations delegate to the relation's own firstOrCreate(), which is what applies the foreign key constraints, and then increment only when the record already existed:
public function incrementOrCreate(
array $attributes,
string $column = 'count',
$default = 1,
$step = 1,
array $extra = []
) {
return tap($this->firstOrCreate($attributes, [$column => $default]), function ($instance) use ($column, $step, $extra) {
if (! $instance->wasRecentlyCreated) {
$instance->increment($column, $step, $extra);
}
});
}
The signature matches the builder version: $attributes is the lookup, $column is the counter (default count), $default is the starting value on creation, $step is how much to add on each subsequent call, and $extra lets you update additional columns within the same increment() query.
What it looks like after the upgrade
Counter-style relations now behave the way you would expect:
// hasMany / morphMany: the foreign key is set, the counter accumulates
$user->visits()->incrementOrCreate(['page' => '/home'], 'hits');
$user->visits()->incrementOrCreate(['page' => '/home'], 'hits');
// One row: user_id = $user->id, page = '/home', hits = 2
// belongsToMany: the tag is created once, attached once, and its count grows
$post->tags()->incrementOrCreate(['name' => 'laravel'], 'count');
$post->tags()->incrementOrCreate(['name' => 'laravel'], 'count');
// One tag named "laravel" with count = 2, and exactly one pivot row
The new integration tests assert exactly this: the two calls return the same model instance, wasRecentlyCreated flips from true to false, the foreign key matches the parent, and the relation contains a single record.
Upgrade impact
Moving from v13.34.0 to the release containing this fix is drop-in — there are no new config options, migrations, or required code changes. Still, four things are worth checking:
- Expect fewer rows. Any loop calling
incrementOrCreate()through a relation will now produce one attached, accumulating row instead of one orphan per call. If anything downstream depended on the old row-per-call behavior (reports, imports), re-check the numbers. - Clean up historical orphans. Rows written by the buggy path keep their
nullforeign keys. A backfill query or a migration that reattaches or deletes them is worth scheduling. - Remove workarounds. If you merged the foreign key into the attributes manually — e.g.
incrementOrCreate(['user_id' => $user->id, 'page' => '/home'])— that still works, but the plain form is now safe and more readable. - Watch for collisions. If you subclass relations or registered an
incrementOrCreatemacro with a different signature, reconcile it with the native method.
Takeaways
incrementOrCreate()now works correctly onhasOne,hasMany,morphOne,morphMany, andbelongsToManyby routing through the relation's ownfirstOrCreate().- Foreign keys, morph columns, and pivot rows are populated as expected; counters only increment on records that already existed.
- The signature is unchanged from the builder version:
($attributes, $column = 'count', $default = 1, $step = 1, $extra = []). - No breaking changes, but audit data written before the fix for orphaned rows and drop any manual foreign-key workarounds.