Skip to content

[13.x] Fix incrementEach/decrementEach to scope to model instance#59376

Merged
taylorotwell merged 3 commits intolaravel:13.xfrom
JoshSalway:fix/incrementEach-model-scoping
Mar 26, 2026
Merged

[13.x] Fix incrementEach/decrementEach to scope to model instance#59376
taylorotwell merged 3 commits intolaravel:13.xfrom
JoshSalway:fix/incrementEach-model-scoping

Conversation

@JoshSalway
Copy link
Copy Markdown
Contributor

@JoshSalway JoshSalway commented Mar 26, 2026

Summary

Fixes 🟢 #57262. Also addresses 🔴 #48595, 🔴 #49009, 🔴 #52419, 🔴 #57302.

Aligns $model->incrementEach() and $model->decrementEach() behavior with $model->increment() and $model->decrement() when called on a model instance. Currently, calling incrementEach on an instance forwards to the base Query Builder without scoping to the model's primary key. This PR adds the Model and Builder-level methods to match the existing increment/decrement pattern.

Before

$post = Post::find(1);
$post->incrementEach(['views' => 1, 'likes' => 1]);
UPDATE posts SET views = views + 1, likes = likes + 1
-- All rows affected — not scoped to the model instance
| id | title        | views | likes |          | id | title        | views | likes |
|----|--------------|-------|-------|    →     |----|--------------|-------|-------|
| 1  | Hello World  | 10    | 5     |          | 1  | Hello World  | 11    | 6     |
| 2  | Laravel Tips | 50    | 20    |          | 2  | Laravel Tips | 51    | 21    | ← not intended
| 3  | PHP 8.5      | 100   | 40    |          | 3  | PHP 8.5      | 101   | 41    | ← not intended

After

$post = Post::find(1);
$post->incrementEach(['views' => 1, 'likes' => 1]);
UPDATE posts SET views = views + 1, likes = likes + 1, updated_at = '...' WHERE id = 1
-- Only the model instance is updated
| id | title        | views | likes |          | id | title        | views | likes |
|----|--------------|-------|-------|    →     |----|--------------|-------|-------|
| 1  | Hello World  | 10    | 5     |          | 1  | Hello World  | 11    | 6     | ← only this row
| 2  | Laravel Tips | 50    | 20    |          | 2  | Laravel Tips | 50    | 20    |
| 3  | PHP 8.5      | 100   | 40    |          | 3  | PHP 8.5      | 100   | 40    |

Query builder usage is unchanged:

// Still updates all matching rows, as expected
Post::where('published', true)->incrementEach(['views' => 1]);

Changes

Model-level (Model.php):

  • Adds incrementEach(), decrementEach(), and incrementOrDecrementEach() methods mirroring the existing incrementOrDecrement() pattern
  • Scopes query to primary key via setKeysForSaveQuery()
  • Handles class-castable columns via isClassDeviable() / deviateClassCastableAttribute()
  • Fires updating/updated model events
  • Syncs in-memory attributes and uses syncOriginalAttributes() scoped to only the affected columns
  • Non-existing models forward to query builder
  • Registered in __call() so model instance calls are intercepted

Builder-level (Builder.php):

  • Adds incrementEach() and decrementEach() that call addUpdatedAtColumn()
  • Ensures updated_at is automatically set, consistent with increment()/decrement()

Attribution

Builds on @sumaiazaman's draft ⚪ #59065.

Test plan

Integration tests (7 tests in EloquentUpdateTest.php — real database):

  • testIncrementEachOnModelInstanceOnlyAffectsThatRow — 3 rows, only target row changes
  • testDecrementEachOnModelInstanceOnlyAffectsThatRow — 2 rows, only target row changes
  • testIncrementEachViaQueryBuilderStillAffectsAllMatchingRows — mass update unchanged
  • testIncrementEachOnModelInstanceUpdatesTimestamps — updated_at automatically set
  • testIncrementEachOnSoftDeletedModelIgnoresGlobalScopes — works on trashed models
  • testIncrementEachDoesNotResetUnrelatedDirtyAttributes — dirty name preserved after incrementing views
  • testIncrementEachSyncsPrevious — getChanges() reflects incremented columns

Unit tests — Model (6 tests in DatabaseEloquentModelTest.php):

  • testIncrementEachOnExistingModelScopesQueryToModelKey — WHERE clause + attribute sync
  • testDecrementEachOnExistingModelScopesQueryToModelKey — WHERE clause + attribute sync
  • testIncrementEachWithExtraColumnsOnExistingModel — $extra parameters propagated
  • testIncrementEachFiresModelEvents — updating/updated events fire
  • testIncrementEachReturnsFalseWhenUpdatingEventCancelled — event cancellation respected
  • testIncrementEachOnNonExistingModelForwardsToQueryBuilder — non-existing model path

Unit tests — Builder (3 tests in DatabaseEloquentBuilderTest.php):

  • testIncrementEachCallsToBaseWithUpdatedAt — updated_at automatically added
  • testDecrementEachCallsToBaseWithUpdatedAt — updated_at automatically added
  • testIncrementEachWithoutTimestamps — models without timestamps skip updated_at

Post-merge

@github-actions
Copy link
Copy Markdown

Thanks for submitting a PR!

Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@JoshSalway JoshSalway force-pushed the fix/incrementEach-model-scoping branch 2 times, most recently from a41922b to beedcef Compare March 26, 2026 03:41
@JoshSalway JoshSalway marked this pull request as ready for review March 26, 2026 12:18
JoshSalway and others added 2 commits March 26, 2026 22:27
…odel instance

Fixes laravel#57262.

Model-level: Adds incrementEach/decrementEach methods that scope to the
model's primary key, fire updating/updated events, and sync in-memory
attributes — mirroring the existing incrementOrDecrement pattern.

Builder-level: Adds incrementEach/decrementEach methods that call
addUpdatedAtColumn, ensuring updated_at is automatically set —
consistent with how increment/decrement already behave.

Co-Authored-By: sumaiazaman <saktar50.cse@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add isClassDeviable/deviateClassCastableAttribute handling per column,
  matching incrementOrDecrement behavior for Money/custom cast objects
- Use syncOriginalAttributes(array_keys($columns)) instead of
  syncOriginal() to avoid marking unrelated dirty attributes as clean
- Set attributes before event check, matching increment() behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JoshSalway JoshSalway changed the base branch from 12.x to 13.x March 26, 2026 12:27
@JoshSalway JoshSalway force-pushed the fix/incrementEach-model-scoping branch from 729a3e1 to aef4c87 Compare March 26, 2026 12:27
@JoshSalway JoshSalway changed the title [12.x] Fix incrementEach/decrementEach updating all rows instead of model instance [13.x] Align incrementEach/decrementEach with increment/decrement on model instances Mar 26, 2026
Tests against a real database to verify:
- Instance call only affects that row (other rows unchanged)
- decrementEach only affects that row
- Query builder path still affects all matching rows
- Timestamps are updated automatically
- Soft-deleted models work (ignores global scopes)
- Unrelated dirty attributes are preserved
- Changes and previous values sync correctly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JoshSalway JoshSalway changed the title [13.x] Align incrementEach/decrementEach with increment/decrement on model instances [13.x] Fix incrementEach/decrementEach to scope to model instance Mar 26, 2026
@taylorotwell taylorotwell merged commit 8c69608 into laravel:13.x Mar 26, 2026
52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants