Migrating from spatie/laravel-data
This page maps spatie/laravel-data concepts and attributes onto Simple Data Objects, for teams moving an existing DTO layer over. The laravel-data side was checked against v4.23.
Not a drop-in replacement
The two libraries make different trade-offs. A few laravel-data features don't have an equivalent here yet — see What doesn't map over before you start. Treat this as a reference for a manual rewrite, not a find-and-replace.
Migrating with an AI agent
The repository ships a migrate-from-laravel-data skill for Claude Code that follows this page class by class, runs your tests after each one, and stops to ask wherever a feature has no equivalent.
Before you start
- Simple Data Objects requires PHP 8.4+ and Laravel 12 or 13 (
illuminate/*^12.0|^13.0). - Install it alongside
laravel-dataand migrate one class at a time, running the tests that cover it. Removespatie/laravel-dataonly when nothing references it any more.
Core API
class UserData extends Data → class UserData extends BaseData
Resource and Dto become BaseData too. Keep each property's mutability as it was — readonly is supported but not required.
UserData::from($payload) → UserData::from($payload)
Accepts an array, an Arrayable (models, collections), a JSON string, stdClass, or any object with public properties. Never validates — see Validation rules.
UserData::from($a, $b) → UserData::from([...$a, ...$b])
Ours takes a single $data argument, not variadic ...$payloads — merge sources yourself before calling.
UserData::validateAndCreate($payload) → UserData::fromValidated($payload)
Validates #[Rules], then hydrates; throws ValidationException.
UserData::validate($payload) → UserData::validate($payload)
Throws ValidationException, works standalone (no Laravel app needed). Returns void, where laravel-data returns the validated payload.
UserData::collect($items) → UserData::collection($items)
Always returns a TypedDataCollection; laravel-data returns the same kind of container it was given. Append ->all() where an array is expected.
UserData::optional($payload) → $payload === null ? null : UserData::from($payload)
$data->toArray() → $data->toArray()
Ours accepts an optional ?string $context for serialization groups — see #[Hidden] below.
$data->toJson() → $data->toJson()
Ours accepts int $flags = 0 and the same ?string $context.
$data->with(...) / $data->additional([...]) → #[Computed] method
Name clash. In laravel-data these append extra keys to the output; our with() returns a modified copy of the object. A derived output field is a #[Computed] method here.
$data->only(...) / $data->except(...) → $data->only(...) / $data->except(...)
Same names, different result: ours return a plain array, not a chainable data object.
$data->wrap('data') (instance call) → #[WrapIn('data')] (class attribute)
Ours is fixed at the class level; laravel-data's is set per call — there's no per-call override here.
(no equivalent) → UserData::fromResult($payload)
Accumulates every field error instead of throwing on the first one — see Error Accumulation.
(no equivalent) → UserData::lazyXml($file, $path)
Streams a large XML file into DTOs one element at a time — see XML imports.
Attributes
#[WithCast(SomeCast::class, ...$args)] → #[Cast(new SomeCast(...$args))]
Ours takes a constructed instance, not a class-string + arguments — see Casts below.
#[MapInputName('input_key')] → #[MapInputName('input_key')]
Same name, same form — only the import changes.
#[MapOutputName('output_key')] → #[MapOutputName('output_key')]
Same.
#[MapName('key')] (property) → #[MapPropertyName('key')]
One key for both input and output. MapPropertyName also accepts several aliases for the same property.
#[MapName('in', 'out')] (property) → #[MapInputName('in')] + #[MapOutputName('out')]
Ours splits input and output mapping into two attributes.
#[MapName(SnakeCaseMapper::class)] (class-level) → #[TransformKeys(TransformKeys::SNAKE_CASE)] (class-level)
Ours ships fixed strategies (snake/camel/studly/kebab) rather than a pluggable mapper class, and always renames keys in both directions — a class-level MapInputName/MapOutputName mapper for one direction only has no equivalent.
#[Hidden] → #[Hidden]
Drop-in. Ours can additionally be revealed per call — #[Hidden(except: ['admin'])] with toArray(context: 'admin') — which laravel-data's Hidden has no counterpart for.
#[Computed] (property) → #[Computed] (method)
Different target: laravel-data marks a property as derived; ours marks a method whose return value becomes a serialized field. Turn the computed property into a method, and every $data->name read into $data->name().
#[DataCollectionOf(ItemData::class)] → #[DataCollection(ItemData::class)]
Type the property as TypedDataCollection. If it was typed array, its consumers now receive a collection.
Spatie\LaravelData\Optional → StdOut\SimpleDataObjects\Optional
Same idea (string|Optional $name). Optional::create() becomes Optional::missing(); an Optional property cannot also declare a default value.
#[WithoutValidation] → (no attribute needed)
Ours only validates properties that carry #[Rules] or fall under class-level #[InferRules] — omit the attribute instead of opting out.
(no equivalent) → #[Pipe(...)]
Input-preprocessing middleware (value- or array-level), run before hydration. No laravel-data equivalent — closest is a custom cast, but a pipe runs before casting/validation, not instead of it.
(no equivalent) → #[Flatten]
Inlines a nested DTO's fields into the parent's input/output. No laravel-data equivalent.
(no equivalent) → #[IgnoreIfNull]
Omits a field from output entirely when null, instead of serializing it as null.
PropertyMorphableData + #[PropertyForMorph] → #[Discriminator('type', [...])] (abstract class)
Declarative polymorphic hydration by a discriminator field. laravel-data decides the concrete class in a morph() method; here that logic has to be expressed as a static value-to-class map.
Validation rules
laravel-data gives you one rule per attribute class (#[Max(255)], #[Email], #[In(['a', 'b'])], ...) plus automatic inference from PHP types. Simple Data Objects takes the opposite approach: #[Rules([...])] takes a plain Laravel validation rule array — the same strings/objects you'd put in a FormRequest — and #[InferRules] (class-level, opt-in) infers rules from property types the same way laravel-data does by default.
// laravel-data
#[Max(200)]
#[Email]
public string $email;
// Simple Data Objects
#[Rules(['required', 'string', 'max:200', 'email'])]
public string $email;
// or, under a class-level #[InferRules], only the extra rules
#[Rules(['max:200', 'email'], merge: true)]
public string $email;Without merge: true, an explicit #[Rules] replaces the inferred rules for that property.
from() never validates
laravel-data validates automatically when a data object is created from a request. Here from() only hydrates. Every place that relied on that must switch to a validating entry point: fromValidated(), fromRequest(), or controller injection.
Rules defined by overriding rules(), messages() or attributes() on the data class have no equivalent — move them into #[Rules], or keep a FormRequest for that endpoint.
Casts
laravel-data casts implement cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed and are wired up via #[WithCast(SomeCast::class, ...$args)] — the attribute constructs the cast for you from a class-string.
Here, a cast implements CastsValue (get(mixed $value): mixed for hydration, set(mixed $value): mixed for serialization) and the attribute takes an already-constructed instance:
// laravel-data
#[WithCast(DateTimeCast::class, 'Y-m-d')]
public DateTime $deliveryDate;
// Simple Data Objects
#[Cast(new DateTimeCast('Y-m-d'))]
public DateTime $deliveryDate;When porting a custom cast:
get()andset()receive only the value. A cast that reads the property definition, sibling values or the creation context has no direct equivalent.- Add a static
__set_state()so classes using the cast can be written to the warmed metadata cache; without it they are silently left out of it. A cast holding secrets must not implement it. #[WithTransformer]— a separate, output-only step inlaravel-data— becomes the cast'sset(). Fold a property's cast and transformer into oneCastsValue.- There is no global or class-level cast registration: each property carries its own
#[Cast]. Nested data objects and enums need none — they are resolved from the property type.
Built-in casts here: DateTimeCast, DateTimeImmutableCast, EnumCast, BooleanCast, IntegerCast & FloatCast, TrimCast, JsonCast, CommaSeparatedCast, MoneyCast, UuidCast, EncryptedCast (XSalsa20-Poly1305).
Laravel integration
Everything Laravel-specific here is opt-in per class — nothing changes until the class asks for it.
Data class type-hinted as a controller parameter → same, with HasLaravelIntegration + the service provider
laravel-data does this out of the box. Here the class opts in with the trait, and the provider must be registered by hand in bootstrap/providers.php — it is deliberately not auto-discovered. Without it, call UserData::fromRequest($request).
UserData::from($request) → UserData::fromRequest($request)
fromRequest() validates; plain from($request) would hydrate without validating.
UserData::from($model) → UserData::from($model) or UserData::fromModel($model)
from() uses $model->toArray(). fromModel() uses the model's own attributes plus only the relations marked #[WhenLoaded].
'address' => AddressData::class in model casts → same, with implements Castable + use IsEloquentCastable
DataCollection::class.':'.ItemData::class in model casts → AsDataCollection::of(ItemData::class)
Spatie\LaravelData\Concerns\WireableData → StdOut\SimpleDataObjects\Concerns\WireableData
Keep implements Wireable — see Livewire Integration.
UserData::collect($paginator) → UserData::paginatedCollection($paginator)
LengthAwarePaginator only — see Pagination. Cursor and simple paginators have no equivalent.
XML imports
laravel-data has no XML support, so XML feeds are usually imported with a hand-written loop: SimpleXML or XMLReader, a function turning each element into an array, then from(). lazyXml() replaces the loop and the mapping function — the DTO describes the element, and the file is streamed one element at a time:
// before
foreach (simplexml_load_file($file)->shop->offers->offer as $node) {
$importer->process(OfferData::from(offerToArray($node)));
}
// after
OfferData::lazyXml($file, 'catalog/shop/offers/offer')
->each(fn (OfferData $offer) => $importer->process($offer));This is an improvement you can make after the migration, not a required step. Before deleting the old mapping function, check what it did beyond copying values: only int/float/bool and int-backed enums are converted automatically, so dates, yes/no flags or decimal commas need a #[Cast]; and malformed XML now throws DataHydrationException. The full rules are on the Streaming XML page.
What doesn't map over
A few laravel-data features are genuinely not available here yet. Don't look for a workaround — these need real feature work on our side:
Lazyproperties (Lazy::create(),Lazy::when(),Lazy::whenLoaded(), the#[AutoLazy]family) and the per-request->only()/->except()/->include()/->exclude()partial-payload API, including request-driven includes.#[Hidden(except:)]gives you static, context-based field visibility, but notlaravel-data's dynamic, caller-controlled partials.- Per-property validation rule attributes (
#[Max],#[Email],#[In], and the rest ofSpatie\LaravelData\Attributes\Validation) — use#[Rules([...])]with Laravel rule strings instead, as shown above. - A pluggable
NameMapperfor key transformation —#[TransformKeys]covers snake/camel/studly/kebab, but notLowerCaseMapper,UpperCaseMapperor a custom mapper class. - Magic creation methods — a
public static function fromSomething(...)thatlaravel-data'sfrom()dispatches to by argument type. Ourfrom()does not look for them; call the method by name. - Value-injecting attributes —
#[FromRouteParameter],#[FromAuthenticatedUser],#[FromContainer]and their variants, and#[LoadRelation]. $data->all()(properties without transformation),empty(),factory(), and custompipeline()/normalizers()overrides.#[Pipe]preprocesses input before hydration and may cover a custom pipeline, but the mapping isn't mechanical.
Missing something?
If any of these are the reason you haven't migrated, say so — open a feature request or a discussion. Concrete use cases are what decides what gets built next.
Migration checklist
- Check PHP 8.4+ and Laravel 12/13, install the package alongside
laravel-data, and make sure the test suite is green before touching anything. - Swap
extends Data(orResource/Dto) forextends BaseData. - Rewrite the attributes:
#[WithCast(X::class, ...$args)]to#[Cast(new X(...$args))],#[DataCollectionOf]to#[DataCollection],#[MapName]to#[MapPropertyName]or#[TransformKeys]. - Port custom casts to
CastsValue, with__set_state(). - Collapse per-property validation attributes into
#[Rules([...])]arrays, or drop them in favor of class-level#[InferRules]. - Check every
#[Computed]property — it needs to become a method. - Rewrite the call sites: variadic
::from(...)to a single merged array,collect()tocollection(),validateAndCreate()tofromValidated(), and every->with()/->only()/->except()against the differences above. - Find every place that relied on automatic request validation and give it a validating entry point.
- Opt classes into the Laravel integration they use: controller injection, Eloquent casts, Livewire, pagination.
- Check every
Lazy/ wrap / magic-fromusage against What doesn't map over before assuming it carries over unchanged. - Run your test suite after each class.
fromResult()is worth adopting here even wherelaravel-datacode usedvalidate()+from()separately — it collects every error in one pass. - When nothing references
Spatie\LaravelDataany more,composer remove spatie/laravel-data.

