Skip to content

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-data and migrate one class at a time, running the tests that cover it. Remove spatie/laravel-data only 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.

php
// 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:

php
// 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() and set() 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 in laravel-data — becomes the cast's set(). Fold a property's cast and transformer into one CastsValue.
  • 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

See Eloquent Attribute Casting.

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:

php
// 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:

  • Lazy properties (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 not laravel-data's dynamic, caller-controlled partials.
  • Per-property validation rule attributes (#[Max], #[Email], #[In], and the rest of Spatie\LaravelData\Attributes\Validation) — use #[Rules([...])] with Laravel rule strings instead, as shown above.
  • A pluggable NameMapper for key transformation — #[TransformKeys] covers snake/camel/studly/kebab, but not LowerCaseMapper, UpperCaseMapper or a custom mapper class.
  • Magic creation methods — a public static function fromSomething(...) that laravel-data's from() dispatches to by argument type. Our from() 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 custom pipeline() / 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 ​

  1. 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.
  2. Swap extends Data (or Resource / Dto) for extends BaseData.
  3. Rewrite the attributes: #[WithCast(X::class, ...$args)] to #[Cast(new X(...$args))], #[DataCollectionOf] to #[DataCollection], #[MapName] to #[MapPropertyName] or #[TransformKeys].
  4. Port custom casts to CastsValue, with __set_state().
  5. Collapse per-property validation attributes into #[Rules([...])] arrays, or drop them in favor of class-level #[InferRules].
  6. Check every #[Computed] property — it needs to become a method.
  7. Rewrite the call sites: variadic ::from(...) to a single merged array, collect() to collection(), validateAndCreate() to fromValidated(), and every ->with() / ->only() / ->except() against the differences above.
  8. Find every place that relied on automatic request validation and give it a validating entry point.
  9. Opt classes into the Laravel integration they use: controller injection, Eloquent casts, Livewire, pagination.
  10. Check every Lazy / wrap / magic-from usage against What doesn't map over before assuming it carries over unchanged.
  11. Run your test suite after each class. fromResult() is worth adopting here even where laravel-data code used validate() + from() separately — it collects every error in one pass.
  12. When nothing references Spatie\LaravelData any more, composer remove spatie/laravel-data.

Released under the MIT License.