Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

## [2.4.0] — 2026-10-16

### Added
- `EncryptedCast(provider: ...)` with the `EncryptionKeyProvider` contract
loads the key from a KMS, Vault or HSM instead of the environment.
- `IsEloquentCastable` accepts an output context (`AccountData::class.':storage'`),
so `#[Hidden(except: ['storage'])]` fields are stored and survive the
next load. Without a context nothing changes.

### Security
- Input is marked `#[\SensitiveParameter]` in every entry point, compiled
hydrator, pipe, cast and Eloquent cast, so stack traces no longer carry
card numbers when `zend.exception_ignore_args` is off.
- The `EncryptedCast` derived key no longer shows up in `var_export()`,
`(array)` casts or `json_encode()` of the cast or its metadata.
- `DateTimeCast` and `DateTimeImmutableCast` without `inputFormat` mask card
numbers in PHP's own `DateMalformedStringException`; the exception type
is unchanged.

## [2.3.0] — 2026-10-16

### Added
Expand Down Expand Up @@ -568,7 +587,8 @@ Initial release.
`toResponse()`).
- CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`.

[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...HEAD
[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...HEAD
[2.4.0]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...v2.4.0
[2.3.0]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...v2.3.0
[2.2.1]: https://github.com/std-out/simple-data-objects/compare/v2.2.0...v2.2.1
[1.8.0]: https://github.com/std-out/simple-data-objects/compare/v1.7.2...v1.8.0
Expand Down
2 changes: 1 addition & 1 deletion docs/attributes/sensitive.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ To queue a job that needs the card, pass an identifier (or a token from your pay

- `var_export()` and Symfony VarDumper (`dump()`, `dd()`) read properties directly and ignore `__debugInfo()`, so they still show the value.
- A field without a cast, enum or nested type goes to the constructor as-is; a wrong type raises PHP's `TypeError`, which names the type, never the value.
- Set `zend.exception_ignore_args=On` in production so values passed to `from()` do not appear in stack traces.
- Every entry point (`from*()`, `tryFrom()`, `validate()`, `with()`, `collection()`), the compiled hydrators, pipes, casts and the Eloquent casts mark their input `#[\SensitiveParameter]`, so with `zend.exception_ignore_args=Off` stack traces show `Object(SensitiveParameterValue)` instead of the input. Your own casts and pipes need the same attribute on their `$value` / `$data` parameter. Keep `zend.exception_ignore_args=On` in production anyway: it also covers application frames.

## PAN masking

Expand Down
6 changes: 4 additions & 2 deletions docs/casts/custom.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ namespace StdOut\SimpleDataObjects\Contracts;

interface CastsValue
{
public function get(mixed $value): mixed;
public function set(mixed $value): mixed;
public function get(#[\SensitiveParameter] mixed $value): mixed;
public function set(#[\SensitiveParameter] mixed $value): mixed;
}
```

- `get()` — called during **hydration** (raw input → PHP value)
- `set()` — called during **serialization** (PHP value → serializable form)

PHP does not inherit `#[\SensitiveParameter]` from the interface. Add it to your own `get()` / `set()` so the value stays out of stack traces.

## Example: Money Cast

```php
Expand Down
27 changes: 24 additions & 3 deletions docs/casts/encrypted.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,28 @@ class UserData extends BaseData
}
```

A literal key is also accepted (`new EncryptedCast(key: '...')`) — useful in tests, but **avoid it in application code**: the key would live in your source tree. Exactly one of `key` / `env` must be provided; a missing or empty environment variable throws a `RuntimeException` immediately rather than silently encrypting with an empty key.
A literal key is also accepted (`new EncryptedCast(key: '...')`) — useful in tests, but **avoid it in application code**: the key would live in your source tree. Exactly one of `key` / `env` / `provider` must be provided; a missing or empty environment variable throws a `RuntimeException` immediately rather than silently encrypting with an empty key.

### Key provider (KMS, Vault, HSM)

To keep the key out of the process environment, pass a class implementing `EncryptionKeyProvider`. It is instantiated without arguments when the cast is built, and its `key()` is called once:

```php
use StdOut\SimpleDataObjects\Contracts\EncryptionKeyProvider;

final class KmsKeyProvider implements EncryptionKeyProvider
{
public function key(): string
{
return KmsClient::default()->decrypt(file_get_contents('/run/secrets/data-key.enc'));
}
}

#[Cast(new EncryptedCast(provider: KmsKeyProvider::class))]
public readonly string $taxId,
```

A class that does not implement the interface throws `InvalidArgumentException`; an empty key throws `RuntimeException`.

### Hydration (decrypt)

Expand Down Expand Up @@ -67,9 +88,9 @@ The cast keeps key material out of the places the library controls:

- **Never written to the metadata file cache.** `EncryptedCast` deliberately does not implement `__set_state()`, so classes using it are excluded from [file-based metadata persistence](../features/cache.md) — only the in-memory cache applies. Exporting the cast would write the key to disk in plaintext.
- **Not serializable.** `serialize()` on the cast throws a `LogicException`.
- **Redacted in debug output.** `var_dump()` / `print_r()` show `[redacted]` instead of the derived key, and the constructor parameter is marked `#[\SensitiveParameter]`, so the raw key does not appear in stack traces.
- **Hidden from every dump.** The derived key is held in a `\SensitiveParameterValue`, so `var_dump()`, `print_r()`, `var_export()`, `(array)` casts and `json_encode()` of the cast, or of metadata that contains it, never show it. The constructor parameter and the values passed to `get()` / `set()` are marked `#[\SensitiveParameter]`, so neither the key nor the plaintext appears in stack traces.

It cannot protect the key everywhere: a key read with `env:` stays in the process environment, and `var_export()` of the cast shows the derived key.
It cannot protect the key everywhere: a key read with `env:` stays in the process environment — use a [key provider](#key-provider-kms-vault-hsm) to avoid that.

## Tamper Detection

Expand Down
2 changes: 1 addition & 1 deletion docs/features/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,6 @@ Casts transform values in both directions. On `toArray()`, the `set()` method of
| `DateTimeCast('Y-m-d')` | `DateTime` object | `'2025-06-15'` |
| `BooleanCast` | `true` | `true` |
| `JsonCast` | `['key' => 'val']` | `'{"key":"val"}'` |
| `EncryptedCast('key')` | plaintext string | AES ciphertext |
| `EncryptedCast('key')` | plaintext string | XSalsa20-Poly1305 ciphertext |

See [Casts →](../casts/index.md) for the full reference.
26 changes: 26 additions & 0 deletions docs/laravel/eloquent-casting.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,32 @@ If you prefer the older `$casts` property style, the mapping is identical either
protected $casts = ['address' => AddressData::class];
```

### Keeping `#[Hidden]` fields in storage

The column is written with `toJson()`, so `#[Hidden]` fields are dropped and a required hidden field makes the next load fail. Pass an output [context](../attributes/hidden.md#context-groups) after the class name and list it in `except` to store such a field while keeping it out of API responses:

```php
class AccountData extends BaseData implements Castable
{
use IsEloquentCastable;

public function __construct(
public readonly string $login,
#[Hidden(except: ['storage'])]
public readonly string $apiToken,
) {}
}

protected function casts(): array
{
return ['account' => AccountData::class.':storage'];
}

$user->account->toArray(); // ['login' => '...'] — apiToken stays hidden everywhere else
```

Dirty-tracking compares the same context, so a change to a stored hidden field marks the attribute dirty.

## Collections

`AsDataCollection::of()` casts a JSON array column to a `TypedDataCollection`, mirroring Laravel's own `AsCollection::using()` idiom. The item class needs no trait or interface — only `AsDataCollection` itself is `Castable`:
Expand Down
24 changes: 12 additions & 12 deletions src/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ abstract class BaseData implements Arrayable, DataObject, JsonSerializable, Stri
* a plain object (public properties), or an existing instance of the
* same class (returned as-is — instances are immutable).
*/
public static function from(mixed $data): static
public static function from(#[\SensitiveParameter] mixed $data): static
{
$hydrate = HydratorCompiler::$hydrators[static::class] ?? HydratorCompiler::compile(static::class);

Expand All @@ -60,7 +60,7 @@ public static function from(mixed $data): static
* created but only some are ever read. Note that invalid input therefore
* throws on first access, not here.
*/
public static function fromLazy(mixed $data): static
public static function fromLazy(#[\SensitiveParameter] mixed $data): static
{
$class = static::class;
$reflector = self::$reflectors[$class] ??= new \ReflectionClass($class);
Expand Down Expand Up @@ -103,7 +103,7 @@ public static function fromLazy(mixed $data): static
});
}

public static function tryFrom(mixed $data): ?static
public static function tryFrom(#[\SensitiveParameter] mixed $data): ?static
{
try {
return static::from($data);
Expand All @@ -118,7 +118,7 @@ public static function tryFrom(mixed $data): ?static
*
* @return HydrationResult<static>
*/
public static function fromResult(mixed $data): HydrationResult
public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult
{
$collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class);

Expand Down Expand Up @@ -146,7 +146,7 @@ public static function fromResult(mixed $data): HydrationResult
/**
* @return TypedDataCollection<static>
*/
public static function collection(iterable $items): TypedDataCollection
public static function collection(#[\SensitiveParameter] iterable $items): TypedDataCollection
{
return TypedDataCollection::of(static::class, $items);
}
Expand All @@ -164,7 +164,7 @@ public static function jsonSchema(): array
*
* @return LazyCollection<int, static>
*/
public static function lazyCollection(iterable $items): LazyCollection
public static function lazyCollection(#[\SensitiveParameter] iterable $items): LazyCollection
{
$class = static::class;

Expand Down Expand Up @@ -199,12 +199,12 @@ public static function lazyXml(string $uri, string $path): LazyCollection
}

/** Explicit alias of from() for JSON input — same decoding and errors. */
public static function fromJson(string $json): static
public static function fromJson(#[\SensitiveParameter] string $json): static
{
return static::from($json);
}

public function with(mixed ...$overrides): static
public function with(#[\SensitiveParameter] mixed ...$overrides): static
{
$meta = MetadataRegistry::get(static::class);
$current = get_object_vars($this);
Expand Down Expand Up @@ -250,7 +250,7 @@ public function with(mixed ...$overrides): static
return $instance;
}

public static function fromValidated(mixed $data): static
public static function fromValidated(#[\SensitiveParameter] mixed $data): static
{
$array = is_array($data) ? $data : InputNormalizer::normalize(static::class, $data);
$meta = MetadataRegistry::get(static::class);
Expand All @@ -276,7 +276,7 @@ public static function fromValidated(mixed $data): static
*
* @return HydrationResult<static>
*/
public static function fromValidatedResult(mixed $data): HydrationResult
public static function fromValidatedResult(#[\SensitiveParameter] mixed $data): HydrationResult
{
if (! is_array($data)) {
try {
Expand Down Expand Up @@ -329,7 +329,7 @@ public static function fromValidatedResult(mixed $data): HydrationResult
}

/** @throws ValidationException */
public static function validate(mixed $data): void
public static function validate(#[\SensitiveParameter] mixed $data): void
{
$meta = MetadataRegistry::get(static::class);

Expand Down Expand Up @@ -358,7 +358,7 @@ public static function validate(mixed $data): void
*
* @throws DataHydrationException
*/
private static function resolveDiscriminated(ClassMeta $meta, array $data): string
private static function resolveDiscriminated(ClassMeta $meta, #[\SensitiveParameter] array $data): string
{
/** @var class-string<static>|null $target */
$target = $meta->resolveDiscriminatedClass($data);
Expand Down
4 changes: 2 additions & 2 deletions src/Casts/BooleanCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static function __set_state(array $state): self
return new self;
}

public function get(mixed $value): ?bool
public function get(#[\SensitiveParameter] mixed $value): ?bool
{
if ($value === null) {
return null;
Expand All @@ -29,7 +29,7 @@ public function get(mixed $value): ?bool
return in_array(strtolower((string) $value), self::TRUTHY, true);
}

public function set(mixed $value): ?bool
public function set(#[\SensitiveParameter] mixed $value): ?bool
{
return $value === null ? null : (bool) $value;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Casts/CommaSeparatedCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static function __set_state(array $state): self
return new self($state['separator'], $state['trim']);
}

public function get(mixed $value): ?array
public function get(#[\SensitiveParameter] mixed $value): ?array
{
if ($value === null) {
return null;
Expand All @@ -40,7 +40,7 @@ public function get(mixed $value): ?array
return $this->trim ? array_map('trim', $parts) : $parts;
}

public function set(mixed $value): ?string
public function set(#[\SensitiveParameter] mixed $value): ?string
{
if ($value === null) {
return null;
Expand Down
11 changes: 8 additions & 3 deletions src/Casts/DateTimeCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace StdOut\SimpleDataObjects\Casts;

use DateMalformedStringException;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
Expand All @@ -30,7 +31,7 @@ public static function __set_state(array $state): self
return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']);
}

public function get(mixed $value): ?DateTime
public function get(#[\SensitiveParameter] mixed $value): ?DateTime
{
if ($value === null) {
return null;
Expand Down Expand Up @@ -58,10 +59,14 @@ public function get(mixed $value): ?DateTime
return $dt;
}

return new DateTime((string) $value, $tz);
try {
return new DateTime((string) $value, $tz);
} catch (DateMalformedStringException $e) {
throw new DateMalformedStringException(Redactor::forMessage($e->getMessage()));
}
}

public function set(mixed $value): ?string
public function set(#[\SensitiveParameter] mixed $value): ?string
{
if ($value === null) {
return null;
Expand Down
11 changes: 8 additions & 3 deletions src/Casts/DateTimeImmutableCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace StdOut\SimpleDataObjects\Casts;

use DateMalformedStringException;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
Expand All @@ -30,7 +31,7 @@ public static function __set_state(array $state): self
return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']);
}

public function get(mixed $value): ?DateTimeImmutable
public function get(#[\SensitiveParameter] mixed $value): ?DateTimeImmutable
{
if ($value === null) {
return null;
Expand Down Expand Up @@ -58,10 +59,14 @@ public function get(mixed $value): ?DateTimeImmutable
return $dt;
}

return new DateTimeImmutable((string) $value, $tz);
try {
return new DateTimeImmutable((string) $value, $tz);
} catch (DateMalformedStringException $e) {
throw new DateMalformedStringException(Redactor::forMessage($e->getMessage()));
}
}

public function set(mixed $value): ?string
public function set(#[\SensitiveParameter] mixed $value): ?string
{
if ($value === null) {
return null;
Expand Down
Loading
Loading