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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ package.json export-ignore
package-lock.json export-ignore
phpunit.xml.dist export-ignore
pint.json export-ignore
phpstan.neon.dist export-ignore
infection.json5 export-ignore
39 changes: 39 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,45 @@ jobs:
- name: Run tests
run: vendor/bin/phpunit --no-coverage

static-analysis:
name: PHPStan
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

- name: Set up PHP 8.4
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962
with:
php-version: "8.4"
coverage: none

- name: Install dependencies
run: composer install --no-interaction --prefer-dist

- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G

mutation:
name: Mutation testing
runs-on: ubuntu-latest
needs: test

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

- name: Set up PHP 8.4 with pcov
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962
with:
php-version: "8.4"
coverage: pcov

- name: Install dependencies
run: composer install --no-interaction --prefer-dist

- name: Run Infection
run: php -d memory_limit=1G vendor/bin/infection --threads=max --only-covering-test-cases --no-progress

coverage:
name: Coverage
runs-on: ubuntu-latest
Expand Down
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

## [2.6.0] — 2026-10-16

### Added
- `CommaSeparatedCast(maxItems: ...)` caps the item count of untrusted input;
the string is never split past the limit.
- `Support\SensitiveVarDumperCaster::register()` redacts `#[Sensitive]`
fields in Symfony VarDumper (`dump()`, `dd()`). Opt-in.

### Fixed
- `Money::toDecimal()` is exact for amounts above 2^53 (it went through a
float before) and about 25% faster.
- `fromValidated()` on large collections under `#[InferRules]` now validates
in linear time: 8 000 items take 0.2 s instead of 1.3 s. Wildcard rules
are expanded per item only when the result is identical; rules with `*`
in their parameters and `distinct` keep the wildcard.
- `toArray($context)` with ever-new context names no longer compiles and
keeps a serializer per name: contexts not declared in `#[Hidden(except:)]`
share one serializer, and at most 64 of them are cached per class.

### Changed
- PHPStan (level 5) runs in CI and covers the Laravel traits through the
classes that use them. `EncryptionKeyring::keys()` is documented as
`array<array-key, string>`, since numeric key IDs become integer keys.
- New tests pin the ciphertext formats, the blind-index construction, the
metadata cache defaults and the file-cache edge cases (mutation testing).
- Documentation: deploy order for the metadata cache (warm, then reload
PHP) and how to protect the cache directory.

## [2.5.0] — 2026-10-16

### Added
Expand Down Expand Up @@ -605,7 +633,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.5.0...HEAD
[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.6.0...HEAD
[2.6.0]: https://github.com/std-out/simple-data-objects/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...v2.5.0
[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
Expand Down
9 changes: 8 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
"illuminate/database": "^12.0|^13.0",
"illuminate/http": "^12.0|^13.0",
"illuminate/pagination": "^12.0|^13.0",
"infection/infection": "^0.35",
"laravel/pint": "^1.0",
"orchestra/testbench": "^11.0",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.0"
},
"suggest": {
Expand All @@ -48,11 +50,16 @@
},
"config": {
"sort-packages": true,
"optimize-autoloader": true
"optimize-autoloader": true,
"allow-plugins": {
"infection/extension-installer": true
}
},
"scripts": {
"lint": "vendor/bin/pint",
"lint:check": "vendor/bin/pint --test",
"analyse": "vendor/bin/phpstan analyse --memory-limit=1G",
"mutation": "php -d memory_limit=1G vendor/bin/infection --threads=max --only-covering-test-cases",
"test": "vendor/bin/phpunit --no-coverage",
"test:coverage": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml",
"coverage:check": "php bin/check-coverage.php build/coverage.xml 100"
Expand Down
9 changes: 8 additions & 1 deletion docs/attributes/sensitive.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ To queue a job that needs the card, pass an identifier (or a token from your pay

## Limits

- `var_export()` and Symfony VarDumper (`dump()`, `dd()`) read properties directly and ignore `__debugInfo()`, so they still show the value.
- Symfony VarDumper (`dump()`, `dd()`) reads properties directly and ignores `__debugInfo()`. Register the caster once at boot — for example in a service provider's `boot()` — to redact `#[Sensitive]` fields there too:

```php
\StdOut\SimpleDataObjects\Support\SensitiveVarDumperCaster::register();
```

It requires `symfony/var-dumper` (installed with Laravel) and changes nothing until you call it.
- `var_export()` has no hook and always shows 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.
- 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.

Expand Down
11 changes: 11 additions & 0 deletions docs/casts/comma-separated.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ $filter->toArray()['tags']; // 'a,b,c'
- Items are trimmed by default (`trim: true`); pass `trim: false` to keep raw whitespace.
- Serialization (`toArray()`) joins the array back with the same separator, so `from()`/`toArray()` round-trip.

## Limiting the number of items

Every item costs far more memory than its separator: a 10 MB string of commas expands to about 500 MB of array, and running out of memory is a fatal error that `tryFrom()` cannot catch. For untrusted input, cap the count:

```php
#[Cast(new CommaSeparatedCast(maxItems: 100))]
public readonly array $tags,
```

Input with more items — a string or an array — throws `InvalidArgumentException`, and the string is never split past the limit. Without `maxItems` there is no limit.

## Null Handling

Returns `null` for `null` input on both hydration and serialization.
21 changes: 21 additions & 0 deletions docs/features/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ It scans the sources for **concrete** `BaseData` subclasses (abstract bases are

Classes whose metadata cannot be exported (see [Limitations](#limitations)) are reported as `skipped` and keep using the in-memory cache. A broken DTO definition (e.g. conflicting attributes) fails the command immediately — deploy-time is exactly when you want to find out.

### Deploying a change

The cache is keyed by class name only, not by the class's source. A worker that keeps running the old cache file keeps applying the old attributes — a removed `#[Hidden]` or a changed `#[Rules]` does not take effect. Every deploy that changes a DTO needs, in this order:

1. **Warm:** `sdo-warm` (or `sdo:warm`). It always rebuilds every class, so stale files are replaced.
2. **Reload PHP:** `systemctl reload php-fpm`, `php artisan octane:reload`, or restart the queue workers. Long-running workers keep their in-memory cache, and with `opcache.validate_timestamps=0` opcache keeps serving the old file even after it changed on disk.

Skipping step 2 is safe only with `opcache.validate_timestamps=1` and short-lived FPM workers.

### Protecting the cache directory

Cache files are PHP code that every worker `require`s, so whoever can write to the directory can run code in your application. Keep the runtime user from writing there:

1. Run `sdo-warm` as the deploy user, not as the web server user.
2. Make the directory and files read-only for the runtime user, for example `chown -R deploy: storage/framework/data-objects && chmod 0755 … && chmod 0644 …/*.meta.php`.
3. Watch the directory with your file-integrity monitoring (PCI DSS 11.5.2): it changes only on deploy.

A read-only directory is safe: a class whose file is missing is built in memory and the failed write is ignored.

The library does not sign cache files. A signature checked before `require` would not help: the file could be swapped between the check and the `require`, and reading it twice would cancel the opcache benefit. File permissions are the control that works.

### Going further: opcache.preload

The warmed cache files are plain PHP, so they can be compiled into opcache **before the first request ever arrives** via [preloading](https://www.php.net/manual/en/opcache.preloading.php). Add to your preload script:
Expand Down
16 changes: 16 additions & 0 deletions infection.json5
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": ["src"]
},
"logs": {
"text": "build/infection.log",
"summary": "build/infection-summary.log"
},
"tmpDir": "build/infection",
"mutators": {
"@default": true
},
"timeout": 20,
"minCoveredMsi": 84
}
30 changes: 30 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
parameters:
level: 5
paths:
- src
- tests/Fixtures
ignoreErrors:
-
identifier: new.static
path: src/BaseData.php
-
identifier: method.notFound
message: '#BaseData::__construct\(\)#'
path: src/BaseData.php
-
identifier: return.type
path: src/Laravel/Console/MakeDataCommand.php
-
identifier: return.type
message: '#should return StdOut\\SimpleDataObjects\\(TypedDataCollection|Laravel\\PaginatedDataCollection)<.+> but returns StdOut\\SimpleDataObjects\\(TypedDataCollection|Laravel\\PaginatedDataCollection)<StdOut\\SimpleDataObjects\\BaseData>\.$#'
-
identifier: property.uninitializedReadonly
path: tests/Fixtures/*
-
identifier: property.onlyWritten
path: tests/Fixtures/SensitiveCardData.php
-
identifier: argument.type
paths:
- tests/Fixtures/MissingTargetDiscriminatorData.php
- tests/Fixtures/NonStringTargetDiscriminatorData.php
8 changes: 4 additions & 4 deletions src/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
public static function fromLazy(#[\SensitiveParameter] mixed $data): static
{
$class = static::class;
$reflector = self::$reflectors[$class] ??= new \ReflectionClass($class);

Check warning on line 66 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "AssignCoalesce": @@ @@ public static function fromLazy(#[\SensitiveParameter] mixed $data): static { $class = static::class; - $reflector = self::$reflectors[$class] ??= new \ReflectionClass($class); + $reflector = (self::$reflectors[$class] = new \ReflectionClass($class)); // A ghost's class is fixed at creation, so a #[Discriminator] class // must resolve the concrete class eagerly; hydration stays deferred.

// A ghost's class is fixed at creation, so a #[Discriminator] class
// must resolve the concrete class eagerly; hydration stays deferred.
Expand All @@ -86,19 +86,19 @@
$meta = MetadataRegistry::get($class);

if (! $meta->hasConstructor) {
(HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost);

Check warning on line 89 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $meta = MetadataRegistry::get($class); if (! $meta->hasConstructor) { - (HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost); + (HydratorCompiler::compilePopulate($class) ?? HydratorCompiler::$populators[$class])($normalized, $ghost); return; }

return;
}

$args = (HydratorCompiler::$argResolvers[$class] ?? HydratorCompiler::compileArgs($class))($normalized);

Check warning on line 94 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ return; } - $args = (HydratorCompiler::$argResolvers[$class] ?? HydratorCompiler::compileArgs($class))($normalized); + $args = (HydratorCompiler::compileArgs($class) ?? HydratorCompiler::$argResolvers[$class])($normalized); $ghost->__construct(...$args); // Hybrid: the constructor only covers its own parameters — the
$ghost->__construct(...$args);

// Hybrid: the constructor only covers its own parameters — the
// extra properties still need populating, same mechanism as the
// constructor-less path above.
if ($meta->hasExtraProperties) {
(HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost);

Check warning on line 101 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ // extra properties still need populating, same mechanism as the // constructor-less path above. if ($meta->hasExtraProperties) { - (HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost); + (HydratorCompiler::compilePopulate($class) ?? HydratorCompiler::$populators[$class])($normalized, $ghost); } }); }
}
});
}
Expand All @@ -120,7 +120,7 @@
*/
public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult
{
$collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class);

Check warning on line 123 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ */ public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult { - $collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class); + $collect = HydratorCompiler::compileCollecting(static::class) ?? HydratorCompiler::$collectingHydrators[static::class]; if (is_array($data)) { /** @var HydrationResult<static> */

if (is_array($data)) {
/** @var HydrationResult<static> */
Expand All @@ -131,7 +131,7 @@
return HydrationResult::success($data);
}

try {

Check warning on line 134 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "CatchBlockRemoval": @@ @@ try { $data = InputNormalizer::normalize(static::class, $data); - } catch (DataHydrationException $e) { - return HydrationResult::failure(['$input' => $e->getMessage()]); } catch (\Throwable) { return HydrationResult::failure(['$input' => 'Input could not be normalized.']); }
$data = InputNormalizer::normalize(static::class, $data);
} catch (DataHydrationException $e) {
return HydrationResult::failure(['$input' => $e->getMessage()]);
Expand Down Expand Up @@ -169,7 +169,7 @@
$class = static::class;

return LazyCollection::make(static function () use ($items, $class): \Generator {
$hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class);

Check warning on line 172 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $class = static::class; return LazyCollection::make(static function () use ($items, $class): \Generator { - $hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class); + $hydrate = HydratorCompiler::compile($class) ?? HydratorCompiler::$hydrators[$class]; foreach ($items as $item) { yield $item instanceof $class

foreach ($items as $item) {
yield $item instanceof $class
Expand All @@ -190,7 +190,7 @@
$class = static::class;

return LazyCollection::make(static function () use ($class, $uri, $path): \Generator {
$hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class);

Check warning on line 193 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $class = static::class; return LazyCollection::make(static function () use ($class, $uri, $path): \Generator { - $hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class); + $hydrate = HydratorCompiler::compile($class) ?? HydratorCompiler::$hydrators[$class]; foreach (XmlStream::read($class, $uri, $path) as $row) { yield $hydrate($row);

foreach (XmlStream::read($class, $uri, $path) as $row) {
yield $hydrate($row);
Expand All @@ -214,7 +214,7 @@
foreach ($meta->parameters as $param) {
if (array_key_exists($param->phpName, $overrides)) {
$value = $overrides[$param->phpName];
$value = match (true) {

Check warning on line 217 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ if (array_key_exists($param->phpName, $overrides)) { $value = $overrides[$param->phpName]; $value = match (true) { - $param->isPlain => $value, $param->isSensitive => ValueCaster::castSensitive($param, $value, static::class), default => ValueCaster::cast($param, $value), };
$param->isPlain => $value,
$param->isSensitive => ValueCaster::castSensitive($param, $value, static::class),
default => ValueCaster::cast($param, $value),
Expand Down Expand Up @@ -264,7 +264,7 @@

if ($meta->validationRules !== []) {
[$input, $rules] = $meta->validationInput($array);
static::validatorFactory()->make($input, $rules)->validate();
self::validatorFactory()->make($input, $rules)->validate();
}

return static::from($array);
Expand Down Expand Up @@ -309,7 +309,7 @@
}

[$input, $rules] = $meta->validationInput($data);
$validator = static::validatorFactory()->make($input, $rules);
$validator = self::validatorFactory()->make($input, $rules);

try {
if (! $validator->fails()) {
Expand Down Expand Up @@ -348,7 +348,7 @@
$array = is_array($data) ? $data : InputNormalizer::normalize(static::class, $data);
[$input, $rules] = $meta->validationInput($array);

static::validatorFactory()
self::validatorFactory()
->make($input, $rules)
->validate();
}
Expand Down Expand Up @@ -429,7 +429,7 @@
return (SerializerCompiler::$serializers[static::class] ?? SerializerCompiler::compile(static::class))($this);
}

return (SerializerCompiler::$contextualSerializers[static::class][$context] ?? SerializerCompiler::compile(static::class, $context))($this);
return (SerializerCompiler::$contextualSerializers[static::class][$context] ?? SerializerCompiler::compile(static::class, $context))($this, $context);
}

public function toJson(int $flags = 0, ?string $context = null): string
Expand Down
23 changes: 19 additions & 4 deletions src/Casts/CommaSeparatedCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace StdOut\SimpleDataObjects\Casts;

use InvalidArgumentException;
use StdOut\SimpleDataObjects\Contracts\CastsValue;
use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema;

Expand All @@ -12,11 +13,16 @@ final class CommaSeparatedCast implements CastsValue, ProvidesJsonSchema
public function __construct(
private readonly string $separator = ',',
private readonly bool $trim = true,
) {}
private readonly ?int $maxItems = null,
) {
if ($maxItems !== null && $maxItems < 1) {
throw new InvalidArgumentException('CommaSeparatedCast $maxItems must be at least 1.');
}
}

public static function __set_state(array $state): self
{
return new self($state['separator'], $state['trim']);
return new self($state['separator'], $state['trim'], $state['maxItems'] ?? null);
}

public function get(#[\SensitiveParameter] mixed $value): ?array
Expand All @@ -26,7 +32,7 @@ public function get(#[\SensitiveParameter] mixed $value): ?array
}

if (is_array($value)) {
return $value;
return $this->maxItems === null || count($value) <= $this->maxItems ? $value : $this->tooMany();
}

$str = (string) $value;
Expand All @@ -35,7 +41,11 @@ public function get(#[\SensitiveParameter] mixed $value): ?array
return [];
}

$parts = explode($this->separator, $str);
if ($this->maxItems === null) {
$parts = explode($this->separator, $str);
} elseif (count($parts = explode($this->separator, $str, $this->maxItems + 1)) > $this->maxItems) {
$this->tooMany();
}

return $this->trim ? array_map('trim', $parts) : $parts;
}
Expand All @@ -49,6 +59,11 @@ public function set(#[\SensitiveParameter] mixed $value): ?string
return implode($this->separator, $value);
}

private function tooMany(): never
{
throw new InvalidArgumentException("CommaSeparatedCast accepts at most {$this->maxItems} items.");
}

public function jsonSchema(): array
{
return ['type' => 'string'];
Expand Down
2 changes: 1 addition & 1 deletion src/Casts/EncryptedCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ public function jsonSchema(): array

private function seal(#[\SensitiveParameter] string $plaintext): string
{
$keyId = (string) $this->currentKeyId;
$keyId = $this->currentKeyId;
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
sodium_pad($plaintext, self::PAD_BLOCK),
Expand Down
2 changes: 1 addition & 1 deletion src/Contracts/EncryptionKeyring.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ interface EncryptionKeyring
{
public function currentKeyId(): string;

/** @return array<string, string> */
/** @return array<array-key, string> */
public function keys(): array;
}
Loading
Loading