From 8f1d72783ca7f5592ea1678563b5ea99823b1ad0 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 15 Jan 2026 22:22:17 +0100 Subject: [PATCH 1/4] Remove attribute access events for performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove model.beforeGetAttribute, model.getAttribute, model.beforeSetAttribute, and model.setAttribute events from Database and Halcyon models. These events fired on every attribute access/mutation, causing significant overhead (e.g., 50 users × 15 attributes × 2 events = 1,500 event fires for a simple list). Laravel does not fire events for attribute access. Breaking change: Code relying on these events will need refactoring. --- src/Database/Concerns/HasAttributes.php | 84 +------------------------ src/Halcyon/Model.php | 22 ------- 2 files changed, 1 insertion(+), 105 deletions(-) diff --git a/src/Database/Concerns/HasAttributes.php b/src/Database/Concerns/HasAttributes.php index e875fcd3e..c992e6db0 100644 --- a/src/Database/Concerns/HasAttributes.php +++ b/src/Database/Concerns/HasAttributes.php @@ -19,13 +19,6 @@ public function attributesToArray() { $attributes = $this->getArrayableAttributes(); - // Before Event - foreach ($attributes as $key => $value) { - if (($eventValue = $this->fireEvent('model.beforeGetAttribute', [$key], true)) !== null) { - $attributes[$key] = $eventValue; - } - } - // Dates $attributes = $this->addDateAttributesToArray($attributes); @@ -49,13 +42,6 @@ public function attributesToArray() $attributes, $mutatedAttributes ); - // After Event - foreach ($attributes as $key => $value) { - if (($eventValue = $this->fireEvent('model.getAttribute', [$key, $value], true)) !== null) { - $attributes[$key] = $eventValue; - } - } - return $attributes; } @@ -114,23 +100,6 @@ public function getRelationValue($key) */ public function getAttributeValue($key) { - /** - * @event model.beforeGetAttribute - * Called before the model attribute is retrieved - * - * Example usage: - * - * $model->bindEvent('model.beforeGetAttribute', function ((string) $key) use (\October\Rain\Database\Model $model) { - * if ($key === 'not-for-you-to-look-at') { - * return 'you are not allowed here'; - * } - * }); - * - */ - if (($attr = $this->fireEvent('model.beforeGetAttribute', [$key], true)) !== null) { - return $attr; - } - $attr = parent::getAttributeValue($key); // Return valid json (boolean, array) if valid, otherwise @@ -142,23 +111,6 @@ public function getAttributeValue($key) } } - /** - * @event model.getAttribute - * Called after the model attribute is retrieved - * - * Example usage: - * - * $model->bindEvent('model.getAttribute', function ((string) $key, $value) use (\October\Rain\Database\Model $model) { - * if ($key === 'not-for-you-to-look-at') { - * return "Totally not $value"; - * } - * }); - * - */ - if (($_attr = $this->fireEvent('model.getAttribute', [$key, $attr], true)) !== null) { - return $_attr; - } - return $attr; } @@ -190,23 +142,6 @@ public function setAttribute($key, $value) return $this->setRelationSimpleValue($key, $value); } - /** - * @event model.beforeSetAttribute - * Called before the model attribute is set - * - * Example usage: - * - * $model->bindEvent('model.beforeSetAttribute', function ((string) $key, $value) use (\October\Rain\Database\Model $model) { - * if ($key === 'do-not-touch') { - * return "$value has been touched"; - * } - * }); - * - */ - if (($_value = $this->fireEvent('model.beforeSetAttribute', [$key, $value], true)) !== null) { - $value = $_value; - } - // Jsonable if ($this->isJsonable($key) && (!empty($value) || is_array($value))) { $value = json_encode($value, JSON_UNESCAPED_UNICODE); @@ -217,24 +152,7 @@ public function setAttribute($key, $value) $value = trim($value); } - $result = parent::setAttribute($key, $value); - - /** - * @event model.setAttribute - * Called after the model attribute is set - * - * Example usage: - * - * $model->bindEvent('model.setAttribute', function ((string) $key, $value) use (\October\Rain\Database\Model $model) { - * if ($key === 'do-not-touch') { - * \Log::info("{$key} has been touched and set to {$value}!") - * } - * }); - * - */ - $this->fireEvent('model.setAttribute', [$key, $value]); - - return $result; + return parent::setAttribute($key, $value); } /** diff --git a/src/Halcyon/Model.php b/src/Halcyon/Model.php index 98bf81562..94c07b30e 100644 --- a/src/Halcyon/Model.php +++ b/src/Halcyon/Model.php @@ -639,11 +639,6 @@ protected function getArrayableAppends() */ public function getAttribute($key) { - // Before Event - if (($attr = $this->fireEvent('model.beforeGetAttribute', [$key], true)) !== null) { - return $attr; - } - $value = $this->getAttributeFromArray($key); // If the attribute has a get mutator, we will call that then return what @@ -653,11 +648,6 @@ public function getAttribute($key) return $this->mutateAttribute($key, $value); } - // After Event - if (($_attr = $this->fireEvent('model.getAttribute', [$key, $value], true)) !== null) { - return $_attr; - } - return $value; } @@ -715,29 +705,17 @@ protected function mutateAttributeForArray($key, $value) */ public function setAttribute($key, $value) { - // Before Event - if (($_value = $this->fireEvent('model.beforeSetAttribute', [$key, $value], true)) !== null) { - $value = $_value; - } - // First we will check for the presence of a mutator for the set operation // which simply lets the developers tweak the attribute as it is set on // the model, such as "json_encoding" an listing of data for storage. if ($this->hasSetMutator($key)) { $method = 'set'.Str::studly($key).'Attribute'; - // If we return the returned value of the mutator call straight away, that will disable the firing of - // 'model.setAttribute' event, and then no third party plugins will be able to implement any kind of - // post processing logic when an attribute is set with explicit mutators. Returning from the mutator - // call will also break method chaining as intended by returning `$this` at the end of this method. $this->{$method}($value); } else { $this->attributes[$key] = $value; } - // After Event - $this->fireEvent('model.setAttribute', [$key, $value]); - return $this; } From 6ddef313246b421b7b3c88eea8e527e39125fa16 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 15 Jan 2026 22:37:27 +0100 Subject: [PATCH 2/4] Add empty key check to getAttribute() to match Laravel Sync getAttribute() with Laravel's implementation by adding an early return for null/empty keys. This prevents unnecessary processing and matches Laravel 12's behavior. --- src/Database/Concerns/HasAttributes.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Database/Concerns/HasAttributes.php b/src/Database/Concerns/HasAttributes.php index c992e6db0..0b3828548 100644 --- a/src/Database/Concerns/HasAttributes.php +++ b/src/Database/Concerns/HasAttributes.php @@ -52,6 +52,10 @@ public function attributesToArray() */ public function getAttribute($key) { + if (!$key) { + return; + } + if ( array_key_exists($key, $this->attributes) || $this->hasGetMutator($key) || From 50860f659d81fe2d45172c3532aecb9c79e793bb Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 15 Jan 2026 22:39:43 +0100 Subject: [PATCH 3/4] Remove jsonable feature in favor of Laravel casts Remove HasJsonable trait entirely and migrate to Laravel's $casts: - Delete HasJsonable.php - Remove trait from Model.php - Remove jsonable handling from HasAttributes (attributesToArray, getAttributeValue, setAttribute) - Remove jsonable handling from Validation trait Migrate models to use $casts with 'array' type: - DeferredBinding: pivot_data - User: permissions - Role: permissions - Preferences: value - ExpandoModel: expandoColumn - contentfield.stub: fieldName Breaking change: $jsonable property no longer supported. Use Laravel's $casts = ['field' => 'array'] instead. --- src/Auth/Models/Preferences.php | 6 +- src/Auth/Models/Role.php | 6 +- src/Auth/Models/User.php | 6 +- src/Database/Concerns/HasAttributes.php | 60 ------------- src/Database/Concerns/HasJsonable.php | 89 ------------------- src/Database/ExpandoModel.php | 2 +- src/Database/Model.php | 1 - src/Database/Models/DeferredBinding.php | 6 +- src/Database/Traits/Validation.php | 5 -- .../Console/contentfield/contentfield.stub | 2 +- 10 files changed, 18 insertions(+), 165 deletions(-) delete mode 100644 src/Database/Concerns/HasJsonable.php diff --git a/src/Auth/Models/Preferences.php b/src/Auth/Models/Preferences.php index ff109c2a7..6e895206d 100644 --- a/src/Auth/Models/Preferences.php +++ b/src/Auth/Models/Preferences.php @@ -27,9 +27,11 @@ class Preferences extends Model protected static $cache = []; /** - * @var array jsonable attribute names that are json encoded and decoded from the database + * @var array casts attribute types to convert automatically */ - protected $jsonable = ['value']; + protected $casts = [ + 'value' => 'array' + ]; /** * @var \October\Rain\Auth\Models\User userContext is the user that owns the preferences diff --git a/src/Auth/Models/Role.php b/src/Auth/Models/Role.php index fb73d1f00..04902a744 100644 --- a/src/Auth/Models/Role.php +++ b/src/Auth/Models/Role.php @@ -30,9 +30,11 @@ class Role extends Model ]; /** - * @var array jsonable attribute names that are json encoded and decoded from the database + * @var array casts attribute types to convert automatically */ - protected $jsonable = ['permissions']; + protected $casts = [ + 'permissions' => 'array' + ]; /** * @var array allowedPermissionsValues diff --git a/src/Auth/Models/User.php b/src/Auth/Models/User.php index 2b350aae0..c974ff313 100644 --- a/src/Auth/Models/User.php +++ b/src/Auth/Models/User.php @@ -87,9 +87,11 @@ class User extends Model implements Authenticatable public $customMessages = []; /** - * @var array jsonable attribute names that are json encoded and decoded from the database + * @var array casts attribute types to convert automatically */ - protected $jsonable = ['permissions']; + protected $casts = [ + 'permissions' => 'array' + ]; /** * allowedPermissionsValues diff --git a/src/Database/Concerns/HasAttributes.php b/src/Database/Concerns/HasAttributes.php index 0b3828548..cd1f7aa2e 100644 --- a/src/Database/Concerns/HasAttributes.php +++ b/src/Database/Concerns/HasAttributes.php @@ -11,40 +11,6 @@ */ trait HasAttributes { - /** - * attributesToArray converts the model's attributes to an array. - * @return array - */ - public function attributesToArray() - { - $attributes = $this->getArrayableAttributes(); - - // Dates - $attributes = $this->addDateAttributesToArray($attributes); - - // Mutate - $attributes = $this->addMutatedAttributesToArray( - $attributes, $mutatedAttributes = $this->getMutatedAttributes() - ); - - // Casts - $attributes = $this->addCastAttributesToArray( - $attributes, $mutatedAttributes - ); - - // Appends - foreach ($this->getArrayableAppends() as $key) { - $attributes[$key] = $this->mutateAttributeForArray($key, null); - } - - // Jsonable - $attributes = $this->addJsonableAttributesToArray( - $attributes, $mutatedAttributes - ); - - return $attributes; - } - /** * getAttribute from the model. * Overridden from {@link Eloquent} to implement recognition of the relation. @@ -97,27 +63,6 @@ public function getRelationValue($key) return $this->getRelationshipFromMethod($key); } - /** - * getAttributeValue gets a plain attribute (not a relationship). - * @param string $key - * @return mixed - */ - public function getAttributeValue($key) - { - $attr = parent::getAttributeValue($key); - - // Return valid json (boolean, array) if valid, otherwise - // jsonable fields will return a string for invalid data. - if ($this->isJsonable($key) && !empty($attr)) { - $_attr = json_decode($attr, true); - if (json_last_error() === JSON_ERROR_NONE) { - $attr = $_attr; - } - } - - return $attr; - } - /** * hasGetMutator determines if a get mutator exists for an attribute. * @param string $key @@ -146,11 +91,6 @@ public function setAttribute($key, $value) return $this->setRelationSimpleValue($key, $value); } - // Jsonable - if ($this->isJsonable($key) && (!empty($value) || is_array($value))) { - $value = json_encode($value, JSON_UNESCAPED_UNICODE); - } - // Trim strings if ($this->trimStrings && is_string($value)) { $value = trim($value); diff --git a/src/Database/Concerns/HasJsonable.php b/src/Database/Concerns/HasJsonable.php deleted file mode 100644 index 25f7b5bcf..000000000 --- a/src/Database/Concerns/HasJsonable.php +++ /dev/null @@ -1,89 +0,0 @@ -jsonable = array_merge($this->jsonable, $attributes); - } - - /** - * isJsonable checks if an attribute is jsonable or not. - * - * @return array - */ - public function isJsonable($key) - { - return in_array($key, $this->jsonable); - } - - /** - * getJsonable attributes name - * - * @return array - */ - public function getJsonable() - { - return $this->jsonable; - } - - /** - * jsonable attributes set for the model. - * - * @param array $jsonable - * @return $this - */ - public function jsonable(array $jsonable) - { - $this->jsonable = $jsonable; - - return $this; - } - - /** - * addJsonableAttributesToArray - * @return array - */ - protected function addJsonableAttributesToArray(array $attributes, array $mutatedAttributes) - { - foreach ($this->jsonable as $key) { - if ( - !array_key_exists($key, $attributes) || - in_array($key, $mutatedAttributes) - ) { - continue; - } - - // Prevent double decoding of jsonable attributes. - if (!is_string($attributes[$key])) { - continue; - } - - $jsonValue = json_decode($attributes[$key], true); - if (json_last_error() === JSON_ERROR_NONE) { - $attributes[$key] = $jsonValue; - } - } - - return $attributes; - } -} diff --git a/src/Database/ExpandoModel.php b/src/Database/ExpandoModel.php index 2b51ce892..eccb416b9 100644 --- a/src/Database/ExpandoModel.php +++ b/src/Database/ExpandoModel.php @@ -33,7 +33,7 @@ public function __construct(array $attributes = []) // Process attributes last for traits with attribute modifiers $this->bindEvent('model.beforeSaveDone', [$this, 'expandoBeforeSaveDone'], -1); - $this->addJsonable($this->expandoColumn); + $this->addCasts([$this->expandoColumn => 'array']); } /** diff --git a/src/Database/Model.php b/src/Database/Model.php index 295727381..081f4bce1 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -19,7 +19,6 @@ class Model extends EloquentModel { use Concerns\HasEvents; - use Concerns\HasJsonable; use Concerns\HasAttributes; use Concerns\HasReplication; use Concerns\HasRelationships; diff --git a/src/Database/Models/DeferredBinding.php b/src/Database/Models/DeferredBinding.php index 1fbc2500b..a094616c2 100644 --- a/src/Database/Models/DeferredBinding.php +++ b/src/Database/Models/DeferredBinding.php @@ -21,9 +21,11 @@ class DeferredBinding extends Model public $table = 'deferred_bindings'; /** - * @var array jsonable attribute names that are json encoded and decoded from the database + * @var array casts attribute types to convert automatically */ - protected $jsonable = ['pivot_data']; + protected $casts = [ + 'pivot_data' => 'array' + ]; /** * @var array nullable attribute names which should be set to null when empty diff --git a/src/Database/Traits/Validation.php b/src/Database/Traits/Validation.php index 08169e53a..bad7dd4ef 100644 --- a/src/Database/Traits/Validation.php +++ b/src/Database/Traits/Validation.php @@ -337,11 +337,6 @@ public function validate($rules = null, $customMessages = null, $attributeNames if (!empty($rules)) { $data = $this->getValidationAttributes(); - // Decode jsonable attribute values - foreach ($this->getJsonable() as $jsonable) { - $data[$jsonable] = $this->getAttribute($jsonable); - } - // Add relation values, if specified. foreach ($rules as $attribute => $rule) { if (!$this->hasRelation($attribute) || array_key_exists($attribute, $data)) { diff --git a/src/Scaffold/Console/contentfield/contentfield.stub b/src/Scaffold/Console/contentfield/contentfield.stub index c8e6320c3..41d651bc1 100644 --- a/src/Scaffold/Console/contentfield/contentfield.stub +++ b/src/Scaffold/Console/contentfield/contentfield.stub @@ -41,7 +41,7 @@ class {{studly_name}} extends ContentFieldBase */ public function extendModelObject($model) { - $model->addJsonable($this->fieldName); + $model->addCasts([$this->fieldName => 'array']); } /** From bfa54b987f21bb46f74f67f5f6680cccb6e5886a Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 15 Jan 2026 22:55:39 +0100 Subject: [PATCH 4/4] Refactor Encryptable trait to not use attribute events Replace event-based encryption with direct method overrides: - Override setAttribute() to encrypt values before storing - Override getAttributeValue() to decrypt values when reading - Remove bindEvent calls from initializeEncryptable() Mark trait as deprecated in favor of Laravel's 'encrypted' cast: protected $casts = ['secret' => 'encrypted']; --- src/Database/Traits/Encryptable.php | 57 +++++++++++++++++++---------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/Database/Traits/Encryptable.php b/src/Database/Traits/Encryptable.php index b30495699..29ba8099c 100644 --- a/src/Database/Traits/Encryptable.php +++ b/src/Database/Traits/Encryptable.php @@ -6,6 +6,9 @@ /** * Encryptable database trait * + * @deprecated Use Laravel's built-in 'encrypted' cast instead: + * protected $casts = ['secret' => 'encrypted']; + * * @package october\database * @author Alexey Bobkov, Samuel Georges */ @@ -34,27 +37,43 @@ public function initializeEncryptable() static::class )); } + } + + /** + * setAttribute overrides the base method to encrypt encryptable attributes. + * @param string $key + * @param mixed $value + * @return mixed + */ + public function setAttribute($key, $value) + { + if ( + in_array($key, $this->getEncryptableAttributes()) && + $value !== null && + $value !== '' + ) { + $value = $this->makeEncryptableValue($key, $value); + } - // Encrypt required fields when necessary - $this->bindEvent('model.beforeSetAttribute', function ($key, $value) { - if ( - in_array($key, $this->getEncryptableAttributes()) && - $value !== null && - $value !== '' - ) { - return $this->makeEncryptableValue($key, $value); - } - }); + return parent::setAttribute($key, $value); + } + + /** + * getAttributeValue overrides the base method to decrypt encryptable attributes. + * @param string $key + * @return mixed + */ + public function getAttributeValue($key) + { + if ( + in_array($key, $this->getEncryptableAttributes()) && + array_get($this->attributes, $key) !== null && + array_get($this->attributes, $key) !== '' + ) { + return $this->getEncryptableValue($key); + } - $this->bindEvent('model.beforeGetAttribute', function ($key) { - if ( - in_array($key, $this->getEncryptableAttributes()) && - array_get($this->attributes, $key) !== null && - array_get($this->attributes, $key) !== '' - ) { - return $this->getEncryptableValue($key); - } - }); + return parent::getAttributeValue($key); } /**