From 67d4910c01e39b2bc7ff225763117ad37d623783 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:08:39 +0000 Subject: [PATCH 01/18] Add verifyConfiguration to BcryptHasher Port Laravel's verifyConfiguration() method and its supporting methods (isUsingCorrectAlgorithm, isUsingValidOptions) to verify that a hash was created with valid configuration parameters. --- src/hashing/src/BcryptHasher.php | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/hashing/src/BcryptHasher.php b/src/hashing/src/BcryptHasher.php index ef746d5f9..4671d353a 100644 --- a/src/hashing/src/BcryptHasher.php +++ b/src/hashing/src/BcryptHasher.php @@ -70,6 +70,43 @@ public function needsRehash(string $hashedValue, array $options = []): bool ]); } + /** + * Verify that the configuration of the given hash is less than or equal + * to the current hasher configuration. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + return $this->isUsingCorrectAlgorithm($hashedValue) && $this->isUsingValidOptions($hashedValue); + } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'bcrypt'; + } + + /** + * Verify the hashed value's options are valid (cost is not greater than configured). + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['options' => $options] = $this->info($hashedValue); + + if (! is_int($options['cost'] ?? null)) { + return false; + } + + if ($options['cost'] > $this->rounds) { + return false; + } + + return true; + } + /** * Set the default password work factor. * From 0e9dbf2517f5a1fa61170e4c3ada08ee822bfdb3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:09:26 +0000 Subject: [PATCH 02/18] Add verifyConfiguration to ArgonHasher Port Laravel's verifyConfiguration() method and its supporting methods (isUsingCorrectAlgorithm, isUsingValidOptions) to verify that an Argon2i hash was created with valid configuration parameters (memory, time, threads). --- src/hashing/src/ArgonHasher.php | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/hashing/src/ArgonHasher.php b/src/hashing/src/ArgonHasher.php index 78dc033d1..6579624c7 100644 --- a/src/hashing/src/ArgonHasher.php +++ b/src/hashing/src/ArgonHasher.php @@ -94,6 +94,51 @@ public function needsRehash(string $hashedValue, array $options = []): bool ]); } + /** + * Verify that the configuration of the given hash is less than or equal + * to the current hasher configuration. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + return $this->isUsingCorrectAlgorithm($hashedValue) && $this->isUsingValidOptions($hashedValue); + } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2i'; + } + + /** + * Verify the hashed value's options are valid (memory, time, threads are not greater than configured). + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['options' => $options] = $this->info($hashedValue); + + if ( + ! is_int($options['memory_cost'] ?? null) + || ! is_int($options['time_cost'] ?? null) + || ! is_int($options['threads'] ?? null) + ) { + return false; + } + + if ( + $options['memory_cost'] > $this->memory + || $options['time_cost'] > $this->time + || $options['threads'] > $this->threads + ) { + return false; + } + + return true; + } + /** * Set the default password memory factor. * From 3e220c130a3838853596d2ad69e5dd7661a34a15 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:10:08 +0000 Subject: [PATCH 03/18] Add isUsingCorrectAlgorithm override to Argon2IdHasher Override isUsingCorrectAlgorithm() to check for 'argon2id' algorithm. This allows the inherited verifyConfiguration() from ArgonHasher to work correctly for Argon2id hashes. --- src/hashing/src/Argon2IdHasher.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/hashing/src/Argon2IdHasher.php b/src/hashing/src/Argon2IdHasher.php index 913a55495..a10ff04b2 100644 --- a/src/hashing/src/Argon2IdHasher.php +++ b/src/hashing/src/Argon2IdHasher.php @@ -26,6 +26,14 @@ public function check(string $value, ?string $hashedValue, array $options = []): return password_verify($value, $hashedValue); } + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2id'; + } + /** * Get the algorithm that should be used for hashing. */ From bd8d55a1438061b38385026351a691528c148600 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:10:54 +0000 Subject: [PATCH 04/18] Add verifyConfiguration to HashManager Delegate verifyConfiguration() to the underlying driver to verify that a hash was created with valid configuration parameters. --- src/hashing/src/HashManager.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/hashing/src/HashManager.php b/src/hashing/src/HashManager.php index d3fe0ac3e..a65f18478 100644 --- a/src/hashing/src/HashManager.php +++ b/src/hashing/src/HashManager.php @@ -76,6 +76,23 @@ public function isHashed(string $value): bool return password_get_info($value)['algo'] !== null; } + /** + * Verify that the configuration of the given hash is less than or equal + * to the current hasher configuration. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + $driver = $this->driver(); + + if (method_exists($driver, 'verifyConfiguration')) { + return $driver->verifyConfiguration($hashedValue); + } + + return true; + } + /** * Get the default driver name. */ From 5d16dbc197ca2cdb727633c642b448c5d50561d6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:11:33 +0000 Subject: [PATCH 05/18] Add verifyConfiguration to Hash facade docblock Add @method annotation for verifyConfiguration() for IDE autocompletion and static analysis support. --- src/support/src/Facades/Hash.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/support/src/Facades/Hash.php b/src/support/src/Facades/Hash.php index e75a75ebe..d4e7ed66b 100644 --- a/src/support/src/Facades/Hash.php +++ b/src/support/src/Facades/Hash.php @@ -15,6 +15,7 @@ * @method static bool check(string $value, string|null $hashedValue, array $options = []) * @method static bool needsRehash(string $hashedValue, array $options = []) * @method static bool isHashed(string $value) + * @method static bool verifyConfiguration(string $hashedValue) * @method static string getDefaultDriver() * @method static mixed driver(string|null $driver = null) * @method static \Hypervel\Hashing\HashManager extend(string $driver, \Closure $callback) From 01780c50d6d95cc189411dfd2552ad53e9313a76 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:12:33 +0000 Subject: [PATCH 06/18] Add tests for verifyConfiguration in hashers Add comprehensive test coverage for the new verifyConfiguration() method across all hashers (Bcrypt, Argon, Argon2id) and HashManager. Tests cover: - Valid hash verification - Lower cost/options verification (should pass) - Higher cost/options verification (should fail) - Wrong algorithm verification (should fail) - HashManager delegation to driver --- tests/Hashing/HasherTest.php | 102 +++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/Hashing/HasherTest.php b/tests/Hashing/HasherTest.php index d5bbf8cb0..cd1205a49 100644 --- a/tests/Hashing/HasherTest.php +++ b/tests/Hashing/HasherTest.php @@ -127,6 +127,108 @@ public function testIsHashedWithNonHashedValue() $this->assertFalse($this->hashManager->isHashed('foo')); } + public function testBcryptVerifyConfigurationWithValidHash() + { + $hasher = new BcryptHasher(['rounds' => 10]); + $hash = $hasher->make('password'); + + $this->assertTrue($hasher->verifyConfiguration($hash)); + } + + public function testBcryptVerifyConfigurationWithLowerCost() + { + // Hash created with cost 4 + $lowCostHasher = new BcryptHasher(['rounds' => 4]); + $hash = $lowCostHasher->make('password'); + + // Verify with hasher configured for cost 10 - should pass (lower is ok) + $higherCostHasher = new BcryptHasher(['rounds' => 10]); + $this->assertTrue($higherCostHasher->verifyConfiguration($hash)); + } + + public function testBcryptVerifyConfigurationWithHigherCost() + { + // Hash created with cost 12 + $highCostHasher = new BcryptHasher(['rounds' => 12]); + $hash = $highCostHasher->make('password'); + + // Verify with hasher configured for cost 10 - should fail (higher than configured) + $lowerCostHasher = new BcryptHasher(['rounds' => 10]); + $this->assertFalse($lowerCostHasher->verifyConfiguration($hash)); + } + + public function testBcryptVerifyConfigurationWithWrongAlgorithm() + { + $argonHasher = new ArgonHasher(); + $argonHash = $argonHasher->make('password'); + + $bcryptHasher = new BcryptHasher(); + $this->assertFalse($bcryptHasher->verifyConfiguration($argonHash)); + } + + public function testArgonVerifyConfigurationWithValidHash() + { + $hasher = new ArgonHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $hash = $hasher->make('password'); + + $this->assertTrue($hasher->verifyConfiguration($hash)); + } + + public function testArgonVerifyConfigurationWithLowerOptions() + { + // Hash created with lower options + $lowOptionsHasher = new ArgonHasher(['memory' => 512, 'time' => 1, 'threads' => 1]); + $hash = $lowOptionsHasher->make('password'); + + // Verify with hasher configured for higher options - should pass + $higherOptionsHasher = new ArgonHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $this->assertTrue($higherOptionsHasher->verifyConfiguration($hash)); + } + + public function testArgonVerifyConfigurationWithHigherMemory() + { + // Hash created with higher memory + $highMemoryHasher = new ArgonHasher(['memory' => 2048, 'time' => 2, 'threads' => 2]); + $hash = $highMemoryHasher->make('password'); + + // Verify with hasher configured for lower memory - should fail + $lowerMemoryHasher = new ArgonHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $this->assertFalse($lowerMemoryHasher->verifyConfiguration($hash)); + } + + public function testArgonVerifyConfigurationWithWrongAlgorithm() + { + $bcryptHasher = new BcryptHasher(); + $bcryptHash = $bcryptHasher->make('password'); + + $argonHasher = new ArgonHasher(); + $this->assertFalse($argonHasher->verifyConfiguration($bcryptHash)); + } + + public function testArgon2idVerifyConfigurationWithValidHash() + { + $hasher = new Argon2IdHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $hash = $hasher->make('password'); + + $this->assertTrue($hasher->verifyConfiguration($hash)); + } + + public function testArgon2idVerifyConfigurationWithWrongAlgorithm() + { + // Use Argon2i hash with Argon2id hasher + $argonHasher = new ArgonHasher(); + $argonHash = $argonHasher->make('password'); + + $argon2idHasher = new Argon2IdHasher(); + $this->assertFalse($argon2idHasher->verifyConfiguration($argonHash)); + } + + public function testHashManagerVerifyConfigurationDelegatesToDriver() + { + $hash = $this->hashManager->make('password'); + $this->assertTrue($this->hashManager->verifyConfiguration($hash)); + } + protected function getContainer() { $container = Mockery::mock(ContainerInterface::class); From 748267622892ba98af7215adb54f1e192b48bc78 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:13:20 +0000 Subject: [PATCH 07/18] Override $primitiveCastTypes in HasAttributes Add the 10 missing cast types from Laravel: - encrypted, encrypted:array, encrypted:collection, encrypted:json, encrypted:object - hashed - immutable_date, immutable_datetime, immutable_custom_datetime - json:unicode --- .../Eloquent/Concerns/HasAttributes.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index b6c04518c..fa05fa990 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -14,6 +14,41 @@ trait HasAttributes { + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * The cache of the casters. */ From 06827fee12169be9ba5658f03fc09dd14b707b37 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:14:04 +0000 Subject: [PATCH 08/18] Add encryption helper methods to HasAttributes Add methods to support encrypted casts: - $encrypter static property for custom encrypter - encryptUsing() to set custom encrypter - currentEncrypter() to get the current encrypter - isEncryptedCastable() to detect encrypted cast types - fromEncryptedString() to decrypt values - castAttributeAsEncryptedString() to encrypt values --- .../Eloquent/Concerns/HasAttributes.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index fa05fa990..85bdd519d 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -10,6 +10,8 @@ use Hyperf\Contract\Castable; use Hyperf\Contract\CastsAttributes; use Hyperf\Contract\CastsInboundAttributes; +use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Date; trait HasAttributes @@ -59,6 +61,51 @@ trait HasAttributes */ protected static array $castsCache = []; + /** + * The encrypter instance that is used to encrypt attributes. + */ + protected static ?Encrypter $encrypter = null; + + /** + * Set the encrypter instance that will be used to encrypt attributes. + */ + public static function encryptUsing(?Encrypter $encrypter): void + { + static::$encrypter = $encrypter; + } + + /** + * Get the current encrypter being used by the model. + */ + public static function currentEncrypter(): Encrypter + { + return static::$encrypter ?? Crypt::getFacadeRoot(); + } + + /** + * Determine whether a value is an encrypted castable for inbound manipulation. + */ + protected function isEncryptedCastable(string $key): bool + { + return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + } + + /** + * Decrypt the given encrypted string. + */ + public function fromEncryptedString(string $value): mixed + { + return static::currentEncrypter()->decrypt($value, false); + } + + /** + * Cast the given attribute to an encrypted string. + */ + protected function castAttributeAsEncryptedString(string $key, mixed $value): string + { + return static::currentEncrypter()->encrypt($value, false); + } + /** * Resolve the custom caster class for a given key. */ From 3be5ad3227eef6013ae393dcf7b20efcfd7d3030 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:14:54 +0000 Subject: [PATCH 09/18] Add hashing helper method to HasAttributes Add castAttributeAsHashedString() to support the 'hashed' cast type. The method hashes plain text values and verifies configuration for already-hashed values to prevent accepting hashes with invalid config. --- .../Eloquent/Concerns/HasAttributes.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index 85bdd519d..5799b562f 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -13,6 +13,8 @@ use Hypervel\Encryption\Contracts\Encrypter; use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Facades\Hash; +use RuntimeException; trait HasAttributes { @@ -106,6 +108,28 @@ protected function castAttributeAsEncryptedString(string $key, mixed $value): st return static::currentEncrypter()->encrypt($value, false); } + /** + * Cast the given attribute to a hashed string. + * + * @throws RuntimeException + */ + protected function castAttributeAsHashedString(string $key, mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (! Hash::isHashed($value)) { + return Hash::make($value); + } + + if (! Hash::verifyConfiguration($value)) { + throw new RuntimeException("Could not verify the hashed value's configuration."); + } + + return $value; + } + /** * Resolve the custom caster class for a given key. */ From 58db2369ef94a06e49f57f7f64ffbef89661ffce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:17:24 +0000 Subject: [PATCH 10/18] Override castAttribute() and getCastType() for new cast types Add support for: - encrypted casts (decrypt before processing, strip encrypted: prefix) - json:unicode (same as array/json on GET) - immutable_date (returns CarbonImmutable) - immutable_datetime (returns CarbonImmutable) - immutable_custom_datetime (returns CarbonImmutable with custom format) Also adds isImmutableCustomDateTimeCast() helper method and getCastType() override to properly detect immutable custom datetime casts. --- .../Eloquent/Concerns/HasAttributes.php | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index 5799b562f..b539606f7 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -14,6 +14,8 @@ use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Date; use Hypervel\Support\Facades\Hash; +use Hyperf\Collection\Collection as BaseCollection; +use Hypervel\Support\Str; use RuntimeException; trait HasAttributes @@ -130,6 +132,105 @@ protected function castAttributeAsHashedString(string $key, mixed $value): ?stri return $value; } + /** + * Get the type of cast for a model attribute. + */ + protected function getCastType(string $key): string + { + $castType = $this->getCasts()[$key]; + + if ($this->isCustomDateTimeCast($castType)) { + return 'custom_datetime'; + } + + if ($this->isImmutableCustomDateTimeCast($castType)) { + return 'immutable_custom_datetime'; + } + + if ($this->isDecimalCast($castType)) { + return 'decimal'; + } + + return trim(strtolower($castType)); + } + + /** + * Determine if the cast type is an immutable custom date time cast. + */ + protected function isImmutableCustomDateTimeCast(string $cast): bool + { + return str_starts_with($cast, 'immutable_date:') + || str_starts_with($cast, 'immutable_datetime:'); + } + + /** + * Cast an attribute to a native PHP type. + */ + protected function castAttribute(string $key, mixed $value): mixed + { + $castType = $this->getCastType($key); + + if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { + return null; + } + + // If the key is one of the encrypted castable types, we'll first decrypt + // the value and update the cast type so we may leverage the following + // logic for casting this value to any additionally specified types. + if ($this->isEncryptedCastable($key)) { + $value = $this->fromEncryptedString($value); + + $castType = Str::after($castType, 'encrypted:'); + } + + switch ($castType) { + case 'int': + case 'integer': + return (int) $value; + case 'real': + case 'float': + case 'double': + return $this->fromFloat($value); + case 'decimal': + return $this->asDecimal($value, explode(':', $this->getCasts()[$key], 2)[1]); + case 'string': + return (string) $value; + case 'bool': + case 'boolean': + return (bool) $value; + case 'object': + return $this->fromJson($value, true); + case 'array': + case 'json': + case 'json:unicode': + return $this->fromJson($value); + case 'collection': + return new BaseCollection($this->fromJson($value)); + case 'date': + return $this->asDate($value); + case 'datetime': + case 'custom_datetime': + return $this->asDateTime($value); + case 'immutable_date': + return $this->asDate($value)->toImmutable(); + case 'immutable_custom_datetime': + case 'immutable_datetime': + return $this->asDateTime($value)->toImmutable(); + case 'timestamp': + return $this->asTimestamp($value); + } + + if ($this->isEnumCastable($key)) { + return $this->getEnumCastableAttributeValue($key, $value); + } + + if ($this->isClassCastable($key)) { + return $this->getClassCastableAttributeValue($key, $value); + } + + return $value; + } + /** * Resolve the custom caster class for a given key. */ From 0ddf242ead227f9212fda98d98092e0e6d08dee5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:18:08 +0000 Subject: [PATCH 11/18] Refactor castAttribute() to use match expression Replace switch statement with PHP 8 match expression for cleaner code. Extract default case handling into castAttributeDefault() helper method. --- .../Eloquent/Concerns/HasAttributes.php | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index b539606f7..c957d482d 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -183,43 +183,29 @@ protected function castAttribute(string $key, mixed $value): mixed $castType = Str::after($castType, 'encrypted:'); } - switch ($castType) { - case 'int': - case 'integer': - return (int) $value; - case 'real': - case 'float': - case 'double': - return $this->fromFloat($value); - case 'decimal': - return $this->asDecimal($value, explode(':', $this->getCasts()[$key], 2)[1]); - case 'string': - return (string) $value; - case 'bool': - case 'boolean': - return (bool) $value; - case 'object': - return $this->fromJson($value, true); - case 'array': - case 'json': - case 'json:unicode': - return $this->fromJson($value); - case 'collection': - return new BaseCollection($this->fromJson($value)); - case 'date': - return $this->asDate($value); - case 'datetime': - case 'custom_datetime': - return $this->asDateTime($value); - case 'immutable_date': - return $this->asDate($value)->toImmutable(); - case 'immutable_custom_datetime': - case 'immutable_datetime': - return $this->asDateTime($value)->toImmutable(); - case 'timestamp': - return $this->asTimestamp($value); - } + return match ($castType) { + 'int', 'integer' => (int) $value, + 'real', 'float', 'double' => $this->fromFloat($value), + 'decimal' => $this->asDecimal($value, explode(':', $this->getCasts()[$key], 2)[1]), + 'string' => (string) $value, + 'bool', 'boolean' => (bool) $value, + 'object' => $this->fromJson($value, true), + 'array', 'json', 'json:unicode' => $this->fromJson($value), + 'collection' => new BaseCollection($this->fromJson($value)), + 'date' => $this->asDate($value), + 'datetime', 'custom_datetime' => $this->asDateTime($value), + 'immutable_date' => $this->asDate($value)->toImmutable(), + 'immutable_custom_datetime', 'immutable_datetime' => $this->asDateTime($value)->toImmutable(), + 'timestamp' => $this->asTimestamp($value), + default => $this->castAttributeDefault($key, $value), + }; + } + /** + * Handle default cast attribute types (enum and class casts). + */ + protected function castAttributeDefault(string $key, mixed $value): mixed + { if ($this->isEnumCastable($key)) { return $this->getEnumCastableAttributeValue($key, $value); } From 710674992389869e99e02409a7d522dfa95b5424 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:19:23 +0000 Subject: [PATCH 12/18] Override setAttribute() for encrypted/hashed/unicode casts Add support for: - Encrypted casts on SET (encrypt before storing) - Hashed cast on SET (hash before storing) - json:unicode on SET (use JSON_UNESCAPED_UNICODE flag) Also adds: - getJsonCastFlags() to determine JSON encoding flags - castAttributeAsJson() override to use flags - asJson() override to accept flags parameter --- .../Eloquent/Concerns/HasAttributes.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index c957d482d..52e7f6e72 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -274,6 +274,101 @@ protected function casts(): array return []; } + /** + * Set a given attribute on the model. + * + * @return $this + */ + public function setAttribute(string $key, mixed $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)) { + return $this->setMutatedAttributeValue($key, $value); + } + + // If an attribute is listed as a "date", we'll convert it from a DateTime + // instance into a form proper for storage on the database tables using + // the connection grammar's date format. We will auto set the values. + if ($value && $this->isDateAttribute($key)) { + $value = $this->fromDateTime($value); + } + + if ($this->isEnumCastable($key)) { + $this->setEnumCastableAttribute($key, $value); + + return $this; + } + + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + + if ($this->isJsonCastable($key) && ! is_null($value)) { + $value = $this->castAttributeAsJson($key, $value); + } + + // If this attribute contains a JSON ->, we'll set the proper value in the + // attribute's underlying array. This takes care of properly nesting an + // attribute in the array's value in the case of deeply nested items. + if (str_contains($key, '->')) { + return $this->fillJsonAttribute($key, $value); + } + + if (! is_null($value) && $this->isEncryptedCastable($key)) { + $value = $this->castAttributeAsEncryptedString($key, $value); + } + + if (! is_null($value) && $this->hasCast($key, 'hashed')) { + $value = $this->castAttributeAsHashedString($key, $value); + } + + $this->attributes[$key] = $value; + + return $this; + } + + /** + * Cast the given attribute to JSON. + */ + protected function castAttributeAsJson(string $key, mixed $value): string + { + $value = $this->asJson($value, $this->getJsonCastFlags($key)); + + if ($value === false) { + throw new \Hyperf\Database\Model\JsonEncodingException( + "Unable to encode attribute [{$key}] for model [" . static::class . '] to JSON: ' . json_last_error_msg() + ); + } + + return $value; + } + + /** + * Get the JSON casting flags for the given attribute. + */ + protected function getJsonCastFlags(string $key): int + { + $flags = 0; + + if ($this->hasCast($key, ['json:unicode'])) { + $flags |= JSON_UNESCAPED_UNICODE; + } + + return $flags; + } + + /** + * Encode the given value as JSON. + */ + protected function asJson(mixed $value, int $flags = 0): string|false + { + return json_encode($value, $flags); + } + /** * Return a timestamp as DateTime object with time set to 00:00:00. * From 1da8aa01f013263440025064b40e437eb5caad19 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:20:18 +0000 Subject: [PATCH 13/18] Override isJsonCastable() to include new cast types Add json:unicode and encrypted:* types to the list of JSON-castable types to ensure proper JSON encoding on SET for these cast types. --- .../Eloquent/Concerns/HasAttributes.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index 52e7f6e72..1ac95892d 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -369,6 +369,24 @@ protected function asJson(mixed $value, int $flags = 0): string|false return json_encode($value, $flags); } + /** + * Determine whether a value is JSON castable for inbound manipulation. + */ + protected function isJsonCastable(string $key): bool + { + return $this->hasCast($key, [ + 'array', + 'json', + 'json:unicode', + 'object', + 'collection', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + ]); + } + /** * Return a timestamp as DateTime object with time set to 00:00:00. * From f0516bfa31260e6c6ff6c1383da22a932225d369 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:23:17 +0000 Subject: [PATCH 14/18] Move $primitiveCastTypes from trait to Model class PHP traits cannot override static properties from parent classes/traits. Move the $primitiveCastTypes property to the Model class instead to avoid the composition conflict with Hyperf's base model. --- .../Eloquent/Concerns/HasAttributes.php | 35 ------------------- src/core/src/Database/Eloquent/Model.php | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index 1ac95892d..5479d2bed 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -20,41 +20,6 @@ trait HasAttributes { - /** - * The built-in, primitive cast types supported by Eloquent. - * - * @var string[] - */ - protected static array $primitiveCastTypes = [ - 'array', - 'bool', - 'boolean', - 'collection', - 'custom_datetime', - 'date', - 'datetime', - 'decimal', - 'double', - 'encrypted', - 'encrypted:array', - 'encrypted:collection', - 'encrypted:json', - 'encrypted:object', - 'float', - 'hashed', - 'immutable_date', - 'immutable_datetime', - 'immutable_custom_datetime', - 'int', - 'integer', - 'json', - 'json:unicode', - 'object', - 'real', - 'string', - 'timestamp', - ]; - /** * The cache of the casters. */ diff --git a/src/core/src/Database/Eloquent/Model.php b/src/core/src/Database/Eloquent/Model.php index fb6c06e37..1dc4288a2 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -84,6 +84,41 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasTimestamps; use TransformsToResource; + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * The resolved builder class names by model. * From a54e34c373f14a255e63a120645b1357366e1d27 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:24:07 +0000 Subject: [PATCH 15/18] Add tests for new cast types Comprehensive tests for: - immutable_date cast (returns CarbonImmutable) - immutable_datetime cast (returns CarbonImmutable) - immutable_custom_datetime cast (returns CarbonImmutable) - json:unicode cast (preserves unicode characters) - encrypted cast (encrypts/decrypts values) - encrypted:array cast - encrypted:collection cast - encrypted:object cast - hashed cast (hashes plain text, preserves existing hashes) --- .../Concerns/HasAttributesCastsTest.php | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php diff --git a/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php new file mode 100644 index 000000000..3d3fe207a --- /dev/null +++ b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php @@ -0,0 +1,286 @@ +setRawAttributes([ + 'birth_date' => '2000-01-15', + ]); + + $date = $model->birth_date; + + $this->assertInstanceOf(CarbonImmutable::class, $date); + $this->assertSame('2000-01-15', $date->format('Y-m-d')); + } + + public function testImmutableDatetimeCastReturnsImmutableCarbon(): void + { + $model = new ImmutableDateModel(); + $model->setRawAttributes([ + 'created_at' => '2000-01-15 10:30:00', + ]); + + $datetime = $model->created_at; + + $this->assertInstanceOf(CarbonImmutable::class, $datetime); + $this->assertSame('2000-01-15 10:30:00', $datetime->format('Y-m-d H:i:s')); + } + + public function testImmutableCustomDatetimeCastReturnsImmutableCarbon(): void + { + $model = new ImmutableDateModel(); + // Use standard datetime format that asDateTime can parse + $model->setRawAttributes([ + 'formatted_at' => '2000-01-15 10:30:00', + ]); + + $datetime = $model->formatted_at; + + $this->assertInstanceOf(CarbonImmutable::class, $datetime); + $this->assertSame('2000-01-15', $datetime->format('Y-m-d')); + } + + public function testJsonUnicodeCastPreservesUnicodeOnSet(): void + { + $model = new JsonUnicodeModel(); + $model->data = ['name' => '日本語', 'emoji' => '😀']; + + $rawAttributes = $model->getAttributes(); + + // Should contain unescaped unicode + $this->assertStringContainsString('日本語', $rawAttributes['data']); + $this->assertStringContainsString('😀', $rawAttributes['data']); + // Should NOT contain escaped unicode like \u65e5 + $this->assertStringNotContainsString('\u', $rawAttributes['data']); + } + + public function testJsonUnicodeCastPreservesUnicodeOnGet(): void + { + $model = new JsonUnicodeModel(); + $model->setRawAttributes([ + 'data' => '{"name":"日本語","emoji":"😀"}', + ]); + + $data = $model->data; + + $this->assertSame('日本語', $data['name']); + $this->assertSame('😀', $data['emoji']); + } + + public function testEncryptedCastEncryptsOnSet(): void + { + $model = new EncryptedModel(); + $model->secret = 'my-secret-value'; + + $rawAttributes = $model->getAttributes(); + + // Should be encrypted (not plain text) + $this->assertNotSame('my-secret-value', $rawAttributes['secret']); + // Should be base64-encoded JSON (typical encryption format) + $this->assertMatchesRegularExpression('/^[A-Za-z0-9+\/=]+$/', $rawAttributes['secret']); + } + + public function testEncryptedCastDecryptsOnGet(): void + { + $encrypter = new Encrypter(str_repeat('a', 16)); + $encrypted = $encrypter->encrypt('my-secret-value', false); + + $model = new EncryptedModel(); + $model->setRawAttributes([ + 'secret' => $encrypted, + ]); + + $this->assertSame('my-secret-value', $model->secret); + } + + public function testEncryptedArrayCast(): void + { + $model = new EncryptedModel(); + $model->secret_array = ['key' => 'value', 'nested' => ['a' => 1]]; + + // Verify it's encrypted in raw attributes + $rawAttributes = $model->getAttributes(); + $this->assertNotSame(['key' => 'value', 'nested' => ['a' => 1]], $rawAttributes['secret_array']); + + // Verify it decrypts correctly + $this->assertSame(['key' => 'value', 'nested' => ['a' => 1]], $model->secret_array); + } + + public function testEncryptedCollectionCast(): void + { + $model = new EncryptedModel(); + $model->secret_collection = ['item1', 'item2', 'item3']; + + // Verify it's encrypted in raw attributes + $rawAttributes = $model->getAttributes(); + $this->assertIsString($rawAttributes['secret_collection']); + + // Verify it decrypts to collection + $collection = $model->secret_collection; + $this->assertInstanceOf(\Hyperf\Collection\Collection::class, $collection); + $this->assertSame(['item1', 'item2', 'item3'], $collection->all()); + } + + public function testEncryptedObjectCast(): void + { + $model = new EncryptedModel(); + $model->secret_object = ['key' => 'value']; + + // Verify it decrypts to stdClass + $object = $model->secret_object; + $this->assertInstanceOf(\stdClass::class, $object); + $this->assertSame('value', $object->key); + } + + public function testHashedCastHashesPlainTextOnSet(): void + { + $model = new HashedModel(); + $model->password = 'secret123'; + + $rawAttributes = $model->getAttributes(); + + // Should be hashed (starts with $2y$ for bcrypt) + $this->assertStringStartsWith('$2y$', $rawAttributes['password']); + // Should verify correctly + $this->assertTrue(password_verify('secret123', $rawAttributes['password'])); + } + + public function testHashedCastPreservesAlreadyHashedValues(): void + { + $hasher = new BcryptHasher(['rounds' => 4]); + $existingHash = $hasher->make('secret123'); + + $model = new HashedModel(); + $model->password = $existingHash; + + $rawAttributes = $model->getAttributes(); + + // Should keep the existing hash unchanged + $this->assertSame($existingHash, $rawAttributes['password']); + } + + /** + * Note: Testing verifyConfiguration failure would require mocking the Hash facade + * with a specific configuration. The verifyConfiguration logic is tested separately + * in HasherTest. Here we just verify the cast works with valid hashes. + */ + public function testHashedCastWorksWithValidExistingHash(): void + { + // Create a hash with the default hasher (bcrypt cost 10) + $hasher = new BcryptHasher(['rounds' => 10]); + $existingHash = $hasher->make('secret123'); + + $model = new HashedModel(); + $model->password = $existingHash; + + $rawAttributes = $model->getAttributes(); + + // Should keep the existing hash unchanged (since it's already hashed with valid config) + $this->assertSame($existingHash, $rawAttributes['password']); + } +} + +class ImmutableDateModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'birth_date' => 'immutable_date', + 'created_at' => 'immutable_datetime', + 'formatted_at' => 'immutable_datetime:d/m/Y', + ]; + } +} + +class JsonUnicodeModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'data' => 'json:unicode', + ]; + } +} + +class EncryptedModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'secret' => 'encrypted', + 'secret_array' => 'encrypted:array', + 'secret_collection' => 'encrypted:collection', + 'secret_json' => 'encrypted:json', + 'secret_object' => 'encrypted:object', + ]; + } +} + +class HashedModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'password' => 'hashed', + ]; + } +} From e4c5f29874ed89ad2bb93c48eba0016b5997d7fb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:26:30 +0000 Subject: [PATCH 16/18] Fix PHPStan errors - Cast decimal precision to int in castAttribute() - Change setAttribute return type annotation to @return static - Add $primitiveCastTypes to Pivot and MorphPivot classes (they use HasAttributes trait but don't extend Hypervel\Model) --- .../Eloquent/Concerns/HasAttributes.php | 4 +-- .../Eloquent/Relations/MorphPivot.php | 35 +++++++++++++++++++ .../src/Database/Eloquent/Relations/Pivot.php | 35 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index 5479d2bed..0ae85ee6f 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -151,7 +151,7 @@ protected function castAttribute(string $key, mixed $value): mixed return match ($castType) { 'int', 'integer' => (int) $value, 'real', 'float', 'double' => $this->fromFloat($value), - 'decimal' => $this->asDecimal($value, explode(':', $this->getCasts()[$key], 2)[1]), + 'decimal' => $this->asDecimal($value, (int) explode(':', $this->getCasts()[$key], 2)[1]), 'string' => (string) $value, 'bool', 'boolean' => (bool) $value, 'object' => $this->fromJson($value, true), @@ -242,7 +242,7 @@ protected function casts(): array /** * Set a given attribute on the model. * - * @return $this + * @return static */ public function setAttribute(string $key, mixed $value) { diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php index cdfa47a3c..adb525110 100644 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ b/src/core/src/Database/Eloquent/Relations/MorphPivot.php @@ -20,6 +20,41 @@ class MorphPivot extends BaseMorphPivot use HasObservers; use HasTimestamps; + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * Delete the pivot model record from the database. * diff --git a/src/core/src/Database/Eloquent/Relations/Pivot.php b/src/core/src/Database/Eloquent/Relations/Pivot.php index 085b717ef..936dcbb73 100644 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ b/src/core/src/Database/Eloquent/Relations/Pivot.php @@ -20,6 +20,41 @@ class Pivot extends BasePivot use HasObservers; use HasTimestamps; + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * Delete the pivot model record from the database. * From c7cda040389fa7d297d4c100e3ec00af76ec8b07 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:27:19 +0000 Subject: [PATCH 17/18] Apply php-cs-fixer formatting --- src/core/src/Database/Eloquent/Concerns/HasAttributes.php | 2 +- .../Database/Eloquent/Concerns/HasAttributesCastsTest.php | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index 0ae85ee6f..71a7f0f44 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; +use Hyperf\Collection\Collection as BaseCollection; use Hyperf\Contract\Castable; use Hyperf\Contract\CastsAttributes; use Hyperf\Contract\CastsInboundAttributes; @@ -14,7 +15,6 @@ use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Date; use Hypervel\Support\Facades\Hash; -use Hyperf\Collection\Collection as BaseCollection; use Hypervel\Support\Str; use RuntimeException; diff --git a/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php index 3d3fe207a..891c204a3 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php +++ b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php @@ -9,10 +9,9 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Encryption\Encrypter; use Hypervel\Hashing\BcryptHasher; -use Hypervel\Hashing\HashManager; use Hypervel\Support\Facades\Hash; use Hypervel\Testbench\TestCase; -use RuntimeException; +use stdClass; /** * @internal @@ -171,7 +170,7 @@ public function testEncryptedObjectCast(): void // Verify it decrypts to stdClass $object = $model->secret_object; - $this->assertInstanceOf(\stdClass::class, $object); + $this->assertInstanceOf(stdClass::class, $object); $this->assertSame('value', $object->key); } From e193e14eb549370601f5c9373bba4b4ca688e754 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:30:55 +0000 Subject: [PATCH 18/18] Add additional tests for edge cases and encryptUsing API - Test null value handling for encrypted casts (SET and GET) - Test null value handling for hashed cast (SET) - Test encryptUsing() with custom encrypter - Test currentEncrypter() returns custom encrypter when set - Test encrypter can be reset and replaced --- .../Concerns/HasAttributesCastsTest.php | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php index 891c204a3..32ca47956 100644 --- a/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php +++ b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php @@ -220,6 +220,89 @@ public function testHashedCastWorksWithValidExistingHash(): void // Should keep the existing hash unchanged (since it's already hashed with valid config) $this->assertSame($existingHash, $rawAttributes['password']); } + + public function testEncryptedCastHandlesNullOnSet(): void + { + $model = new EncryptedModel(); + $model->secret = null; + + $rawAttributes = $model->getAttributes(); + + // Null should remain null, not be encrypted + $this->assertNull($rawAttributes['secret']); + } + + public function testEncryptedCastHandlesNullOnGet(): void + { + $model = new EncryptedModel(); + $model->setRawAttributes([ + 'secret' => null, + ]); + + // Null should remain null, not throw decryption error + $this->assertNull($model->secret); + } + + public function testHashedCastHandlesNullOnSet(): void + { + $model = new HashedModel(); + $model->password = null; + + $rawAttributes = $model->getAttributes(); + + // Null should remain null, not be hashed + $this->assertNull($rawAttributes['password']); + } + + public function testEncryptUsingAllowsCustomEncrypter(): void + { + // Create a different encrypter with a different key + $customKey = str_repeat('b', 16); + $customEncrypter = new Encrypter($customKey); + + // Set it on the model class + EncryptedModel::encryptUsing($customEncrypter); + + $model = new EncryptedModel(); + $model->secret = 'custom-encrypted-value'; + + // Verify the value was encrypted + $rawAttributes = $model->getAttributes(); + $this->assertNotSame('custom-encrypted-value', $rawAttributes['secret']); + + // Verify it can be decrypted with the custom encrypter + $decrypted = $customEncrypter->decrypt($rawAttributes['secret'], false); + $this->assertSame('custom-encrypted-value', $decrypted); + + // Verify the model getter also decrypts correctly + $this->assertSame('custom-encrypted-value', $model->secret); + } + + public function testCurrentEncrypterReturnsCustomEncrypterWhenSet(): void + { + $customEncrypter = new Encrypter(str_repeat('c', 16)); + EncryptedModel::encryptUsing($customEncrypter); + + $this->assertSame($customEncrypter, EncryptedModel::currentEncrypter()); + } + + public function testEncryptUsingCanBeResetAndNewEncrypterSet(): void + { + $firstEncrypter = new Encrypter(str_repeat('d', 16)); + $secondEncrypter = new Encrypter(str_repeat('e', 16)); + + // Set first encrypter + EncryptedModel::encryptUsing($firstEncrypter); + $this->assertSame($firstEncrypter, EncryptedModel::currentEncrypter()); + + // Reset and set second encrypter + EncryptedModel::encryptUsing(null); + EncryptedModel::encryptUsing($secondEncrypter); + + // Should now use second encrypter + $this->assertSame($secondEncrypter, EncryptedModel::currentEncrypter()); + $this->assertNotSame($firstEncrypter, EncryptedModel::currentEncrypter()); + } } class ImmutableDateModel extends Model