GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — GitHub - std-out/simple-data-objects: Blazing-fast attribute-driven DTOs for PHP 8.4+ — compiled hydrators, zero reflection in production, works standalone or with Laravel

BranchesTags
Open more actions menu

Latest commit

 

History

86 Commits
86 Commits

Folders and files

NameName
Last commit message
Last commit date
.claude/skills/migrate-from-laravel-data
.claude/skills/migrate-from-laravel-data
 
 
.github
.github
 
 
.oss-scanner
.oss-scanner
 
 
bin
bin
 
 
config
config
 
 
docs
docs
 
 
src
src
 
 
tests
tests
 
 
.dockerignore
.dockerignore
 
 
.gitattributes
.gitattributes
 
 
.gitignore
.gitignore
 
 
CHANGELOG.md
CHANGELOG.md
 
 
CONTRIBUTING.md
CONTRIBUTING.md
 
 
Dockerfile
Dockerfile
 
 
LICENSE
LICENSE
 
 
Makefile
Makefile
 
 
README.md
README.md
 
 
composer.json
composer.json
 
 
docker-compose.yml
docker-compose.yml
 
 
infection.json5
infection.json5
 
 
package-lock.json
package-lock.json
 
 
package.json
package.json
 
 
phpstan.neon.dist
phpstan.neon.dist
 
 
phpunit.xml.dist
phpunit.xml.dist
 
 
pint.json
pint.json
 
 

Repository files navigation

Simple Data Objects

Tests Security Coverage Latest Version Total Downloads PHP License

Lightweight, attribute-driven DTOs for PHP 8.4+ — compiled hydration and serialization, zero reflection at runtime.
Built for bulk imports that need to stay memory-flat, Octane/Swoole/FrankenPHP APIs where every allocation costs RPS, and standalone PHP projects that don't want a Laravel app just for validation. Works standalone or inside Laravel 12–13.

composer require std-out/simple-data-objects

→ Full documentation


Why

Simple Data Objects
Bulk import / ETL lazyCollection() keeps memory flat regardless of row count
Large XML feeds lazyXml() streams a file one element at a time — the DTO describes the element
Octane / Swoole / FrankenPHP Compiled per-class closures — zero reflection, zero dispatch overhead per request
Non-Laravel projects Validation and casting work without a Laravel app
Boilerplate None — constructor props + attributes
Roundtrip from(toArray()) always works, mapped keys included
Pipelines Middleware-style input preprocessing, class or property level

Performance

Benchmarked against spatie/laravel-data — the most widely used full-featured data-object library in the PHP/Laravel ecosystem, and one we have a lot of respect for; it's more feature-rich than this library in several areas (lazy properties, wrappers, partial data, transformers). Identical DTO shapes, 20,000 iterations per scenario, PHP 8.4, inside a fully booted Laravel app (not a synthetic standalone script). Medians of 5 runs:

Scenario Simple Data Objects spatie/laravel-data Advantage
Hydration — flat DTO ~6,550,000 ops/s ~132,000 ops/s ~50× faster
Hydration — nested DTO ~3,390,000 ops/s ~95,000 ops/s ~36× faster
Hydration — collection of 20 ~209,000 ops/s ~10,200 ops/s ~21× faster
Serialization — flat DTO ~14,900,000 ops/s ~249,000 ops/s ~60× faster
Serialization — nested DTO ~7,500,000 ops/s ~166,000 ops/s ~47× faster
Streaming — 100k-row CSV import ~67,200 rows/s ~35,800 rows/s ~87% faster, same flat ~12 KB memory footprint
Streaming — 100k-element XML file¹ ~80,000 elements/s ~15,000 elements/s ~5× faster, same flat memory footprint

¹ benchmark:xml in the same benchmark app, one process per scenario, median of 3 runs: lazyXml() against a hand-written XMLReader loop feeding spatie/laravel-data, which has no XML support of its own.

Absolute numbers vary with hardware; the ratios stay stable across runs. CPU time per operation follows the same ratios — less CPU burned per request means more headroom per server. Streaming a large import with lazyCollection() keeps memory flat regardless of row count — the win there is architectural (no full materialization), not a per-row memory difference from spatie/laravel-data, which also streams comparably once both sides are measured on equal footing.

Don't take the numbers on faith — run the benchmarks yourself: clone the companion repo, make bench, or swap in your own payload shapes.

Where spatie/laravel-data is the better fit: it has years more production mileage, a larger community, and a broader feature set — lazy properties, wrappers, partial data, transformers, and deep integration with the rest of the Spatie ecosystem. This library trades some of that breadth for a narrower, compiled hot path. If those features matter more to you than raw hydration/serialization speed, spatie/laravel-data is the right choice.


Quick Look

use StdOut\SimpleDataObjects\BaseData;
use StdOut\SimpleDataObjects\Attributes\{Cast, Rules, Pipe};
use StdOut\SimpleDataObjects\Casts\DateTimeCast;
use StdOut\SimpleDataObjects\Pipes\TrimValuePipe;

class CreateOrderData extends BaseData
{
    public function __construct(
        #[Rules(['required', 'string', 'max:200'])]
        #[Pipe(TrimValuePipe::class)]
        public readonly string $title,

        #[Rules(['required', 'email'])]
        public readonly string $customerEmail,

        #[Cast(new DateTimeCast('Y-m-d'))]
        public readonly \DateTime $deliveryDate,

        public readonly ?string $notes = null,
    ) {}
}

// validate → pipe → cast → hydrate
$order = CreateOrderData::fromValidated($request->all());

$order->title;            // trimmed string
$order->deliveryDate;     // \DateTime object
$order->toArray();        // ['title' => ..., 'customerEmail' => ..., 'deliveryDate' => '2025-01-15']
$order->toJson();         // JSON string
$order->with(notes: 'x'); // immutable copy with override

Killer Features

DataPipe — input preprocessing middleware

Transform input before hydration, at class or property level:

use StdOut\SimpleDataObjects\Pipes\{TrimStringsPipe, NullifyEmptyStringsPipe};
use StdOut\SimpleDataObjects\Pipes\{TrimValuePipe, NullifyEmptyStringValuePipe};

// Class-level: runs on the entire input array
#[Pipe(TrimStringsPipe::class, NullifyEmptyStringsPipe::class)]
class ContactData extends BaseData { ... }

// Property-level: runs only on that field's value
class ProfileData extends BaseData
{
    public function __construct(
        #[Pipe(TrimValuePipe::class)]
        public readonly string $name,

        #[Pipe(TrimValuePipe::class, NullifyEmptyStringValuePipe::class)]
        public readonly ?string $bio = null,
    ) {}
}

Custom pipe in 3 lines:

final class UpperCasePipe implements ValuePipe
{
    public function handle(mixed $value, string $paramName, callable $next): mixed
    {
        return $next(is_string($value) ? strtoupper($value) : $value);
    }
}

Zero-reflection in production

from() and toArray() compile a specialized closure per class — plain properties become direct array reads. Enable the file cache and the compiled code persists between requests:

// bootstrap / AppServiceProvider — run once
MetadataRegistry::setStoragePath(storage_path('framework/data-objects'));

Pre-warm it on deploy so even the first request is hot:

vendor/bin/sdo-warm storage/framework/data-objects app/Data

Every worker then starts with opcache-compiled metadata and hydration/serialization code — zero reflection, zero compilation at runtime.

Streaming large datasets

lazyCollection() hydrates one item at a time as the collection is consumed — peak memory stays flat no matter how many rows flow through:

foreach (UserData::lazyCollection($csvRows) as $user) {
    $importer->process($user); // ~12 KB peak at 100k rows — flat regardless of row count
}

Streaming large XML files

lazyXml() reads a file one element at a time and hydrates each into a DTO — the document is never loaded as a whole. One element is one DTO: a property reads the child element with its own name, a structured child is a nested DTO, and three attributes cover the rest:

class OfferData extends BaseData   // <offer id="42"><name>…</name><price currency="UAH">499.90</price><picture>…</picture>…</offer>
{
    public function __construct(
        #[XmlAttribute]
        public readonly int $id,
        public readonly string $name,
        public readonly PriceData $price,
        #[XmlElement('picture')]
        public readonly array $pictures,
    ) {}
}

class PriceData extends BaseData
{
    public function __construct(
        #[XmlText]
        public readonly float $amount,
        #[XmlAttribute]
        public readonly string $currency,
    ) {}
}

OfferData::lazyXml('feed.xml', 'catalog/shop/offers/offer')
    ->filter(fn (OfferData $offer) => $offer->price->amount > 100)
    ->each(fn (OfferData $offer) => $importer->process($offer));

On a 52 MB file with 100,000 elements (PHP 8.4, booted Laravel app, one process per scenario): ~80,000 elements/s, with the process peaking at 55 MB — 1 MB above the same app sitting idle. A SimpleXML loop that handles one element at a time and accumulates nothing peaks at 692 MB, while memory_get_peak_usage() reports ~10 MB for both: the libxml tree lives outside PHP's memory manager, so neither that function nor memory_limit sees it. Collecting every row into an array before hydrating — the most common pattern — peaks at 998 MB. A hand-written XMLReader loop is just as flat on memory as lazyXml(), at ~35,000 elements/s with this library and ~15,000 with spatie/laravel-data. Reproduce with make bench-xml in the benchmark repo. → Streaming XML

Immutable copies with with()

$updated = $user->with(email: '[email protected]'); // original unchanged
$updated->equals($user);                           // false
$user->diff($updated);                             // ['email' => ['old@...', 'new@...']]

Typed collections with IDE generics

#[DataCollection(UserData::class)]
public readonly TypedDataCollection $members,

// IDE infers type throughout the chain:
$team->members->filter(fn (UserData $u) => $u->active)->first()->name;

Validation anywhere

// In Laravel — call fromRequest() yourself, auto-validates
$data = CreateOrderData::fromRequest($request);

// Or register SimpleDataObjectsServiceProvider (opt-in, not auto-discovered)
// and skip FormRequest entirely:
public function store(CreateOrderData $data) { /* already validated */ }

// Standalone — no Laravel app needed
CreateOrderData::validate($rawArray); // throws ValidationException

Never-throw error accumulation

fromResult() tries every field instead of stopping at the first one, with dot-paths for nested DTOs and collections:

$result = CreateOrderData::fromResult($request->all());

$result->ok();       // bool
$result->errors();   // ['deliveryDate' => 'Invalid date format', 'items.2.price' => '...']
$result->value();    // CreateOrderData — throws if !ok()

// fromValidatedResult() merges in #[Rules] failures the same way

Optional — PATCH semantics

Optional distinguishes "key not sent" from "key sent as null" — an omitted field means leave it untouched, not clear it:

class UpdateUserData extends BaseData
{
    public function __construct(
        public readonly string|Optional $name,
        public readonly string|Optional|null $bio,   // null = clear it, Optional = don't touch it
    ) {}
}

$data = UpdateUserData::from(['bio' => null]);
$data->definedOnly();   // ['bio' => null] — 'name' stays absent
$model->update($data->definedOnly());

Generating classes from a sample

Point sdo-generate at a real JSON or XML document and it writes the classes — nested DTOs, collections, types and key mapping inferred from the values:

vendor/bin/sdo-generate storage/samples/order.json app/Data
curl -s https://api.example.com/orders/1 | vendor/bin/sdo-generate - app/Data --class=OrderData
php artisan make:data OrderData --from-model=Order --id=1042 --with=customer,items

DataClassGenerator::fromValue($array, 'OrderData', 'App\\Data') does the same for any PHP array or object.


Schema generation — JSON Schema & TypeScript

jsonSchema() walks the same metadata as from()/toArray() — no extra reflection — into a JSON Schema (draft 2020-12). TypeScriptGenerator/bin/sdo-typescript build on top of it for .d.ts output:

OrderData::jsonSchema();
// ['type' => 'object', 'properties' => [...], 'required' => [...], '$defs' => [...]]
vendor/bin/sdo-typescript resources/js/types/data-objects.d.ts app/Data
export interface OrderData {
  id: number;
  shippingAddress: AddressData;
  status: 'pending' | 'shipped' | 'cancelled';
}

All Attributes

Attribute Where Effect
#[Cast(new DateTimeCast('Y-m-d'))] property type conversion on hydration + serialization
#[Rules(['required', 'email'])] property Laravel validation rules
#[InferRules] class auto-infer validation rules from property types
#[Pipe(TrimValuePipe::class)] property value-level preprocessing pipeline
#[Pipe(TrimStringsPipe::class)] class array-level preprocessing pipeline
#[Flatten] property inline nested DTO fields into parent
#[Hidden(except: ['admin'])] property exclude from toArray() / JSON, optionally per toArray(context:)
#[Sensitive] property keep the value out of exception messages, errors(), serialize() and var_dump() (with use HasSensitiveFields)
#[IgnoreIfNull] property omit from output when null
#[Computed] method add a derived, method-backed field to toArray()
#[MapPropertyName('input_key', ...)] property map input key(s) (aliases) → property, same name on output
#[MapInputName] / #[MapOutputName] property map hydration and serialization keys independently
#[TransformKeys(TransformKeys::SNAKE_CASE)] class transform all keys at class level
#[DataCollection(ItemData::class)] property typed collection of DTOs
#[Discriminator('type', ['card' => CardData::class])] abstract class polymorphic hydration — from() picks the subclass by field value
#[WrapIn('data')] class wrap toResponse()'s payload under a key
#[XmlAttribute] / #[XmlElement('name')] / #[XmlText] property where lazyXml() reads the property from: an XML attribute, a differently named child element, or the element's text

Built-in Casts

DateTimeCast · DateTimeImmutableCast · EnumCast · BooleanCast · IntegerCast · FloatCast · TrimCast · JsonCast · EncryptedCast (XChaCha20-Poly1305 with key IDs and field binding, or legacy XSalsa20-Poly1305)


Contributing

This library is actively developed. Missing a feature you need? Open a feature request or start a discussion — happy to add it.

Bug reports, feature ideas, and PRs are welcome — see CONTRIBUTING.md for the dev setup (one make build away) and the quality bar (100% coverage, enforced in CI).

Release history lives in the CHANGELOG.

License

MIT — see LICENSE.

About

Blazing-fast attribute-driven DTOs for PHP 8.4+ — compiled hydrators, zero reflection in production, works standalone or with Laravel

std-out.github.io/simple-data-objects/

Topics

data-objectsdata-transfer-objectdtohydrationlaravelperformancephpphp84php85serializationvalidation

Resources

Readme
MIT license

Contributing

Contributing

Security policy

Security policy
Activity
Custom properties

Stars

35 stars

Watchers

0 watching

Forks

0 forks
Report repository

Releases

Packages

Contributors

Languages

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.