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