From 858df49c3105e402720bc1f3a4be9d0ef9ae12b2 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 00:40:50 +0200 Subject: [PATCH 01/27] POC for https://github.com/laravel/framework/discussions/31778 + https://github.com/laravel/framework/pull/57627 + https://github.com/macropay-solutions/maravel-framework/pull/21/files --- .../HasCleverRelationships.php | 21 ++++ src/Models/BaseModel.php | 111 +++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/Eloquent/CustomRelations/HasCleverRelationships.php b/src/Eloquent/CustomRelations/HasCleverRelationships.php index b2235ed..45f2848 100644 --- a/src/Eloquent/CustomRelations/HasCleverRelationships.php +++ b/src/Eloquent/CustomRelations/HasCleverRelationships.php @@ -397,6 +397,27 @@ public function __construct( $relationName ); } + + /** + * @inheritdoc + */ + protected function migratePivotAttributes(Model $model): array + { + $values = []; + + foreach (\array_keys($model->getAttributes(true)) as $key) { + // To get the pivots attributes we will just take any of the attributes which + // begin with "pivot_" and add those to this arrays, as well as unsetting + // them from the parent's models since they exist in a different table. + if (str_starts_with($key, 'pivot_')) { + $values[substr($key, 6)] = $model->getAttributeFromArray($key); + + unset($model->$key); + } + } + + return $values; + } }; } diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 9469d20..67a3652 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -3,6 +3,7 @@ namespace MacropaySolutions\LaravelCrudWizard\Models; use Carbon\Carbon; +use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -478,7 +479,13 @@ protected function initializeActiveRecordSegregationProperties(): void */ public function __call($method, $parameters) { - if (\in_array(\strtolower($method), ['incrementeach', 'decrementeach'], true)) { + $lowerMethod = \strtolower($method); + + if ($lowerMethod === 'getattributefromarray') { + return $this->$method(...$parameters); + } + + if (\in_array($lowerMethod, ['incrementeach', 'decrementeach'], true)) { /** \MacropaySolutions\MaravelRestWizard\Models\BaseModel::incrementBulk can be used instead */ throw new \BadMethodCallException(\sprintf( 'Call to unscoped method %s::%s(). Use $model->newQuery()->getQuery()->%s()' . @@ -555,4 +562,106 @@ public function unique($key = null, $strict = false): Collection } }; } + + /** + * Get all of the current attributes on the model. + * @param bool $withoutCasting + * @return array + */ + public function getAttributes(): array + { + if (true !== \func_get_arg(0)) { + $this->mergeAttributesFromCachedCasts(); + } + + return $this->attributes; + } + + /** + * @inheritdoc + */ + public function syncOriginalAttributes($attributes): static + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + foreach ($attributes as $attribute) { + $this->original[$attribute] = $this->getAttributeFromArray($attribute); + } + + return $this; + } + + /** + * Get an attribute from the $attributes array without transformation + * @see self::getAttributeValue + * + * @param string $key + * @return mixed + */ + protected function getAttributeFromArray($key): mixed + { + $this->mergeAttributesFromClassCasts($key); + $this->mergeAttributesFromAttributeCasts($key); + + return $this->attributes[$key] ?? null; + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromClassCasts(): void + { + $k = \func_get_args()[0] ?? null; + + $classCastCache = \is_string($k) || \is_array($k) ? + \array_intersect_key($this->classCastCache, \array_flip(\array_values((array)$k))) : + $this->classCastCache; + + foreach ($classCastCache as $key => $value) { + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_merge( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromAttributeCasts(): void + { + $k = \func_get_args()[0] ?? null; + + $attributeCastCache = \is_string($k) || \is_array($k) ? + \array_intersect_key($this->attributeCastCache, \array_flip(\array_values((array)$k))) : + $this->attributeCastCache; + + foreach ($attributeCastCache as $key => $value) { + $attribute = $this->{Str::camel($key)}(); + + if ($attribute->get && !$attribute->set) { + continue; + } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); + } + } } From 8899de94dbed88bca7cee039f1640b9505408d11 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 00:46:21 +0200 Subject: [PATCH 02/27] POC for https://github.com/laravel/framework/discussions/31778 + https://github.com/laravel/framework/pull/57627 + https://github.com/macropay-solutions/maravel-framework/pull/21/files --- src/Models/BaseModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 67a3652..cf39c4c 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -570,7 +570,7 @@ public function unique($key = null, $strict = false): Collection */ public function getAttributes(): array { - if (true !== \func_get_arg(0)) { + if (true !== (\func_get_args()[0] ?? null)) { $this->mergeAttributesFromCachedCasts(); } From c51442f6ab2c2c049700224035f63efab2436d63 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 10:43:03 +0200 Subject: [PATCH 03/27] POC for https://github.com/laravel/framework/discussions/31778 improve getDirty calls https://github.com/laravel/framework/pull/57627 --- src/Models/BaseModel.php | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index cf39c4c..a2c654e 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -47,6 +48,13 @@ abstract class BaseModel extends Model protected $hidden = [ 'laravel_through_key' ]; + + /** + * Temporary cache to avoid multiple getDirty calls generating multiple set calls for + * sync/merge casted attributes to objects to persist the possible changes made to those objects + */ + protected ?array $tmpDirtyCache = null; + private array $incrementsToRefresh = []; /** @@ -591,6 +599,83 @@ public function syncOriginalAttributes($attributes): static return $this; } + /** + * @inheritdoc + */ + public function isDirty($attributes = null): bool + { + return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); + } + + /** + * Get the attributes that have been changed since the last sync. + * @param string|array $attributes + * @return array + */ + public function getDirty(): array + { + if (isset($this->tmpDirtyCache)) { + if ([] !== $args = \func_get_args()) { + return \array_intersect_key($this->tmpDirtyCache, \array_flip((array)$args[0])); + } + + return $this->tmpDirtyCache; + } + + $attributes = (array)(\func_get_args()[0] ?? \array_keys($this->attributes)); + + $dirty = []; + + foreach ($attributes as $key) { + // this will merge/sync before the if condition + $value = $this->getAttributeFromArray($key); + + if (!$this->originalIsEquivalent($key)) { + $dirty[$key] = $value; + } + } + + return $dirty; + } + + /** + * @inheritdoc + */ + protected function performUpdate(Builder $query): bool + { + if ($this->fireModelEvent('updating') === false) { + return false; + } + + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + // this is needed because updating event might change the model + $dirty = $this->getDirtyForUpdate(); + + if ([] !== $dirty) { + $this->setKeysForSaveQuery($query)->update($dirty); + $this->tmpDirtyCache = $dirty; + $this->syncChanges(); + unset($this->tmpDirtyCache); + + $this->fireModelEvent('updated', false); + } + + return true; + } + + /** + * Get the attributes that have been changed since the last sync for an update operation. + * + * @return array + */ + protected function getDirtyForUpdate(): array + { + return $this->getDirty(); + } + /** * Get an attribute from the $attributes array without transformation * @see self::getAttributeValue From 55f0ff65eae0e6ac7d22be0a507298d2b6aeb514 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 17:57:04 +0200 Subject: [PATCH 04/27] POC for https://github.com/laravel/framework/discussions/31778 bullet proofing --- src/Models/BaseModel.php | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index a2c654e..729ee1b 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -614,15 +614,16 @@ public function isDirty($attributes = null): bool */ public function getDirty(): array { - if (isset($this->tmpDirtyCache)) { - if ([] !== $args = \func_get_args()) { - return \array_intersect_key($this->tmpDirtyCache, \array_flip((array)$args[0])); - } + $args = \func_get_args(); + $attributes = (array)($args[0] ?? []); - return $this->tmpDirtyCache; + if (isset($this->tmpDirtyCache)) { + return [] !== $attributes ? + \array_intersect_key($this->tmpDirtyCache, \array_flip($attributes)) : + $this->tmpDirtyCache; } - $attributes = (array)(\func_get_args()[0] ?? \array_keys($this->attributes)); + $attributes = [] !== $attributes ? $attributes : \array_keys($this->attributes); $dirty = []; @@ -656,9 +657,17 @@ protected function performUpdate(Builder $query): bool if ([] !== $dirty) { $this->setKeysForSaveQuery($query)->update($dirty); - $this->tmpDirtyCache = $dirty; - $this->syncChanges(); - unset($this->tmpDirtyCache); + + try { + $this->tmpDirtyCache = $dirty; + $this->syncChanges(); + } catch (\Throwable $e) { + unset($this->tmpDirtyCache); + + throw $e; + } finally { + unset($this->tmpDirtyCache); + } $this->fireModelEvent('updated', false); } From 24b23428616c2aae812604aa5d4bf8d5462ad4e4 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 18:02:19 +0200 Subject: [PATCH 05/27] POC for https://github.com/laravel/framework/discussions/31778 bullet proofing --- src/Models/BaseModel.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 729ee1b..6cab6fb 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -661,10 +661,6 @@ protected function performUpdate(Builder $query): bool try { $this->tmpDirtyCache = $dirty; $this->syncChanges(); - } catch (\Throwable $e) { - unset($this->tmpDirtyCache); - - throw $e; } finally { unset($this->tmpDirtyCache); } From d769af51ec9b76c5d73df895406d770187c7c486 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 18:11:43 +0200 Subject: [PATCH 06/27] POC for https://github.com/laravel/framework/discussions/31778 bullet proofing --- src/Http/Controllers/ResourceControllerTrait.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/ResourceControllerTrait.php b/src/Http/Controllers/ResourceControllerTrait.php index cfc1e2d..3f20421 100644 --- a/src/Http/Controllers/ResourceControllerTrait.php +++ b/src/Http/Controllers/ResourceControllerTrait.php @@ -156,12 +156,12 @@ public function update(string $identifier, Request $request): JsonResponse try { $model = $this->resourceService->get($identifier, appendIndex: false); - $model->fill(GeneralHelper::filterDataByKeys($all, \array_diff( + $model->fill($filled = GeneralHelper::filterDataByKeys($all, \array_diff( $this->resourceService->getModelColumns(), $this->resourceService->getIgnoreExternalUpdateFor() ))); /** $request can contain also files so, overwrite this function to handle them */ - $request->forceReplace($model->getDirty()); + $request->forceReplace($model->getDirty(\array_keys($filled))); return GeneralHelper::app(JsonResponse::class, [ 'data' => $this->resourceService->update($identifier, $this->validateUpdateRequest($request)) From 650073af7657b994f94c7d914185cc6777cc9def Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 18:17:39 +0200 Subject: [PATCH 07/27] POC for https://github.com/laravel/framework/discussions/31778 bullet proofing --- src/Http/Controllers/ResourceControllerTrait.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/ResourceControllerTrait.php b/src/Http/Controllers/ResourceControllerTrait.php index 3f20421..bc86e56 100644 --- a/src/Http/Controllers/ResourceControllerTrait.php +++ b/src/Http/Controllers/ResourceControllerTrait.php @@ -155,13 +155,21 @@ public function update(string $identifier, Request $request): JsonResponse $all = $request->all(); try { - $model = $this->resourceService->get($identifier, appendIndex: false); - $model->fill($filled = GeneralHelper::filterDataByKeys($all, \array_diff( + $toFill = GeneralHelper::filterDataByKeys($all, \array_diff( $this->resourceService->getModelColumns(), $this->resourceService->getIgnoreExternalUpdateFor() - ))); + )); + + if ($toFill === []) { + return GeneralHelper::app(JsonResponse::class, [ + 'data' => $this->resourceService->get($identifier)->toArray(), + 'status' => 200 + ]); + } + + $model = $this->resourceService->get($identifier, appendIndex: false)->fill($toFill); /** $request can contain also files so, overwrite this function to handle them */ - $request->forceReplace($model->getDirty(\array_keys($filled))); + $request->forceReplace($model->getDirty(\array_keys($toFill))); return GeneralHelper::app(JsonResponse::class, [ 'data' => $this->resourceService->update($identifier, $this->validateUpdateRequest($request)) From 8e1719b09de9f0a0f16f42eeaf05b798e40250c8 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Sun, 2 Nov 2025 23:52:13 +0200 Subject: [PATCH 08/27] POC for https://github.com/laravel/framework/discussions/31778 avoid isDirty call from finishSave --- src/Models/BaseModel.php | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 6cab6fb..a808d4f 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; +use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; @@ -639,6 +640,72 @@ public function getDirty(): array return $dirty; } + /** + * @inheritdoc + */ + public function save(array $options = []): bool + { + $this->mergeAttributesFromCachedCasts(); + + $query = $this->newModelQuery(); + + // If the "saving" event returns false we'll bail out of the save and return + // false, indicating that the save failed. This provides a chance for any + // listeners to cancel save operations if validations fail or whatever. + if ($this->fireModelEvent('saving') === false) { + return false; + } + + // If the model already exists in the database we can just update our record + // that is already in this database using the current IDs in this "where" + // clause to only update this model. Otherwise, we'll just insert them. + if ($this->exists) { + if (!$this->isDirty()) { + return true; + } + + if ($this->performUpdate($query)) { + $this->finishSave($options + ['touch' => true]); + + return true; + } + + return false; + } + + // If the model is brand new, we'll insert it into our database and set the + // ID attribute on the model to the value of the newly inserted row's ID + // which is typically an auto-increment value managed by the database. + $saved = $this->performInsert($query); + + if ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } + + if ($saved) { + $this->finishSave(['touch' => $this->isDirty()]); + } + + return $saved; + } + + /** + * @inheritdoc + */ + protected function finishSave(array $options): void + { + $this->fireModelEvent('saved', false); + + if ($options['touch'] ?? true) { + $this->touchOwners(); + } + + $this->syncOriginal(); + } + /** * @inheritdoc */ From b46eef199cc7a76a7c04cd137461d958471cceaa Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Mon, 3 Nov 2025 09:51:20 +0200 Subject: [PATCH 09/27] POC for https://github.com/laravel/framework/discussions/31778 reduce even more the number of set calls during save --- src/Models/BaseModel.php | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index a808d4f..ef258dd 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -645,32 +645,50 @@ public function getDirty(): array */ public function save(array $options = []): bool { - $this->mergeAttributesFromCachedCasts(); - $query = $this->newModelQuery(); // If the "saving" event returns false we'll bail out of the save and return // false, indicating that the save failed. This provides a chance for any // listeners to cancel save operations if validations fail or whatever. + /** Saving event might change the model so, no point in calling $this->mergeAttributesFromCachedCasts() before */ if ($this->fireModelEvent('saving') === false) { return false; } + /** $this->isDirty() will call $this->mergeAttributesFromCachedCasts() */ + $isDirty = $this->isDirty(); + // If the model already exists in the database we can just update our record // that is already in this database using the current IDs in this "where" // clause to only update this model. Otherwise, we'll just insert them. if ($this->exists) { - if (!$this->isDirty()) { + if (!$isDirty) { + /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ return true; } - if ($this->performUpdate($query)) { - $this->finishSave($options + ['touch' => true]); - - return true; + try { + /** To avoid excessive set calls (https://github.com/laravel/framework/discussions/31778) when: + - calling $this->getDirtyForUpdate() which calls $this->getDirty() after updating event, + - calling $this->finishSave which calls $this->syncOriginal() which calls $this->getAttributes(), + cache must be temporarily cleared */ + $classCastCache = $this->classCastCache; + $attributeCastCache = $this->attributeCastCache; + $this->classCastCache = []; + $this->attributeCastCache = []; + + if ($this->performUpdate($query)) { + $this->finishSave($options + ['touch' => $isDirty]); + + return true; + } + + return false; + } finally { + /** Reset cache by preserving the new cached objects created in updating/updated/saved events */ + $this->classCastCache += $classCastCache; + $this->attributeCastCache += $attributeCastCache; } - - return false; } // If the model is brand new, we'll insert it into our database and set the @@ -686,7 +704,7 @@ public function save(array $options = []): bool } if ($saved) { - $this->finishSave(['touch' => $this->isDirty()]); + $this->finishSave(['touch' => $isDirty]); } return $saved; From 6936364640dbd8608abe1aad70899d84e4efbe51 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Mon, 3 Nov 2025 10:10:19 +0200 Subject: [PATCH 10/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index ef258dd..372c249 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -574,7 +574,7 @@ public function unique($key = null, $strict = false): Collection /** * Get all of the current attributes on the model. - * @param bool $withoutCasting + * @param bool $withoutMergeAttributesFromCachedCasts * @return array */ public function getAttributes(): array From 1b3b5081018e193cff6b66e45cdec08fa7397aec Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Mon, 3 Nov 2025 12:24:35 +0200 Subject: [PATCH 11/27] POC for https://github.com/laravel/framework/discussions/31778 reduce even more the number of set calls during insert also --- src/Models/BaseModel.php | 73 +++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 372c249..8f65692 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -629,7 +629,7 @@ public function getDirty(): array $dirty = []; foreach ($attributes as $key) { - // this will merge/sync before the if condition + /** This will call $this->mergeAttributesFromCachedCasts($key) before the if condition */ $value = $this->getAttributeFromArray($key); if (!$this->originalIsEquivalent($key)) { @@ -658,24 +658,22 @@ public function save(array $options = []): bool /** $this->isDirty() will call $this->mergeAttributesFromCachedCasts() */ $isDirty = $this->isDirty(); - // If the model already exists in the database we can just update our record - // that is already in this database using the current IDs in this "where" - // clause to only update this model. Otherwise, we'll just insert them. - if ($this->exists) { - if (!$isDirty) { - /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ - return true; - } - - try { - /** To avoid excessive set calls (https://github.com/laravel/framework/discussions/31778) when: - - calling $this->getDirtyForUpdate() which calls $this->getDirty() after updating event, - - calling $this->finishSave which calls $this->syncOriginal() which calls $this->getAttributes(), - cache must be temporarily cleared */ - $classCastCache = $this->classCastCache; - $attributeCastCache = $this->attributeCastCache; - $this->classCastCache = []; - $this->attributeCastCache = []; + try { + /** To avoid excessive set calls (https://github.com/laravel/framework/discussions/31778) when: + - calling $this->getDirtyForUpdate() which calls $this->getDirty() after updating event, + - calling $this->getAttributesForInsert() which calls $this->getAttributes() after creating event, + - calling $this->finishSave which calls $this->syncOriginal() which calls $this->getAttributes(), + cache must be temporarily cleared */ + $classCastCache = $this->classCastCache; + $attributeCastCache = $this->attributeCastCache; + $this->classCastCache = []; + $this->attributeCastCache = []; + + if ($this->exists) { + if (!$isDirty) { + /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ + return true; + } if ($this->performUpdate($query)) { $this->finishSave($options + ['touch' => $isDirty]); @@ -684,30 +682,27 @@ public function save(array $options = []): bool } return false; - } finally { - /** Reset cache by preserving the new cached objects created in updating/updated/saved events */ - $this->classCastCache += $classCastCache; - $this->attributeCastCache += $attributeCastCache; } - } - // If the model is brand new, we'll insert it into our database and set the - // ID attribute on the model to the value of the newly inserted row's ID - // which is typically an auto-increment value managed by the database. - $saved = $this->performInsert($query); + $saved = $this->performInsert($query); - if ( - '' === (string)$this->getConnectionName() && - ($connection = $query->getConnection()) instanceof Connection - ) { - $this->setConnection($connection->getName()); - } + if ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } - if ($saved) { - $this->finishSave(['touch' => $isDirty]); - } + if ($saved) { + $this->finishSave(['touch' => $isDirty]); + } - return $saved; + return $saved; + } finally { + /** Reset cache by preserving the new cached objects created in updating/updated/created/creating/saved events */ + $this->classCastCache += $classCastCache; + $this->attributeCastCache += $attributeCastCache; + } } /** @@ -737,7 +732,7 @@ protected function performUpdate(Builder $query): bool $this->updateTimestamps(); } - // this is needed because updating event might change the model + /** This is needed because updating event might change the model */ $dirty = $this->getDirtyForUpdate(); if ([] !== $dirty) { From d55e116748077eeb3af92e259db0599ac60bf1c8 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Mon, 3 Nov 2025 17:18:18 +0200 Subject: [PATCH 12/27] POC for https://github.com/laravel/framework/discussions/31778 handle detached casted objects from $model and revert reduce even more the number of set calls during insert also --- src/Models/BaseModel.php | 115 +++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 42 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 8f65692..5ea8c69 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -54,7 +54,7 @@ abstract class BaseModel extends Model * Temporary cache to avoid multiple getDirty calls generating multiple set calls for * sync/merge casted attributes to objects to persist the possible changes made to those objects */ - protected ?array $tmpDirtyCache = null; + protected ?array $tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; private array $incrementsToRefresh = []; @@ -608,6 +608,16 @@ public function isDirty($attributes = null): bool return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); } + /** + * @inheritdoc + */ + public function syncOriginal(): static + { + $this->original = $this->getAttributes(isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)); + + return $this; + } + /** * Get the attributes that have been changed since the last sync. * @param string|array $attributes @@ -618,10 +628,10 @@ public function getDirty(): array $args = \func_get_args(); $attributes = (array)($args[0] ?? []); - if (isset($this->tmpDirtyCache)) { + if (isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)) { return [] !== $attributes ? - \array_intersect_key($this->tmpDirtyCache, \array_flip($attributes)) : - $this->tmpDirtyCache; + \array_intersect_key($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts, \array_flip($attributes)) : + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts; } $attributes = [] !== $attributes ? $attributes : \array_keys($this->attributes); @@ -655,24 +665,24 @@ public function save(array $options = []): bool return false; } - /** $this->isDirty() will call $this->mergeAttributesFromCachedCasts() */ - $isDirty = $this->isDirty(); + if ($this->exists) { + /** $this->getDirty() will merge/sync attributes from cached casts objects */ + $isDirty = [] !== ($dirty = $this->getDirty()); - try { - /** To avoid excessive set calls (https://github.com/laravel/framework/discussions/31778) when: - - calling $this->getDirtyForUpdate() which calls $this->getDirty() after updating event, - - calling $this->getAttributesForInsert() which calls $this->getAttributes() after creating event, - - calling $this->finishSave which calls $this->syncOriginal() which calls $this->getAttributes(), - cache must be temporarily cleared */ - $classCastCache = $this->classCastCache; - $attributeCastCache = $this->attributeCastCache; - $this->classCastCache = []; - $this->attributeCastCache = []; - - if ($this->exists) { - if (!$isDirty) { - /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ - return true; + if (!$isDirty) { + /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ + return true; + } + + try { + /** We will try to optimize the execution by caching $dirty array BUT, + multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) + WHEN $this->usesTimestamps() or WHEN there are listeners for the following events: + - updating event can do changes so, $this->getDirtyForUpdate() will call $this->getDirty(), + - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + if (!$this->getEventDispatcher()->hasListeners($this::class . '.updating')) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; + unset($dirty); } if ($this->performUpdate($query)) { @@ -682,27 +692,33 @@ public function save(array $options = []): bool } return false; + } finally { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } + } - $saved = $this->performInsert($query); + /** $this->isDirty() will merge/sync attributes from cached casts objects */ + $isDirty = $this->isDirty(); - if ( - '' === (string)$this->getConnectionName() && - ($connection = $query->getConnection()) instanceof Connection - ) { - $this->setConnection($connection->getName()); - } + /** Multiple set calls are needed (https://github.com/laravel/framework/discussions/31778) because: + - $this->performInsert can do changes, + - creating event can do changes so, $this->getAttributesForInsert() will call $this->getAttributes(), + - created/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ - if ($saved) { - $this->finishSave(['touch' => $isDirty]); - } + $saved = $this->performInsert($query); + + if ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } - return $saved; - } finally { - /** Reset cache by preserving the new cached objects created in updating/updated/created/creating/saved events */ - $this->classCastCache += $classCastCache; - $this->attributeCastCache += $attributeCastCache; + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); } + + return $saved; } /** @@ -710,6 +726,10 @@ public function save(array $options = []): bool */ protected function finishSave(array $options): void { + if ($this->getEventDispatcher()->hasListeners($this::class . '.saved')) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + } + $this->fireModelEvent('saved', false); if ($options['touch'] ?? true) { @@ -724,25 +744,36 @@ protected function finishSave(array $options): void */ protected function performUpdate(Builder $query): bool { + // If the updating event returns false, we will cancel the update operation so + // developers can hook Validation systems into their models and cancel this + // operation if the model does not pass validation. Otherwise, we update. if ($this->fireModelEvent('updating') === false) { return false; } + // First we need to create a fresh query instance and touch the creation and + // update timestamp on the model which are maintained by us for developer + // convenience. Then we will just continue saving the model instances. if ($this->usesTimestamps()) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; $this->updateTimestamps(); } - /** This is needed because updating event might change the model */ + // Once we have run the update operation, we will fire the "updated" event for + // this model instance. This will allow developers to hook into these after + // models are updated, giving them a chance to do any special processing. $dirty = $this->getDirtyForUpdate(); if ([] !== $dirty) { $this->setKeysForSaveQuery($query)->update($dirty); - try { - $this->tmpDirtyCache = $dirty; - $this->syncChanges(); - } finally { - unset($this->tmpDirtyCache); + $this->syncChanges(); + + if ( + isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) + && $this->getEventDispatcher()->hasListeners($this::class . '.updated') + ) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } $this->fireModelEvent('updated', false); From f706b2c59ce12c470d49e2d4e70ee8ec4a0e8987 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Mon, 3 Nov 2025 17:40:28 +0200 Subject: [PATCH 13/27] Add trait for testing --- .../ExcessiveSetOptimizerOnSaveTrait.php | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 src/Models/ExcessiveSetOptimizerOnSaveTrait.php diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php new file mode 100644 index 0000000..d607038 --- /dev/null +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -0,0 +1,333 @@ +$method(...$parameters); + } + + if (\in_array($lowerMethod, ['incrementeach', 'decrementeach'], true)) { + /** \MacropaySolutions\MaravelRestWizard\Models\BaseModel::incrementBulk can be used instead */ + throw new \BadMethodCallException(\sprintf( + 'Call to unscoped method %s::%s(). Use $model->newQuery()->getQuery()->%s()' . + ' for unscoped or $model->newQuery()->%s() for scoped behavior.', + static::class, + $method, + $method, + $method, + )); + } + return parent::__call($method, $parameters); + } + + /** + * Get all of the current attributes on the model. + * @param bool $withoutMergeAttributesFromCachedCasts + * @return array + */ + public function getAttributes(): array + { + if (true !== (\func_get_args()[0] ?? null)) { + $this->mergeAttributesFromCachedCasts(); + } + + return $this->attributes; + } + + /** + * @inheritdoc + */ + public function syncOriginalAttributes($attributes): static + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + foreach ($attributes as $attribute) { + $this->original[$attribute] = $this->getAttributeFromArray($attribute); + } + + return $this; + } + + /** + * @inheritdoc + */ + public function isDirty($attributes = null): bool + { + return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); + } + + /** + * @inheritdoc + */ + public function syncOriginal(): static + { + $this->original = $this->getAttributes(isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)); + + return $this; + } + + /** + * Get the attributes that have been changed since the last sync. + * @param string|array $attributes + * @return array + */ + public function getDirty(): array + { + $args = \func_get_args(); + $attributes = (array)($args[0] ?? []); + + if (isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)) { + return [] !== $attributes ? + \array_intersect_key($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts, \array_flip($attributes)) : + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts; + } + + $attributes = [] !== $attributes ? $attributes : \array_keys($this->attributes); + + $dirty = []; + + foreach ($attributes as $key) { + /** This will call $this->mergeAttributesFromCachedCasts($key) before the if condition */ + $value = $this->getAttributeFromArray($key); + + if (!$this->originalIsEquivalent($key)) { + $dirty[$key] = $value; + } + } + + return $dirty; + } + + /** + * @inheritdoc + */ + public function save(array $options = []): bool + { + $query = $this->newModelQuery(); + + // If the "saving" event returns false we'll bail out of the save and return + // false, indicating that the save failed. This provides a chance for any + // listeners to cancel save operations if validations fail or whatever. + /** Saving event might change the model so, no point in calling $this->mergeAttributesFromCachedCasts() before */ + if ($this->fireModelEvent('saving') === false) { + return false; + } + + if ($this->exists) { + /** $this->getDirty() will merge/sync attributes from cached casts objects */ + $isDirty = [] !== ($dirty = $this->getDirty()); + + if (!$isDirty) { + /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ + return true; + } + + try { + /** We will try to optimize the execution by caching $dirty array BUT, + multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) + WHEN $this->usesTimestamps() or WHEN there are listeners for the following events: + - updating event can do changes so, $this->getDirtyForUpdate() will call $this->getDirty(), + - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + if (!$this->getEventDispatcher()->hasListeners($this::class . '.updating')) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; + unset($dirty); + } + + if ($this->performUpdate($query)) { + $this->finishSave($options + ['touch' => $isDirty]); + + return true; + } + + return false; + } finally { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + } + } + + /** $this->isDirty() will merge/sync attributes from cached casts objects */ + $isDirty = $this->isDirty(); + + /** Multiple set calls are needed (https://github.com/laravel/framework/discussions/31778) because: + - $this->performInsert can do changes, + - creating event can do changes so, $this->getAttributesForInsert() will call $this->getAttributes(), + - created/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + + $saved = $this->performInsert($query); + + if ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } + + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); + } + + return $saved; + } + + /** + * @inheritdoc + */ + protected function finishSave(array $options): void + { + if ($this->getEventDispatcher()->hasListeners($this::class . '.saved')) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + } + + $this->fireModelEvent('saved', false); + + if ($options['touch'] ?? true) { + $this->touchOwners(); + } + + $this->syncOriginal(); + } + + /** + * @inheritdoc + */ + protected function performUpdate(Builder $query): bool + { + // If the updating event returns false, we will cancel the update operation so + // developers can hook Validation systems into their models and cancel this + // operation if the model does not pass validation. Otherwise, we update. + if ($this->fireModelEvent('updating') === false) { + return false; + } + + // First we need to create a fresh query instance and touch the creation and + // update timestamp on the model which are maintained by us for developer + // convenience. Then we will just continue saving the model instances. + if ($this->usesTimestamps()) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->updateTimestamps(); + } + + // Once we have run the update operation, we will fire the "updated" event for + // this model instance. This will allow developers to hook into these after + // models are updated, giving them a chance to do any special processing. + $dirty = $this->getDirtyForUpdate(); + + if ([] !== $dirty) { + $this->setKeysForSaveQuery($query)->update($dirty); + + $this->syncChanges(); + + if ( + isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) + && $this->getEventDispatcher()->hasListeners($this::class . '.updated') + ) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + } + + $this->fireModelEvent('updated', false); + } + + return true; + } + + /** + * Get the attributes that have been changed since the last sync for an update operation. + * + * @return array + */ + protected function getDirtyForUpdate(): array + { + return $this->getDirty(); + } + + /** + * Get an attribute from the $attributes array without transformation + * @see self::getAttributeValue + * + * @param string $key + * @return mixed + */ + protected function getAttributeFromArray($key): mixed + { + $this->mergeAttributesFromClassCasts($key); + $this->mergeAttributesFromAttributeCasts($key); + + return $this->attributes[$key] ?? null; + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromClassCasts(): void + { + $k = \func_get_args()[0] ?? null; + + $classCastCache = \is_string($k) || \is_array($k) ? + \array_intersect_key($this->classCastCache, \array_flip(\array_values((array)$k))) : + $this->classCastCache; + + foreach ($classCastCache as $key => $value) { + $caster = $this->resolveCasterClass($key); + + $this->attributes = array_merge( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromAttributeCasts(): void + { + $k = \func_get_args()[0] ?? null; + + $attributeCastCache = \is_string($k) || \is_array($k) ? + \array_intersect_key($this->attributeCastCache, \array_flip(\array_values((array)$k))) : + $this->attributeCastCache; + + foreach ($attributeCastCache as $key => $value) { + $attribute = $this->{Str::camel($key)}(); + + if ($attribute->get && !$attribute->set) { + continue; + } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); + } + } +} \ No newline at end of file From 432bd6bc994fd15334e147b963cb692533c43b20 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Mon, 3 Nov 2025 17:56:56 +0200 Subject: [PATCH 14/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 5 ++++- src/Models/ExcessiveSetOptimizerOnSaveTrait.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 5ea8c69..c08731d 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -726,7 +726,10 @@ public function save(array $options = []): bool */ protected function finishSave(array $options): void { - if ($this->getEventDispatcher()->hasListeners($this::class . '.saved')) { + if ( + isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) + && $this->getEventDispatcher()->hasListeners($this::class . '.saved') + ) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index d607038..5dabb40 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -191,7 +191,10 @@ public function save(array $options = []): bool */ protected function finishSave(array $options): void { - if ($this->getEventDispatcher()->hasListeners($this::class . '.saved')) { + if ( + isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) + && $this->getEventDispatcher()->hasListeners($this::class . '.saved') + ) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } From 85d8f3fa05ce79909c06170f175365021a0c56a7 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Tue, 4 Nov 2025 08:36:04 +0200 Subject: [PATCH 15/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 11 +++++++++-- src/Models/ExcessiveSetOptimizerOnSaveTrait.php | 12 ++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index c08731d..3b09eb8 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -634,10 +634,17 @@ public function getDirty(): array $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts; } - $attributes = [] !== $attributes ? $attributes : \array_keys($this->attributes); - $dirty = []; + if ([] === $attributes) { + foreach ($this->getAttributes() as $key => $value) { + if (!$this->originalIsEquivalent($key)) { + $dirty[$key] = $value; + } + } + + return $dirty; + } foreach ($attributes as $key) { /** This will call $this->mergeAttributesFromCachedCasts($key) before the if condition */ $value = $this->getAttributeFromArray($key); diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index 5dabb40..fe0860a 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -99,10 +99,18 @@ public function getDirty(): array $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts; } - $attributes = [] !== $attributes ? $attributes : \array_keys($this->attributes); - $dirty = []; + if ([] === $attributes) { + foreach ($this->getAttributes() as $key => $value) { + if (!$this->originalIsEquivalent($key)) { + $dirty[$key] = $value; + } + } + + return $dirty; + } + foreach ($attributes as $key) { /** This will call $this->mergeAttributesFromCachedCasts($key) before the if condition */ $value = $this->getAttributeFromArray($key); From e47d26bb41967ccd24d4d1364f14035b66d92964 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Tue, 4 Nov 2025 09:08:04 +0200 Subject: [PATCH 16/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 3b09eb8..76f7eab 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -685,7 +685,8 @@ public function save(array $options = []): bool /** We will try to optimize the execution by caching $dirty array BUT, multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) WHEN $this->usesTimestamps() or WHEN there are listeners for the following events: - - updating event can do changes so, $this->getDirtyForUpdate() will call $this->getDirty(), + - updating event can do changes so, $this->getDirtyForUpdate() and $this->syncChanges() will call + $this->getDirty(), - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ if (!$this->getEventDispatcher()->hasListeners($this::class . '.updating')) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; From 2f9f3692837f396c6f272a1b2051d9cf0a175511 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Tue, 4 Nov 2025 09:25:36 +0200 Subject: [PATCH 17/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 1 + src/Models/ExcessiveSetOptimizerOnSaveTrait.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 76f7eab..192bdc8 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -645,6 +645,7 @@ public function getDirty(): array return $dirty; } + foreach ($attributes as $key) { /** This will call $this->mergeAttributesFromCachedCasts($key) before the if condition */ $value = $this->getAttributeFromArray($key); diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index fe0860a..252c275 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -151,7 +151,8 @@ public function save(array $options = []): bool /** We will try to optimize the execution by caching $dirty array BUT, multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) WHEN $this->usesTimestamps() or WHEN there are listeners for the following events: - - updating event can do changes so, $this->getDirtyForUpdate() will call $this->getDirty(), + - updating event can do changes so, $this->getDirtyForUpdate() and $this->syncChanges() will call + $this->getDirty(), - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ if (!$this->getEventDispatcher()->hasListeners($this::class . '.updating')) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; From 6b5cdf065566f785ce3b23dfb04db3ed91b7bc31 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Tue, 4 Nov 2025 12:45:35 +0200 Subject: [PATCH 18/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 6 +++--- src/Models/ExcessiveSetOptimizerOnSaveTrait.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 192bdc8..8e79890 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -689,7 +689,7 @@ public function save(array $options = []): bool - updating event can do changes so, $this->getDirtyForUpdate() and $this->syncChanges() will call $this->getDirty(), - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ - if (!$this->getEventDispatcher()->hasListeners($this::class . '.updating')) { + if (!$this->getEventDispatcher()->hasListeners('eloquent.updating: ' . $this::class)) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; unset($dirty); } @@ -737,7 +737,7 @@ protected function finishSave(array $options): void { if ( isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners($this::class . '.saved') + && $this->getEventDispatcher()->hasListeners('eloquent.saved: ' . $this::class) ) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } @@ -783,7 +783,7 @@ protected function performUpdate(Builder $query): bool if ( isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners($this::class . '.updated') + && $this->getEventDispatcher()->hasListeners('eloquent.updated: ' . $this::class) ) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index 252c275..c5ca83c 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -154,7 +154,7 @@ public function save(array $options = []): bool - updating event can do changes so, $this->getDirtyForUpdate() and $this->syncChanges() will call $this->getDirty(), - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ - if (!$this->getEventDispatcher()->hasListeners($this::class . '.updating')) { + if (!$this->getEventDispatcher()->hasListeners('eloquent.updating: ' . $this::class)) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; unset($dirty); } @@ -202,7 +202,7 @@ protected function finishSave(array $options): void { if ( isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners($this::class . '.saved') + && $this->getEventDispatcher()->hasListeners('eloquent.saved: ' . $this::class) ) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } @@ -248,7 +248,7 @@ protected function performUpdate(Builder $query): bool if ( isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners($this::class . '.updated') + && $this->getEventDispatcher()->hasListeners('eloquent.updated: ' . $this::class) ) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; } From e8ce6e6bf1cd94b0c7d60a78b7af30953c298c67 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Tue, 4 Nov 2025 20:56:39 +0200 Subject: [PATCH 19/27] POC for https://github.com/laravel/framework/discussions/31778 cover this case $model = User::query()->firstOrFail(); $carbon = $model->date_time_carbon_casted; $carbon->addDay(); echo $model->col_with_get_mutator_that_depends_on_date_time_carbon_casted; // would print 'value date time' without the added day before this commit --- src/Models/BaseModel.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 8e79890..0692802 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -391,7 +391,7 @@ public function getAttributeValue($key): mixed $this->incrementsToRefresh = []; } - $return = parent::getAttributeValue($key); + $return = $this->transformModelValue($key, $this->getAttributeFromArray($key, true)); if ( $return !== null @@ -809,10 +809,18 @@ protected function getDirtyForUpdate(): array * @see self::getAttributeValue * * @param string $key + * @param bool $mergeAllAttributesFromCachedCasts * @return mixed */ protected function getAttributeFromArray($key): mixed { + if ( + true === (\func_get_args()[1] ?? false) + && ($this->hasGetMutator($key) || $this->hasAttributeGetMutator($key)) + ) { + return $this->getAttributes()[$key] ?? null; + } + $this->mergeAttributesFromClassCasts($key); $this->mergeAttributesFromAttributeCasts($key); From 703390eec78bc16ec7d58cd1f3a53b45162ee710 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Tue, 4 Nov 2025 20:59:26 +0200 Subject: [PATCH 20/27] POC for https://github.com/laravel/framework/discussions/31778 cover this case $model = User::query()->firstOrFail(); $carbon = $model->date_time_carbon_casted; $carbon->addDay(); echo $model->col_with_get_mutator_that_depends_on_date_time_carbon_casted; // would print 'value date time' without the added day before this commit --- src/Models/ExcessiveSetOptimizerOnSaveTrait.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index c5ca83c..0a1ba4a 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -269,15 +269,28 @@ protected function getDirtyForUpdate(): array return $this->getDirty(); } + public function getAttributeValue($key): mixed + { + return $this->transformModelValue($key, $this->getAttributeFromArray($key, true)); + } + /** * Get an attribute from the $attributes array without transformation * @see self::getAttributeValue * * @param string $key + * @param bool $mergeAllAttributesFromCachedCasts * @return mixed */ protected function getAttributeFromArray($key): mixed { + if ( + true === (\func_get_args()[1] ?? false) + && ($this->hasGetMutator($key) || $this->hasAttributeGetMutator($key)) + ) { + return $this->getAttributes()[$key] ?? null; + } + $this->mergeAttributesFromClassCasts($key); $this->mergeAttributesFromAttributeCasts($key); From e450d1409d166391d10175fa156c080979b18525 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Thu, 6 Nov 2025 11:06:33 +0200 Subject: [PATCH 21/27] POC for https://github.com/laravel/framework/discussions/31778 cover changes in created updated saved that got into $original but not in db --- src/Models/BaseModel.php | 92 +++++++++++-------- .../ExcessiveSetOptimizerOnSaveTrait.php | 92 +++++++++++-------- 2 files changed, 108 insertions(+), 76 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 0692802..c9eb269 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -56,6 +56,12 @@ abstract class BaseModel extends Model */ protected ?array $tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + /** + * Temporary original cache to prevent changes in created,updated,saved events from getting + * into $original without being saved into DB + */ + protected ?array $tmpOriginalBeforeAfterEvents = null; + private array $incrementsToRefresh = []; /** @@ -586,6 +592,14 @@ public function getAttributes(): array return $this->attributes; } + /** + * @inheritdoc + */ + protected function getAttributesForInsert(): array + { + return $this->tmpOriginalBeforeAfterEvents = $this->getAttributes(); + } + /** * @inheritdoc */ @@ -608,16 +622,6 @@ public function isDirty($attributes = null): bool return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); } - /** - * @inheritdoc - */ - public function syncOriginal(): static - { - $this->original = $this->getAttributes(isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)); - - return $this; - } - /** * Get the attributes that have been changed since the last sync. * @param string|array $attributes @@ -685,10 +689,9 @@ public function save(array $options = []): bool try { /** We will try to optimize the execution by caching $dirty array BUT, multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) - WHEN $this->usesTimestamps() or WHEN there are listeners for the following events: - - updating event can do changes so, $this->getDirtyForUpdate() and $this->syncChanges() will call - $this->getDirty(), - - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + WHEN $this->usesTimestamps() or WHEN there are listeners for updating event because they can do changes + so, $this->getDirtyForUpdate() and $this->syncChanges() will call $this->getDirty() which will call + getAttributes() */ if (!$this->getEventDispatcher()->hasListeners('eloquent.updating: ' . $this::class)) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; unset($dirty); @@ -703,6 +706,7 @@ public function save(array $options = []): bool return false; } finally { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = null; } } @@ -711,20 +715,24 @@ public function save(array $options = []): bool /** Multiple set calls are needed (https://github.com/laravel/framework/discussions/31778) because: - $this->performInsert can do changes, - - creating event can do changes so, $this->getAttributesForInsert() will call $this->getAttributes(), - - created/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + - creating event can do changes so, + $this->getAttributesForInsert() and $this->syncOriginal() will call $this->getAttributes() */ - $saved = $this->performInsert($query); + try { + $saved = $this->performInsert($query); - if ( - '' === (string)$this->getConnectionName() && - ($connection = $query->getConnection()) instanceof Connection - ) { - $this->setConnection($connection->getName()); - } + if ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } - if ($saved) { - $this->finishSave($options + ['touch' => $isDirty]); + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); + } + } finally { + $this->tmpOriginalBeforeAfterEvents = null; } return $saved; @@ -735,19 +743,19 @@ public function save(array $options = []): bool */ protected function finishSave(array $options): void { - if ( - isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners('eloquent.saved: ' . $this::class) - ) { - $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; - } - $this->fireModelEvent('saved', false); if ($options['touch'] ?? true) { $this->touchOwners(); } + if (isset($this->tmpOriginalBeforeAfterEvents)) { + $this->original = $this->tmpOriginalBeforeAfterEvents; + $this->tmpOriginalBeforeAfterEvents = null; + + return; + } + $this->syncOriginal(); } @@ -781,12 +789,8 @@ protected function performUpdate(Builder $query): bool $this->syncChanges(); - if ( - isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners('eloquent.updated: ' . $this::class) - ) { - $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; - } + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; $this->fireModelEvent('updated', false); } @@ -794,6 +798,18 @@ protected function performUpdate(Builder $query): bool return true; } + /** + * @inheritdoc + */ + protected function insertAndSetId(Builder $query, $attributes): void + { + $id = $query->insertGetId($attributes, $keyName = $this->getKeyName()); + + $this->setAttribute($keyName, $id); + + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + } + /** * Get the attributes that have been changed since the last sync for an update operation. * diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index 0a1ba4a..b94d4e5 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -15,6 +15,12 @@ trait ExcessiveSetOptimizerOnSaveTrait */ protected ?array $tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + /** + * Temporary original cache to prevent changes in created,updated,saved events from getting + * into $original without being saved into DB + */ + protected ?array $tmpOriginalBeforeAfterEvents = null; + public function __call($method, $parameters) { $lowerMethod = \strtolower($method); @@ -51,6 +57,14 @@ public function getAttributes(): array return $this->attributes; } + /** + * @inheritdoc + */ + protected function getAttributesForInsert(): array + { + return $this->tmpOriginalBeforeAfterEvents = $this->getAttributes(); + } + /** * @inheritdoc */ @@ -73,16 +87,6 @@ public function isDirty($attributes = null): bool return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); } - /** - * @inheritdoc - */ - public function syncOriginal(): static - { - $this->original = $this->getAttributes(isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)); - - return $this; - } - /** * Get the attributes that have been changed since the last sync. * @param string|array $attributes @@ -150,10 +154,9 @@ public function save(array $options = []): bool try { /** We will try to optimize the execution by caching $dirty array BUT, multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) - WHEN $this->usesTimestamps() or WHEN there are listeners for the following events: - - updating event can do changes so, $this->getDirtyForUpdate() and $this->syncChanges() will call - $this->getDirty(), - - updated/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + WHEN $this->usesTimestamps() or WHEN there are listeners for updating event because they can do changes + so, $this->getDirtyForUpdate() and $this->syncChanges() will call $this->getDirty() which will call + getAttributes() */ if (!$this->getEventDispatcher()->hasListeners('eloquent.updating: ' . $this::class)) { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; unset($dirty); @@ -168,6 +171,7 @@ public function save(array $options = []): bool return false; } finally { $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = null; } } @@ -176,20 +180,24 @@ public function save(array $options = []): bool /** Multiple set calls are needed (https://github.com/laravel/framework/discussions/31778) because: - $this->performInsert can do changes, - - creating event can do changes so, $this->getAttributesForInsert() will call $this->getAttributes(), - - created/saved events can do changes so, $this->syncOriginal() will call $this->getAttributes() */ + - creating event can do changes so, + $this->getAttributesForInsert() and $this->syncOriginal() will call $this->getAttributes() */ - $saved = $this->performInsert($query); + try { + $saved = $this->performInsert($query); - if ( - '' === (string)$this->getConnectionName() && - ($connection = $query->getConnection()) instanceof Connection - ) { - $this->setConnection($connection->getName()); - } + if ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } - if ($saved) { - $this->finishSave($options + ['touch' => $isDirty]); + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); + } + } finally { + $this->tmpOriginalBeforeAfterEvents = null; } return $saved; @@ -200,19 +208,19 @@ public function save(array $options = []): bool */ protected function finishSave(array $options): void { - if ( - isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners('eloquent.saved: ' . $this::class) - ) { - $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; - } - $this->fireModelEvent('saved', false); if ($options['touch'] ?? true) { $this->touchOwners(); } + if (isset($this->tmpOriginalBeforeAfterEvents)) { + $this->original = $this->tmpOriginalBeforeAfterEvents; + $this->tmpOriginalBeforeAfterEvents = null; + + return; + } + $this->syncOriginal(); } @@ -246,12 +254,8 @@ protected function performUpdate(Builder $query): bool $this->syncChanges(); - if ( - isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts) - && $this->getEventDispatcher()->hasListeners('eloquent.updated: ' . $this::class) - ) { - $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; - } + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; $this->fireModelEvent('updated', false); } @@ -259,6 +263,18 @@ protected function performUpdate(Builder $query): bool return true; } + /** + * @inheritdoc + */ + protected function insertAndSetId(Builder $query, $attributes): void + { + $id = $query->insertGetId($attributes, $keyName = $this->getKeyName()); + + $this->setAttribute($keyName, $id); + + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + } + /** * Get the attributes that have been changed since the last sync for an update operation. * From 1a5b70e9be5dc467d232ee1ba25d00729cd2ec52 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Fri, 7 Nov 2025 09:22:33 +0200 Subject: [PATCH 22/27] POC for https://github.com/laravel/framework/discussions/31778 bulletproofing incrementOrDecrement --- src/Models/BaseModel.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index c9eb269..8cc4c00 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -469,7 +469,7 @@ public function getCasts() */ protected function incrementOrDecrement($column, $amount, $extra, $method): int { - $return = parent::incrementOrDecrement($column, $amount, $extra, $method); + $return = (int)parent::incrementOrDecrement($column, $amount, $extra, $method); if ($this->exists) { $this->incrementsToRefresh[$column] = true; @@ -782,18 +782,18 @@ protected function performUpdate(Builder $query): bool // Once we have run the update operation, we will fire the "updated" event for // this model instance. This will allow developers to hook into these after // models are updated, giving them a chance to do any special processing. - $dirty = $this->getDirtyForUpdate(); + if ([] === $dirty = $this->getDirtyForUpdate()) { + return false; + } - if ([] !== $dirty) { - $this->setKeysForSaveQuery($query)->update($dirty); + $this->setKeysForSaveQuery($query)->update($dirty); - $this->syncChanges(); + $this->syncChanges(); - $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; - $this->tmpOriginalBeforeAfterEvents = $this->attributes; + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; - $this->fireModelEvent('updated', false); - } + $this->fireModelEvent('updated', false); return true; } From 4fbd777a1067e24424ace9a1258dd878ed2a5ed1 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Fri, 7 Nov 2025 09:39:58 +0200 Subject: [PATCH 23/27] POC for https://github.com/laravel/framework/discussions/31778 bulletproofing incrementOrDecrement --- src/Models/BaseModel.php | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 8cc4c00..73f4c1c 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -469,13 +469,31 @@ public function getCasts() */ protected function incrementOrDecrement($column, $amount, $extra, $method): int { - $return = (int)parent::incrementOrDecrement($column, $amount, $extra, $method); + if (!$this->exists) { + return $this->newQueryWithoutRelationships()->{$method}($column, $amount, $extra); + } - if ($this->exists) { - $this->incrementsToRefresh[$column] = true; + $this->{$column} = $this->isClassDeviable($column) + ? $this->deviateClassCastableAttribute($method, $column, $amount) + : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); + + $this->forceFill($extra); + + if (!$this->isDirty() || $this->fireModelEvent('updating') === false) { + return 0; } - return $return; + return (int)tap( + $this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), + function () use ($column) { + $this->syncChanges(); + + $this->fireModelEvent('updated', false); + + $this->syncOriginalAttributes(\array_keys($this->changes)); + $this->incrementsToRefresh[$column] = true; + } + ); } /** From 2b5381a5abf87f3d8aa59267fe1abf02d5d42948 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Fri, 7 Nov 2025 09:46:38 +0200 Subject: [PATCH 24/27] POC for https://github.com/laravel/framework/discussions/31778 bulletproofing incrementOrDecrement --- src/Models/BaseModel.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 73f4c1c..db93973 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -475,7 +475,11 @@ protected function incrementOrDecrement($column, $amount, $extra, $method): int $this->{$column} = $this->isClassDeviable($column) ? $this->deviateClassCastableAttribute($method, $column, $amount) - : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); + : (\extension_loaded('bcmath') ? \bcadd( + $s1 = (string)$this->{$column}, + $s2 = (string)($method === 'increment' ? $amount : $amount * -1), + \max(\strlen(\strrchr($s1, '.') ?: ''), \strlen(\strrchr($s2, '.') ?: '')) + ) : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1)); $this->forceFill($extra); From ab31b0651be25cc69590bc927a511be6803bd8f4 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Fri, 7 Nov 2025 12:52:29 +0200 Subject: [PATCH 25/27] POC for https://github.com/laravel/framework/discussions/31778 add consequence lockUpdate new feature and sync trait --- src/Models/BaseModel.php | 49 ++++++++++++++ .../ExcessiveSetOptimizerOnSaveTrait.php | 65 ++++++++++++++++--- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index db93973..92e09b3 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -600,6 +600,55 @@ public function unique($key = null, $strict = false): Collection }; } + /** + * Prevent updates + * Note that relations can be loaded and updated during the lock + */ + public function lockUpdates(bool $checkDirty = true): bool + { + if ( + !$this->exists + || $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== null + || ($checkDirty && $this->isDirty()) + ) { + return false; + } + + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = []; + + return true; + } + + /** + * Unlock updates + * + * To reset the model's $attributes and get the changes from dirty applied during the lock use: + * + * if ($this->unlockUpdate()) { + * $dirty = $this->getDirty(); + * $this->attributes = $this->original; + * $this->classCastCache = []; + * $this->attributeCastCache = []; + * } + * + * Note that relations can be loaded during the lock + */ + public function unlockUpdate(): bool + { + if ($this->isUnlockedForUpdate()) { + return false; + } + + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + + return true; + } + + public function isUnlockedForUpdate(): bool + { + return $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== []; + } + /** * Get all of the current attributes on the model. * @param bool $withoutMergeAttributesFromCachedCasts diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index b94d4e5..7ffa59c 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -43,6 +43,55 @@ public function __call($method, $parameters) return parent::__call($method, $parameters); } + /** + * Prevent updates + * Note that relations can be loaded and updated during the lock + */ + public function lockUpdates(bool $checkDirty = true): bool + { + if ( + !$this->exists + || $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== null + || ($checkDirty && $this->isDirty()) + ) { + return false; + } + + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = []; + + return true; + } + + /** + * Unlock updates + * + * To reset the model's $attributes and get the changes from dirty applied during the lock use: + * + * if ($this->unlockUpdate()) { + * $dirty = $this->getDirty(); + * $this->attributes = $this->original; + * $this->classCastCache = []; + * $this->attributeCastCache = []; + * } + * + * Note that relations can be loaded during the lock + */ + public function unlockUpdate(): bool + { + if ($this->isUnlockedForUpdate()) { + return false; + } + + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + + return true; + } + + public function isUnlockedForUpdate(): bool + { + return $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== []; + } + /** * Get all of the current attributes on the model. * @param bool $withoutMergeAttributesFromCachedCasts @@ -247,18 +296,18 @@ protected function performUpdate(Builder $query): bool // Once we have run the update operation, we will fire the "updated" event for // this model instance. This will allow developers to hook into these after // models are updated, giving them a chance to do any special processing. - $dirty = $this->getDirtyForUpdate(); + if ([] === $dirty = $this->getDirtyForUpdate()) { + return false; + } - if ([] !== $dirty) { - $this->setKeysForSaveQuery($query)->update($dirty); + $this->setKeysForSaveQuery($query)->update($dirty); - $this->syncChanges(); + $this->syncChanges(); - $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; - $this->tmpOriginalBeforeAfterEvents = $this->attributes; + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; - $this->fireModelEvent('updated', false); - } + $this->fireModelEvent('updated', false); return true; } From da5e773309de7743c41f5055282b409eeea0bb02 Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Fri, 7 Nov 2025 12:56:14 +0200 Subject: [PATCH 26/27] POC for https://github.com/laravel/framework/discussions/31778 sync trait --- .../ExcessiveSetOptimizerOnSaveTrait.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index 7ffa59c..771528f 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -21,6 +21,43 @@ trait ExcessiveSetOptimizerOnSaveTrait */ protected ?array $tmpOriginalBeforeAfterEvents = null; + /** + * This will mass update the whole table if the model does not exist! + * @inheritDoc + * @throws \InvalidArgumentException + */ + protected function incrementOrDecrement($column, $amount, $extra, $method): int + { + if (!$this->exists) { + return $this->newQueryWithoutRelationships()->{$method}($column, $amount, $extra); + } + + $this->{$column} = $this->isClassDeviable($column) + ? $this->deviateClassCastableAttribute($method, $column, $amount) + : (\extension_loaded('bcmath') ? \bcadd( + $s1 = (string)$this->{$column}, + $s2 = (string)($method === 'increment' ? $amount : $amount * -1), + \max(\strlen(\strrchr($s1, '.') ?: ''), \strlen(\strrchr($s2, '.') ?: '')) + ) : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1)); + + $this->forceFill($extra); + + if (!$this->isDirty() || $this->fireModelEvent('updating') === false) { + return 0; + } + + return (int)tap( + $this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), + function () use ($column) { + $this->syncChanges(); + + $this->fireModelEvent('updated', false); + + $this->syncOriginalAttributes(\array_keys($this->changes)); + } + ); + } + public function __call($method, $parameters) { $lowerMethod = \strtolower($method); From 9a5b611146c75972016b604e73793fceb5e6ddfe Mon Sep 17 00:00:00 2001 From: Pantea Marius-ciclistu <> Date: Fri, 7 Nov 2025 13:08:20 +0200 Subject: [PATCH 27/27] POC for https://github.com/laravel/framework/discussions/31778 cr --- src/Models/BaseModel.php | 8 ++++---- src/Models/ExcessiveSetOptimizerOnSaveTrait.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 92e09b3..a41ec23 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -624,7 +624,7 @@ public function lockUpdates(bool $checkDirty = true): bool * * To reset the model's $attributes and get the changes from dirty applied during the lock use: * - * if ($this->unlockUpdate()) { + * if ($this->unlockUpdates()) { * $dirty = $this->getDirty(); * $this->attributes = $this->original; * $this->classCastCache = []; @@ -633,9 +633,9 @@ public function lockUpdates(bool $checkDirty = true): bool * * Note that relations can be loaded during the lock */ - public function unlockUpdate(): bool + public function unlockUpdates(): bool { - if ($this->isUnlockedForUpdate()) { + if ($this->hasUnlockedUpdates()) { return false; } @@ -644,7 +644,7 @@ public function unlockUpdate(): bool return true; } - public function isUnlockedForUpdate(): bool + public function hasUnlockedUpdates(): bool { return $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== []; } diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php index 771528f..d55f660 100644 --- a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -113,9 +113,9 @@ public function lockUpdates(bool $checkDirty = true): bool * * Note that relations can be loaded during the lock */ - public function unlockUpdate(): bool + public function unlockUpdates(): bool { - if ($this->isUnlockedForUpdate()) { + if ($this->hasUnlockedUpdates()) { return false; } @@ -124,7 +124,7 @@ public function unlockUpdate(): bool return true; } - public function isUnlockedForUpdate(): bool + public function hasUnlockedUpdates(): bool { return $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== []; }