Imported from jessegall/code-commandments (
skills/commandments/backend/spatie-data-hydration/SKILL.md). Install upstream withnpx skills add jessegall/code-commandments --skill spatie-data-hydration. Copyright stays with the author.
Spatie Data β feed the framework, don't hand-build
π± Load
fix-at-the-sourcefirst β the rule above all. Every sin is a symptom; trace the value to where it is BORN and fix it there, never where it surfaces. This skill serves that one.
A
Dataobject hydrates itself:::from([...])builds nestedData,#[DataCollectionOf]collections, and enum/date casts straight from a plain array. Feed it the simplest input and let it build. The moment you re-create a nested type, a cast, or a derivation at the call site, you've duplicated the mapping the class owns β and coupled every caller to it.
The principle
The sibling skill spatie-data teaches how to author a Data class. This one
teaches how to feed it. They share one root: the Data class is a declarative machine β ::from(),
::collect(), casts, #[DataCollectionOf], #[Computed], and name mappers already do the arrayβobject
work. A call site that re-does any of it by hand is redundant, and it duplicates a mapping that should
live in exactly one place β the class.
The one rule: pass the simplest input the class can build from
::from([...]) runs the whole pipeline recursively. Every value in that array is fed to the matching
property through its own hydration β nested Data, typed collection, enum, or date. So the value you write
should be the raw material, not the finished object.
Nested Data and collections auto-hydrate β don't wrap them
A property typed as a nested Data builds itself from a plain array; a #[DataCollectionOf(E)] builds each
element from an array. So X::from([...]) sitting in a parent ::from array is pure ceremony:
'sandbox' => ConsoleSandboxCopy::from(['label' => 'x'])β just'sandbox' => ['label' => 'x'].'modes' => [Mode::from([...]), Mode::from([...])]β just'modes' => [[...], [...]].
Pass the array; the parent nests it. (The one-argument, array-literal form is the redundant one β an
object source like X::from($model) is a real conversion, not this sin.)
Enums and dates auto-cast β pass the scalar
Spatie casts enums (native) and DateTimeInterface (built-in) straight from their raw value. So constructing
them at a hydration site is redundant:
'status' => WorkflowRunStatus::from($raw)β just'status' => $raw.
(A tryFrom, or a new DateTime($x, $tz) / createFromFormat(...) that carries a timezone or format the
default cast wouldn't reproduce, is not redundant β those change the semantics.)
A derivation belongs in a cast, not a call-site array_map
When each element is derived from a simpler value through a factory β array_map(E::for($enum), $cases) β
auto-hydration can't help (the input isn't the element's array shape). But a cast can: a #[WithCast]
(or per-item IterableItemCast) on the collection property owns the enum β E derivation once, and every
caller just passes the raw list. A factory that closes over services/$this can't move into a per-item cast,
so it stays at the call site β that's the boundary of this rule.
Don't build a Data only to discard it
Building X::from([...])->toArray() constructs a typed object just to flatten it back to an array β either
pass the source array, or type the receiving slot as X and pass the object. And to serialize a Data
object, call ->toArray() β never hand-write ['a' => $d->a, 'b' => $d->b, β¦], which silently drifts from
the class the moment a field is added.
Derive and map on the class, not the caller
A field that is a pure function of other fields is a #[Computed] property β computed once in the class, not
recomputed at every construction site. A boundary that renames keys (snake β camel) is one class-level
#[MapInputName(SnakeCaseMapper::class)] β not a hand-written translation array at each ::from.
Rules
- Don't
->toArray()aDatainto a slot that re-hydrates it; pass the object (or the source array) directly. Drop the->toArray()β the nested-Data/#[DataCollectionOf]slot takes the object as-is. - Move an element derivation (
array_map(E::for(...), $xs)) into a#[WithCast]/IterableItemCaston the collection property; pass the raw list.#[WithCast(SomeCast::class)] public array $itemsβ the cast runsE::for(...)per item; the call site passes the raw values. - Map a snake_case boundary with one class-level
#[MapInputName(SnakeCaseMapper::class)]+::from($src), not a hand-written key translation.#[MapInputName(SnakeCaseMapper::class)]on the class, thenSomeData::from($src). - Pass the enum itself to an enum slot β Spatie's enum cast keeps it; don't destructure it to
->valueat the hydration site only for it to be re-hydrated.'status' => $order->status, not'status' => $order->status->value. - Pass the raw scalar to an enum /
DateTimeInterfaceslot β Spatie auto-casts it; don't construct the value at the hydration site.'status' => $raw, not'status' => Status::from($raw). - Pass the plain array for a nested
Data/#[DataCollectionOf]slot β don't wrap it inX::from([...]).'slot' => ['a' => 1](or[['a' => 1], ...]for a collection), notX::from(['a' => 1]).
Worked example
data-to-array-roundtrip
A X::from(...)->toArray() sits in a ::from slot typed X that re-hydrates it β build β array β build
----------[ Bad ]----------
public function hold(BadgeCopy $badge, string $status): BadgeHolder
{
$toned = new BadgeCopy($badge->label, $this->toneFor($status));
return BadgeHolder::from(['badge' => $toned->toArray()]);
}
----------[ Good ]----------
// in Shop\Http\Pages\Hydration\BadgeHolderBuilder
// The FIX: the `badge` slot is typed `BadgeCopy`, so it takes the object as-is β no `->toArray()`,
// no rebuild.
public function holdReady(BadgeCopy $badge, string $status): BadgeHolder
{
$toned = new BadgeCopy($badge->label, $this->toneFor($status));
return BadgeHolder::from(['badge' => $toned]);
}
/*
* Shared leaf Data classes, enums, and stubs the hydration-site fixtures nest, derive, and cast. Declared
* once here (no findings of their own); the per-scenario site files reference them.
*/
final class BadgeCopy extends Data
{
public function __construct(public readonly string $label, public readonly string $tone) {}
}
The other 5 β one per rule β are in reference/examples.md.
Commands
vendor/bin/commandments judge --skill=backend/spatie-data-hydrationβ find every one of these in the codebase.vendor/bin/commandments info <sin>β what one rule flags, why it is a sin, and the fix. The sins here:data-to-array-roundtrip,derived-collection-cast,hand-key-remap,redundant-enum-unwrap,redundant-native-cast,redundant-nested-from.vendor/bin/commandments repent --sin=<sin>β auto-fix, fordata-to-array-roundtrip,redundant-enum-unwrap,redundant-native-cast,redundant-nested-from. Review it with--dry-runfirst.vendor/bin/commandments report --detector=<Detector> --reason="β¦" --ref=path:lineβ the flagged code is CORRECT under the architecture and the rule is wrong. That is the only thing a report claims: a finding you agree with is yours to fix, however far the fix cascades.
Reference
- Worked examples β every rule's bad β good, 6 of them.
- What fires, and why β the symptom each detector flags, for when you are holding a finding.
- Spatie Data hydration mechanics
Related skills
backend/spatie-dataβ the sibling: how to AUTHOR theDataclass this skill teaches you to feed β types,::fromvsnew, declaring casts/collections.
