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.

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.

Core API

class UserData extends Data class UserData extends BaseData

UserData::from($payload) UserData::from($payload)

Ours takes a single $data argument, not variadic ...$payloads — merge sources yourself before calling.

UserData::collect($items) UserData::collection($items)

Returns a TypedDataCollection, not a plain array.

$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.

UserData::validate($payload) UserData::validate($payload)

Throws ValidationException, works standalone (no Laravel app needed).

(no equivalent) UserData::fromResult($payload)

Accumulates every field error instead of throwing on the first one — see Error Accumulation.

$request->validated() then UserData::from(...) UserData::fromRequest($request) or UserData::fromValidated($request->all())

fromRequest() lives in the opt-in HasLaravelIntegration trait, not core BaseData.

$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.

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')] or #[MapPropertyName(...)]

Same idea, same name. MapPropertyName accepts multiple aliases for the same property.

#[MapOutputName('output_key')] #[MapOutputName('output_key')]

Same.

#[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.

#[Hidden] (property, unconditional) #[Hidden(except: ['admin'])] (property)

Ours can be conditionally revealed per toArray(context: 'admin') call — laravel-data's Hidden has no context system.

#[Computed] (property) #[Computed] (method)

Different target: laravel-data marks a property as derived; ours marks a method whose return value becomes a serialized field. Expect to turn a computed property into a method.

#[DataCollectionOf(ItemData::class)] #[DataCollection(ItemData::class)]

Same idea — typed collection of nested DTOs.

#[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.

(no equivalent) #[Discriminator('type', [...])] (class)

Declarative polymorphic hydration by a discriminator field. laravel-data doesn't ship a direct equivalent — you'd wire this up yourself via a custom factory.

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 readonly string $email;

If you were relying on laravel-data's per-property validation attributes, budget time to collapse each one into a Laravel rule string — there's no 1:1 attribute for attribute here.

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 readonly DateTime $deliveryDate;

Built-in casts here: DateTimeCast, DateTimeImmutableCast, EnumCast, BooleanCast, IntegerCast & FloatCast, TrimCast, JsonCast, CommaSeparatedCast, MoneyCast, UuidCast, EncryptedCast (XSalsa20-Poly1305).

laravel-data's #[WithTransformer] — a separate, output-only transformation step — has no distinct equivalent; a CastsValue::set() implementation covers the same ground on the serialization side.

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()) and the per-request ->only() / ->except() / ->include() partial-payload API. #[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 the common cases (snake/camel/studly/kebab) but not a custom mapper class.

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. Swap extends Data for extends BaseData.
  2. Replace variadic ::from(...$payloads) calls with a single merged array.
  3. Rewrite #[WithCast(X::class, ...$args)] to #[Cast(new X(...$args))].
  4. Collapse per-property validation attributes into #[Rules([...])] arrays, or drop them in favor of class-level #[InferRules].
  5. Rename #[DataCollectionOf] to #[DataCollection].
  6. Check every #[Computed] property — it needs to become a method.
  7. Check every #[Hidden] / wrap / Lazy usage against the gaps above before assuming it carries over unchanged.
  8. Run your test suite. fromResult() is worth adopting here even where laravel-data code used validate() + from() separately — it collects every error in one pass.

Released under the MIT License.