[{"data":1,"prerenderedAt":1578},["ShallowReactive",2],{"post-\u002Fblog\u002Flaravel-dtos-hand-rolled-to-spatie":3,"related-\u002Fblog\u002Flaravel-dtos-hand-rolled-to-spatie":1078},{"id":4,"title":5,"author":6,"body":7,"category":1058,"description":1059,"draft":1060,"extension":1061,"featured":1060,"meta":1062,"navigation":115,"ogImage":1067,"path":1068,"publishedAt":1069,"readingTime":1070,"seo":1071,"stem":1072,"tags":1073,"updatedAt":1069,"__hash__":1077},"blog\u002Fblog\u002Flaravel-dtos-hand-rolled-to-spatie.md","Stop passing arrays around your Laravel app","Jacques",{"type":8,"value":9,"toc":1051},"minimark",[10,15,28,31,34,209,216,220,223,346,349,517,520,570,573,577,580,623,626,630,633,644,916,919,1020,1026,1029,1033,1040,1047],[11,12,14],"h2",{"id":13},"the-problem-nobody-names","The problem nobody names",[16,17,18,19,23,24,27],"p",{},"Open any mature Laravel codebase and grep for ",[20,21,22],"code",{},"$request->all()",". Then grep for ",[20,25,26],{},"$data['",". You'll find them everywhere — controllers shovelling associative arrays into services, services passing those arrays into jobs, jobs passing them into mailers. Every layer trusts that the array has the right keys, the right types, and the right shape.",[16,29,30],{},"It doesn't. It never does. You just haven't hit the bug yet.",[16,32,33],{},"Here's the smell:",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-php shiki shiki-themes github-dark","public function store(Request $request)\n{\n    $this->bookingService->create($request->all());\n}\n\n\u002F\u002F ...somewhere three files away\npublic function create(array $data)\n{\n    $start = Carbon::parse($data['start_at']);\n    $client = Client::find($data['client_id']);\n    \u002F\u002F ...\n}\n","php","",[20,42,43,70,76,104,110,117,124,142,147,175,198,204],{"__ignoreMap":40},[44,45,48,52,55,59,63,67],"span",{"class":46,"line":47},"line",1,[44,49,51],{"class":50},"snl16","public",[44,53,54],{"class":50}," function",[44,56,58],{"class":57},"svObZ"," store",[44,60,62],{"class":61},"s95oV","(",[44,64,66],{"class":65},"sDLfK","Request",[44,68,69],{"class":61}," $request)\n",[44,71,73],{"class":46,"line":72},2,[44,74,75],{"class":61},"{\n",[44,77,79,82,85,88,90,93,96,98,101],{"class":46,"line":78},3,[44,80,81],{"class":65},"    $this",[44,83,84],{"class":50},"->",[44,86,87],{"class":61},"bookingService",[44,89,84],{"class":50},[44,91,92],{"class":57},"create",[44,94,95],{"class":61},"($request",[44,97,84],{"class":50},[44,99,100],{"class":57},"all",[44,102,103],{"class":61},"());\n",[44,105,107],{"class":46,"line":106},4,[44,108,109],{"class":61},"}\n",[44,111,113],{"class":46,"line":112},5,[44,114,116],{"emptyLinePlaceholder":115},true,"\n",[44,118,120],{"class":46,"line":119},6,[44,121,123],{"class":122},"sAwPA","\u002F\u002F ...somewhere three files away\n",[44,125,127,129,131,134,136,139],{"class":46,"line":126},7,[44,128,51],{"class":50},[44,130,54],{"class":50},[44,132,133],{"class":57}," create",[44,135,62],{"class":61},[44,137,138],{"class":50},"array",[44,140,141],{"class":61}," $data)\n",[44,143,145],{"class":46,"line":144},8,[44,146,75],{"class":61},[44,148,150,153,156,159,162,165,168,172],{"class":46,"line":149},9,[44,151,152],{"class":61},"    $start ",[44,154,155],{"class":50},"=",[44,157,158],{"class":65}," Carbon",[44,160,161],{"class":50},"::",[44,163,164],{"class":57},"parse",[44,166,167],{"class":61},"($data[",[44,169,171],{"class":170},"sU2Wk","'start_at'",[44,173,174],{"class":61},"]);\n",[44,176,178,181,183,186,188,191,193,196],{"class":46,"line":177},10,[44,179,180],{"class":61},"    $client ",[44,182,155],{"class":50},[44,184,185],{"class":65}," Client",[44,187,161],{"class":50},[44,189,190],{"class":57},"find",[44,192,167],{"class":61},[44,194,195],{"class":170},"'client_id'",[44,197,174],{"class":61},[44,199,201],{"class":46,"line":200},11,[44,202,203],{"class":122},"    \u002F\u002F ...\n",[44,205,207],{"class":46,"line":206},12,[44,208,109],{"class":61},[16,210,211,212,215],{},"What's in ",[20,213,214],{},"$data","? You don't know. Your IDE doesn't know. The next developer doesn't know. The only way to find out is to read every caller, every test, and every form on the front end.",[11,217,219],{"id":218},"the-hand-rolled-fix","The hand-rolled fix",[16,221,222],{},"Before reaching for a package, do this. It's PHP 8.1+ and it's eleven lines:",[35,224,226],{"className":37,"code":225,"language":39,"meta":40,"style":40},"namespace App\\DataObjects;\n\nuse Carbon\\CarbonImmutable;\n\nfinal readonly class CreateBookingData\n{\n    public function __construct(\n        public int $clientId,\n        public CarbonImmutable $startAt,\n        public int $durationMinutes,\n        public ?string $notes = null,\n    ) {}\n}\n",[20,227,228,239,243,253,257,271,275,288,299,309,318,336,341],{"__ignoreMap":40},[44,229,230,233,236],{"class":46,"line":47},[44,231,232],{"class":50},"namespace",[44,234,235],{"class":57}," App\\DataObjects",[44,237,238],{"class":61},";\n",[44,240,241],{"class":46,"line":72},[44,242,116],{"emptyLinePlaceholder":115},[44,244,245,248,251],{"class":46,"line":78},[44,246,247],{"class":50},"use",[44,249,250],{"class":65}," Carbon\\CarbonImmutable",[44,252,238],{"class":61},[44,254,255],{"class":46,"line":106},[44,256,116],{"emptyLinePlaceholder":115},[44,258,259,262,265,268],{"class":46,"line":112},[44,260,261],{"class":50},"final",[44,263,264],{"class":50}," readonly",[44,266,267],{"class":50}," class",[44,269,270],{"class":57}," CreateBookingData\n",[44,272,273],{"class":46,"line":119},[44,274,75],{"class":61},[44,276,277,280,282,285],{"class":46,"line":126},[44,278,279],{"class":50},"    public",[44,281,54],{"class":50},[44,283,284],{"class":65}," __construct",[44,286,287],{"class":61},"(\n",[44,289,290,293,296],{"class":46,"line":144},[44,291,292],{"class":50},"        public",[44,294,295],{"class":50}," int",[44,297,298],{"class":61}," $clientId,\n",[44,300,301,303,306],{"class":46,"line":149},[44,302,292],{"class":50},[44,304,305],{"class":65}," CarbonImmutable",[44,307,308],{"class":61}," $startAt,\n",[44,310,311,313,315],{"class":46,"line":177},[44,312,292],{"class":50},[44,314,295],{"class":50},[44,316,317],{"class":61}," $durationMinutes,\n",[44,319,320,322,325,328,330,333],{"class":46,"line":200},[44,321,292],{"class":50},[44,323,324],{"class":50}," ?string",[44,326,327],{"class":61}," $notes ",[44,329,155],{"class":50},[44,331,332],{"class":65}," null",[44,334,335],{"class":61},",\n",[44,337,338],{"class":46,"line":206},[44,339,340],{"class":61},"    ) {}\n",[44,342,344],{"class":46,"line":343},13,[44,345,109],{"class":61},[16,347,348],{},"Now your controller has one job — turning a request into a typed object:",[35,350,352],{"className":37,"code":351,"language":39,"meta":40,"style":40},"public function store(StoreBookingRequest $request)\n{\n    $data = new CreateBookingData(\n        clientId: $request->integer('client_id'),\n        startAt: CarbonImmutable::parse($request->string('start_at')),\n        durationMinutes: $request->integer('duration_minutes'),\n        notes: $request->string('notes')->toString() ?: null,\n    );\n\n    $this->bookingService->create($data);\n}\n",[20,353,354,369,373,388,408,437,455,489,494,498,513],{"__ignoreMap":40},[44,355,356,358,360,362,364,367],{"class":46,"line":47},[44,357,51],{"class":50},[44,359,54],{"class":50},[44,361,58],{"class":57},[44,363,62],{"class":61},[44,365,366],{"class":65},"StoreBookingRequest",[44,368,69],{"class":61},[44,370,371],{"class":46,"line":72},[44,372,75],{"class":61},[44,374,375,378,380,383,386],{"class":46,"line":78},[44,376,377],{"class":61},"    $data ",[44,379,155],{"class":50},[44,381,382],{"class":50}," new",[44,384,385],{"class":65}," CreateBookingData",[44,387,287],{"class":61},[44,389,390,393,396,398,401,403,405],{"class":46,"line":106},[44,391,392],{"class":57},"        clientId",[44,394,395],{"class":61},": $request",[44,397,84],{"class":50},[44,399,400],{"class":57},"integer",[44,402,62],{"class":61},[44,404,195],{"class":170},[44,406,407],{"class":61},"),\n",[44,409,410,413,416,419,421,423,425,427,430,432,434],{"class":46,"line":112},[44,411,412],{"class":57},"        startAt",[44,414,415],{"class":61},": ",[44,417,418],{"class":65},"CarbonImmutable",[44,420,161],{"class":50},[44,422,164],{"class":57},[44,424,95],{"class":61},[44,426,84],{"class":50},[44,428,429],{"class":57},"string",[44,431,62],{"class":61},[44,433,171],{"class":170},[44,435,436],{"class":61},")),\n",[44,438,439,442,444,446,448,450,453],{"class":46,"line":119},[44,440,441],{"class":57},"        durationMinutes",[44,443,395],{"class":61},[44,445,84],{"class":50},[44,447,400],{"class":57},[44,449,62],{"class":61},[44,451,452],{"class":170},"'duration_minutes'",[44,454,407],{"class":61},[44,456,457,460,462,464,466,468,471,474,476,479,482,485,487],{"class":46,"line":126},[44,458,459],{"class":57},"        notes",[44,461,395],{"class":61},[44,463,84],{"class":50},[44,465,429],{"class":57},[44,467,62],{"class":61},[44,469,470],{"class":170},"'notes'",[44,472,473],{"class":61},")",[44,475,84],{"class":50},[44,477,478],{"class":57},"toString",[44,480,481],{"class":61},"() ",[44,483,484],{"class":50},"?:",[44,486,332],{"class":65},[44,488,335],{"class":61},[44,490,491],{"class":46,"line":144},[44,492,493],{"class":61},"    );\n",[44,495,496],{"class":46,"line":149},[44,497,116],{"emptyLinePlaceholder":115},[44,499,500,502,504,506,508,510],{"class":46,"line":177},[44,501,81],{"class":65},[44,503,84],{"class":50},[44,505,87],{"class":61},[44,507,84],{"class":50},[44,509,92],{"class":57},[44,511,512],{"class":61},"($data);\n",[44,514,515],{"class":46,"line":200},[44,516,109],{"class":61},[16,518,519],{},"And your service signature stops lying:",[35,521,523],{"className":37,"code":522,"language":39,"meta":40,"style":40},"public function create(CreateBookingData $data): Booking\n{\n    \u002F\u002F $data->startAt is a CarbonImmutable. Guaranteed.\n    \u002F\u002F $data->clientId is an int. Guaranteed.\n    \u002F\u002F Your IDE autocompletes everything.\n}\n",[20,524,525,547,551,556,561,566],{"__ignoreMap":40},[44,526,527,529,531,533,535,538,541,544],{"class":46,"line":47},[44,528,51],{"class":50},[44,530,54],{"class":50},[44,532,133],{"class":57},[44,534,62],{"class":61},[44,536,537],{"class":65},"CreateBookingData",[44,539,540],{"class":61}," $data)",[44,542,543],{"class":50},":",[44,545,546],{"class":65}," Booking\n",[44,548,549],{"class":46,"line":72},[44,550,75],{"class":61},[44,552,553],{"class":46,"line":78},[44,554,555],{"class":122},"    \u002F\u002F $data->startAt is a CarbonImmutable. Guaranteed.\n",[44,557,558],{"class":46,"line":106},[44,559,560],{"class":122},"    \u002F\u002F $data->clientId is an int. Guaranteed.\n",[44,562,563],{"class":46,"line":112},[44,564,565],{"class":122},"    \u002F\u002F Your IDE autocompletes everything.\n",[44,567,568],{"class":46,"line":119},[44,569,109],{"class":61},[16,571,572],{},"That's it. No package, no magic. You've replaced \"trust me, the array has these keys\" with a constructor signature the compiler enforces.",[11,574,576],{"id":575},"why-this-is-worth-the-ten-minutes","Why this is worth the ten minutes",[16,578,579],{},"Four things you get immediately:",[581,582,583,595,601,614],"ul",{},[584,585,586,590,591,594],"li",{},[587,588,589],"strong",{},"Autocomplete everywhere."," ",[20,592,593],{},"$data->"," and your IDE lists every field with its type. No more grepping callers to find out what's in the array.",[584,596,597,600],{},[587,598,599],{},"Refactors that actually work."," Rename a field and your editor catches every usage. Rename an array key and you find out at runtime, on Tuesday, in production.",[584,602,603,590,606,609,610,613],{},[587,604,605],{},"One place where parsing happens.",[20,607,608],{},"Carbon::parse()"," and ",[20,611,612],{},"(int)"," casts live in the constructor, not scattered across the codebase. The rest of your app trusts the types.",[584,615,616,590,619,622],{},[587,617,618],{},"Tests get shorter.",[20,620,621],{},"new CreateBookingData(clientId: 1, startAt: ..., ...)"," is two lines. Building an array fixture with the right shape is ten.",[16,624,625],{},"The hand-rolled version covers maybe eighty percent of what most projects need. For a small or mid-size codebase, you might never need more.",[11,627,629],{"id":628},"when-you-outgrow-the-hand-rolled-version","When you outgrow the hand-rolled version",[16,631,632],{},"Eventually you hit a wall. You want to hydrate DTOs from form requests, models, JSON payloads, and queue jobs without writing four constructors. You want validation rules to live on the DTO itself. You want it to serialise back to JSON for an API response. You want a single source of truth.",[16,634,635,636,643],{},"That's where ",[637,638,642],"a",{"href":639,"rel":640},"https:\u002F\u002Fgithub.com\u002Fspatie\u002Flaravel-data",[641],"nofollow","spatie\u002Flaravel-data"," earns its install:",[35,645,647],{"className":37,"code":646,"language":39,"meta":40,"style":40},"namespace App\\DataObjects;\n\nuse Spatie\\LaravelData\\Data;\nuse Carbon\\CarbonImmutable;\n\nfinal class CreateBookingData extends Data\n{\n    public function __construct(\n        public int $clientId,\n        public CarbonImmutable $startAt,\n        public int $durationMinutes,\n        public ?string $notes = null,\n    ) {}\n\n    public static function rules(): array\n    {\n        return [\n            'clientId' => ['required', 'integer', 'exists:clients,id'],\n            'startAt' => ['required', 'date', 'after:now'],\n            'durationMinutes' => ['required', 'integer', 'min:15', 'max:240'],\n            'notes' => ['nullable', 'string', 'max:500'],\n        ];\n    }\n}\n",[20,648,649,657,661,670,678,682,696,700,710,718,726,734,748,752,757,778,784,793,822,846,874,899,905,911],{"__ignoreMap":40},[44,650,651,653,655],{"class":46,"line":47},[44,652,232],{"class":50},[44,654,235],{"class":57},[44,656,238],{"class":61},[44,658,659],{"class":46,"line":72},[44,660,116],{"emptyLinePlaceholder":115},[44,662,663,665,668],{"class":46,"line":78},[44,664,247],{"class":50},[44,666,667],{"class":65}," Spatie\\LaravelData\\Data",[44,669,238],{"class":61},[44,671,672,674,676],{"class":46,"line":106},[44,673,247],{"class":50},[44,675,250],{"class":65},[44,677,238],{"class":61},[44,679,680],{"class":46,"line":112},[44,681,116],{"emptyLinePlaceholder":115},[44,683,684,686,688,690,693],{"class":46,"line":119},[44,685,261],{"class":50},[44,687,267],{"class":50},[44,689,385],{"class":57},[44,691,692],{"class":50}," extends",[44,694,695],{"class":57}," Data\n",[44,697,698],{"class":46,"line":126},[44,699,75],{"class":61},[44,701,702,704,706,708],{"class":46,"line":144},[44,703,279],{"class":50},[44,705,54],{"class":50},[44,707,284],{"class":65},[44,709,287],{"class":61},[44,711,712,714,716],{"class":46,"line":149},[44,713,292],{"class":50},[44,715,295],{"class":50},[44,717,298],{"class":61},[44,719,720,722,724],{"class":46,"line":177},[44,721,292],{"class":50},[44,723,305],{"class":65},[44,725,308],{"class":61},[44,727,728,730,732],{"class":46,"line":200},[44,729,292],{"class":50},[44,731,295],{"class":50},[44,733,317],{"class":61},[44,735,736,738,740,742,744,746],{"class":46,"line":206},[44,737,292],{"class":50},[44,739,324],{"class":50},[44,741,327],{"class":61},[44,743,155],{"class":50},[44,745,332],{"class":65},[44,747,335],{"class":61},[44,749,750],{"class":46,"line":343},[44,751,340],{"class":61},[44,753,755],{"class":46,"line":754},14,[44,756,116],{"emptyLinePlaceholder":115},[44,758,760,762,765,767,770,773,775],{"class":46,"line":759},15,[44,761,279],{"class":50},[44,763,764],{"class":50}," static",[44,766,54],{"class":50},[44,768,769],{"class":57}," rules",[44,771,772],{"class":61},"()",[44,774,543],{"class":50},[44,776,777],{"class":50}," array\n",[44,779,781],{"class":46,"line":780},16,[44,782,783],{"class":61},"    {\n",[44,785,787,790],{"class":46,"line":786},17,[44,788,789],{"class":50},"        return",[44,791,792],{"class":61}," [\n",[44,794,796,799,802,805,808,811,814,816,819],{"class":46,"line":795},18,[44,797,798],{"class":170},"            'clientId'",[44,800,801],{"class":50}," =>",[44,803,804],{"class":61}," [",[44,806,807],{"class":170},"'required'",[44,809,810],{"class":61},", ",[44,812,813],{"class":170},"'integer'",[44,815,810],{"class":61},[44,817,818],{"class":170},"'exists:clients,id'",[44,820,821],{"class":61},"],\n",[44,823,825,828,830,832,834,836,839,841,844],{"class":46,"line":824},19,[44,826,827],{"class":170},"            'startAt'",[44,829,801],{"class":50},[44,831,804],{"class":61},[44,833,807],{"class":170},[44,835,810],{"class":61},[44,837,838],{"class":170},"'date'",[44,840,810],{"class":61},[44,842,843],{"class":170},"'after:now'",[44,845,821],{"class":61},[44,847,849,852,854,856,858,860,862,864,867,869,872],{"class":46,"line":848},20,[44,850,851],{"class":170},"            'durationMinutes'",[44,853,801],{"class":50},[44,855,804],{"class":61},[44,857,807],{"class":170},[44,859,810],{"class":61},[44,861,813],{"class":170},[44,863,810],{"class":61},[44,865,866],{"class":170},"'min:15'",[44,868,810],{"class":61},[44,870,871],{"class":170},"'max:240'",[44,873,821],{"class":61},[44,875,877,880,882,884,887,889,892,894,897],{"class":46,"line":876},21,[44,878,879],{"class":170},"            'notes'",[44,881,801],{"class":50},[44,883,804],{"class":61},[44,885,886],{"class":170},"'nullable'",[44,888,810],{"class":61},[44,890,891],{"class":170},"'string'",[44,893,810],{"class":61},[44,895,896],{"class":170},"'max:500'",[44,898,821],{"class":61},[44,900,902],{"class":46,"line":901},22,[44,903,904],{"class":61},"        ];\n",[44,906,908],{"class":46,"line":907},23,[44,909,910],{"class":61},"    }\n",[44,912,914],{"class":46,"line":913},24,[44,915,109],{"class":61},[16,917,918],{},"Now the same class works in every direction:",[35,920,922],{"className":37,"code":921,"language":39,"meta":40,"style":40},"\u002F\u002F From a form request\n$data = CreateBookingData::from($request);\n\n\u002F\u002F From a model\n$data = CreateBookingData::from($booking);\n\n\u002F\u002F Back to JSON for an API\nreturn $data->toJson();\n\n\u002F\u002F In a queued job, fully typed and serialisable\ndispatch(new SendBookingConfirmation($data));\n",[20,923,924,929,946,950,955,970,974,979,995,999,1004],{"__ignoreMap":40},[44,925,926],{"class":46,"line":47},[44,927,928],{"class":122},"\u002F\u002F From a form request\n",[44,930,931,934,936,938,940,943],{"class":46,"line":72},[44,932,933],{"class":61},"$data ",[44,935,155],{"class":50},[44,937,385],{"class":65},[44,939,161],{"class":50},[44,941,942],{"class":57},"from",[44,944,945],{"class":61},"($request);\n",[44,947,948],{"class":46,"line":78},[44,949,116],{"emptyLinePlaceholder":115},[44,951,952],{"class":46,"line":106},[44,953,954],{"class":122},"\u002F\u002F From a model\n",[44,956,957,959,961,963,965,967],{"class":46,"line":112},[44,958,933],{"class":61},[44,960,155],{"class":50},[44,962,385],{"class":65},[44,964,161],{"class":50},[44,966,942],{"class":57},[44,968,969],{"class":61},"($booking);\n",[44,971,972],{"class":46,"line":119},[44,973,116],{"emptyLinePlaceholder":115},[44,975,976],{"class":46,"line":126},[44,977,978],{"class":122},"\u002F\u002F Back to JSON for an API\n",[44,980,981,984,987,989,992],{"class":46,"line":144},[44,982,983],{"class":50},"return",[44,985,986],{"class":61}," $data",[44,988,84],{"class":50},[44,990,991],{"class":57},"toJson",[44,993,994],{"class":61},"();\n",[44,996,997],{"class":46,"line":149},[44,998,116],{"emptyLinePlaceholder":115},[44,1000,1001],{"class":46,"line":177},[44,1002,1003],{"class":122},"\u002F\u002F In a queued job, fully typed and serialisable\n",[44,1005,1006,1009,1011,1014,1017],{"class":46,"line":200},[44,1007,1008],{"class":57},"dispatch",[44,1010,62],{"class":61},[44,1012,1013],{"class":50},"new",[44,1015,1016],{"class":65}," SendBookingConfirmation",[44,1018,1019],{"class":61},"($data));\n",[16,1021,1022,1025],{},[20,1023,1024],{},"Data::from()"," introspects the source — request, model, array, another DTO — and hydrates accordingly. Validation runs when you build from a request. Serialisation handles dates, enums, and nested DTOs without you wiring it up.",[16,1027,1028],{},"The trade-off is the package itself. It's a dependency, it has its own learning curve, and the magic is real magic — you'll occasionally need to read the source to understand why something hydrated the way it did. For a project that ships features daily across many endpoints, that's a fair price. For a side project with three forms, the hand-rolled version is better.",[11,1030,1032],{"id":1031},"what-i-actually-do","What I actually do",[16,1034,1035,1036,1039],{},"I start hand-rolled on every new project. The first time I find myself writing the same hydration code in three places, or wishing I could call ",[20,1037,1038],{},"->toJson()"," on the DTO, I install spatie. Not before.",[16,1041,1042,1043,1046],{},"The point isn't which version you pick. The point is that ",[20,1044,1045],{},"array $data"," in a service signature is a lie, and the cost of telling the truth is ten minutes of typing.",[1048,1049,1050],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":40,"searchDepth":78,"depth":78,"links":1052},[1053,1054,1055,1056,1057],{"id":13,"depth":72,"text":14},{"id":218,"depth":72,"text":219},{"id":575,"depth":72,"text":576},{"id":628,"depth":72,"text":629},{"id":1031,"depth":72,"text":1032},"engineering","Array shapes are the silent tax on every Laravel codebase. DTOs cost ten minutes to introduce and pay you back forever. Here's the hand-rolled version, then the spatie upgrade.",false,"md",{"slug":1063,"coverImage":40,"coverImageAlt":1064,"ogTitle":1065,"ogDescription":1066},"laravel-dtos-hand-rolled-to-spatie","A clean, labeled set of boxes versus a messy pile","Stop passing arrays around your Laravel app | DigiFellow","DTOs cost ten minutes and pay you back forever. The hand-rolled version, then the spatie upgrade.",null,"\u002Fblog\u002Flaravel-dtos-hand-rolled-to-spatie","2026-04-29","3",{"title":5,"description":1059},"blog\u002Flaravel-dtos-hand-rolled-to-spatie",[1074,39,1075,1076],"laravel","architecture","dtos","chjbFxrIDEwL3MXBOYIjvxGi0_cGzUPC8s-FQcEIGBE",[1079],{"id":1080,"title":1081,"author":6,"body":1082,"category":1058,"description":1565,"draft":1060,"extension":1061,"featured":1060,"meta":1566,"navigation":115,"ogImage":1067,"path":1571,"publishedAt":1572,"readingTime":1070,"seo":1573,"stem":1574,"tags":1575,"updatedAt":1572,"__hash__":1577},"blog\u002Fblog\u002Flaravel-declare-strict-types.md","Why I put declare(strict_types=1) at the top of every PHP file",{"type":8,"value":1083,"toc":1558},[1084,1088,1136,1139,1143,1166,1188,1196,1200,1203,1206,1301,1314,1343,1346,1350,1353,1370,1402,1405,1418,1461,1478,1481,1485,1488,1525,1531,1534,1549,1552,1555],[11,1085,1087],{"id":1086},"one-line-every-file","One line, every file",[35,1089,1091],{"className":37,"code":1090,"language":39,"meta":40,"style":40},"\u003C?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Services;\n",[20,1092,1093,1101,1105,1123,1127],{"__ignoreMap":40},[44,1094,1095,1098],{"class":46,"line":47},[44,1096,1097],{"class":50},"\u003C?",[44,1099,1100],{"class":65},"php\n",[44,1102,1103],{"class":46,"line":72},[44,1104,116],{"emptyLinePlaceholder":115},[44,1106,1107,1110,1112,1115,1117,1120],{"class":46,"line":78},[44,1108,1109],{"class":50},"declare",[44,1111,62],{"class":61},[44,1113,1114],{"class":65},"strict_types",[44,1116,155],{"class":50},[44,1118,1119],{"class":65},"1",[44,1121,1122],{"class":61},");\n",[44,1124,1125],{"class":46,"line":106},[44,1126,116],{"emptyLinePlaceholder":115},[44,1128,1129,1131,1134],{"class":46,"line":112},[44,1130,232],{"class":50},[44,1132,1133],{"class":57}," App\\Services",[44,1135,238],{"class":61},[16,1137,1138],{},"That's the whole post, really. The rest is why.",[11,1140,1142],{"id":1141},"what-it-actually-does","What it actually does",[16,1144,1145,1146,1149,1150,1153,1154,1157,1158,1161,1162,1165],{},"By default, PHP coerces scalar types at function boundaries. If a method is typed ",[20,1147,1148],{},"int $userId"," and you call it with ",[20,1151,1152],{},"'42'",", PHP shrugs and converts the string. Same for ",[20,1155,1156],{},"'42abc'"," — except now you've silently lost data, because ",[20,1159,1160],{},"(int)'42abc'"," is ",[20,1163,1164],{},"42"," with no warning.",[16,1167,1168,1169,1172,1173,1176,1177,1180,1181,1184,1185,1187],{},"With ",[20,1170,1171],{},"declare(strict_types=1)",", PHP stops coercing. Pass a string to an ",[20,1174,1175],{},"int"," parameter and you get a ",[20,1178,1179],{},"TypeError"," at the call site. Pass ",[20,1182,1183],{},"null"," to a non-nullable parameter and you get a ",[20,1186,1179],{},". The function's signature becomes a contract instead of a suggestion.",[16,1189,1190,1191,1195],{},"The declaration applies to the file it's in, not the file it's calling. That's the key thing to understand — your strict file calling someone else's loose file still enforces ",[1192,1193,1194],"em",{},"your"," types on the way in.",[11,1197,1199],{"id":1198},"why-laravel-makes-this-more-important-not-less","Why Laravel makes this more important, not less",[16,1201,1202],{},"Laravel leans on dynamic-ish patterns. Magic methods, facade proxies, model attribute access, request input that's always either a string or null depending on whether someone filled in the form. None of these care about your types until something explodes at runtime, usually in a queue worker, on a Sunday.",[16,1204,1205],{},"A typical example:",[35,1207,1209],{"className":37,"code":1208,"language":39,"meta":40,"style":40},"public function process(int $orderId): void\n{\n    $order = Order::findOrFail($orderId);\n    \u002F\u002F ...\n}\n\n\u002F\u002F In a controller:\n$this->processor->process($request->input('order_id'));\n",[20,1210,1211,1232,1236,1254,1258,1262,1266,1271],{"__ignoreMap":40},[44,1212,1213,1215,1217,1220,1222,1224,1227,1229],{"class":46,"line":47},[44,1214,51],{"class":50},[44,1216,54],{"class":50},[44,1218,1219],{"class":57}," process",[44,1221,62],{"class":61},[44,1223,1175],{"class":50},[44,1225,1226],{"class":61}," $orderId)",[44,1228,543],{"class":50},[44,1230,1231],{"class":50}," void\n",[44,1233,1234],{"class":46,"line":72},[44,1235,75],{"class":61},[44,1237,1238,1241,1243,1246,1248,1251],{"class":46,"line":78},[44,1239,1240],{"class":61},"    $order ",[44,1242,155],{"class":50},[44,1244,1245],{"class":65}," Order",[44,1247,161],{"class":50},[44,1249,1250],{"class":57},"findOrFail",[44,1252,1253],{"class":61},"($orderId);\n",[44,1255,1256],{"class":46,"line":106},[44,1257,203],{"class":122},[44,1259,1260],{"class":46,"line":112},[44,1261,109],{"class":61},[44,1263,1264],{"class":46,"line":119},[44,1265,116],{"emptyLinePlaceholder":115},[44,1267,1268],{"class":46,"line":126},[44,1269,1270],{"class":122},"\u002F\u002F In a controller:\n",[44,1272,1273,1276,1278,1281,1283,1286,1288,1290,1293,1295,1298],{"class":46,"line":144},[44,1274,1275],{"class":65},"$this",[44,1277,84],{"class":50},[44,1279,1280],{"class":61},"processor",[44,1282,84],{"class":50},[44,1284,1285],{"class":57},"process",[44,1287,95],{"class":61},[44,1289,84],{"class":50},[44,1291,1292],{"class":57},"input",[44,1294,62],{"class":61},[44,1296,1297],{"class":170},"'order_id'",[44,1299,1300],{"class":61},"));\n",[16,1302,1303,1306,1307,1309,1310,1313],{},[20,1304,1305],{},"$request->input('order_id')"," returns a string. Without strict types, PHP coerces it and the method runs. With strict types, you get a ",[20,1308,1179],{}," the first time you run the test — ",[1192,1311,1312],{},"before"," the bug ships. The fix is one line:",[35,1315,1317],{"className":37,"code":1316,"language":39,"meta":40,"style":40},"$this->processor->process($request->integer('order_id'));\n",[20,1318,1319],{"__ignoreMap":40},[44,1320,1321,1323,1325,1327,1329,1331,1333,1335,1337,1339,1341],{"class":46,"line":47},[44,1322,1275],{"class":65},[44,1324,84],{"class":50},[44,1326,1280],{"class":61},[44,1328,84],{"class":50},[44,1330,1285],{"class":57},[44,1332,95],{"class":61},[44,1334,84],{"class":50},[44,1336,400],{"class":57},[44,1338,62],{"class":61},[44,1340,1297],{"class":170},[44,1342,1300],{"class":61},[16,1344,1345],{},"Now the controller is explicit about what it's doing, and the service signature isn't lying anymore. Multiply this by every controller, every job, every event listener, and you stop a whole category of \"works in dev, breaks in prod\" bugs.",[11,1347,1349],{"id":1348},"the-three-gotchas","The three gotchas",[16,1351,1352],{},"In several years of running this on every project, three things have bitten me. They're all easy to handle once you know.",[16,1354,1355,1362,1363,1365,1366,1369],{},[587,1356,1357,1358,1361],{},"1. Carbon dates and ",[20,1359,1360],{},"int|string"," IDs."," Older Laravel code sometimes types model primary keys loosely, and packages occasionally return ",[20,1364,1360],{}," because UUIDs and auto-increments coexist. If you're calling third-party code, your strict file still has to satisfy ",[1192,1367,1368],{},"its"," signature. Cast at the boundary:",[35,1371,1373],{"className":37,"code":1372,"language":39,"meta":40,"style":40},"$user = User::find((int) $payload['user_id']);\n",[20,1374,1375],{"__ignoreMap":40},[44,1376,1377,1380,1382,1385,1387,1389,1392,1394,1397,1400],{"class":46,"line":47},[44,1378,1379],{"class":61},"$user ",[44,1381,155],{"class":50},[44,1383,1384],{"class":65}," User",[44,1386,161],{"class":50},[44,1388,190],{"class":57},[44,1390,1391],{"class":61},"((",[44,1393,1175],{"class":50},[44,1395,1396],{"class":61},") $payload[",[44,1398,1399],{"class":170},"'user_id'",[44,1401,174],{"class":61},[16,1403,1404],{},"Annoying for thirty seconds, then you forget it exists.",[16,1406,1407,1414,1415,1417],{},[587,1408,1409,1410,1413],{},"2. Float-to-int gotchas with ",[20,1411,1412],{},"divide"," and timestamps."," PHP's division returns float, even when both operands are integers. Under strict types, you can't pass that float to an ",[20,1416,1175],{}," parameter without an explicit cast. This is correct behaviour but it surprises people:",[35,1419,1421],{"className":37,"code":1420,"language":39,"meta":40,"style":40},"$page = $total \u002F $perPage;        \u002F\u002F float, even if both are int\n$this->goToPage((int) $page);     \u002F\u002F explicit cast required\n",[20,1422,1423,1442],{"__ignoreMap":40},[44,1424,1425,1428,1430,1433,1436,1439],{"class":46,"line":47},[44,1426,1427],{"class":61},"$page ",[44,1429,155],{"class":50},[44,1431,1432],{"class":61}," $total ",[44,1434,1435],{"class":50},"\u002F",[44,1437,1438],{"class":61}," $perPage;        ",[44,1440,1441],{"class":122},"\u002F\u002F float, even if both are int\n",[44,1443,1444,1446,1448,1451,1453,1455,1458],{"class":46,"line":72},[44,1445,1275],{"class":65},[44,1447,84],{"class":50},[44,1449,1450],{"class":57},"goToPage",[44,1452,1391],{"class":61},[44,1454,1175],{"class":50},[44,1456,1457],{"class":61},") $page);     ",[44,1459,1460],{"class":122},"\u002F\u002F explicit cast required\n",[16,1462,1463,1466,1467,1470,1471,1473,1474,1477],{},[587,1464,1465],{},"3. Tests that build models with array fixtures."," Factories handle this fine, but hand-built fixtures sometimes pass ",[20,1468,1469],{},"'1'"," where the model expects an ",[20,1472,1175],{},". Strict types in your ",[1192,1475,1476],{},"test"," file doesn't change how the model behaves — but if your test calls a service with a strict signature, that fixture string will trip the type check. The fix is usually that the fixture was wrong all along; strict types just surfaced it.",[16,1479,1480],{},"None of these are reasons not to turn it on. They're reasons to know what you're signing up for.",[11,1482,1484],{"id":1483},"how-to-roll-it-out","How to roll it out",[16,1486,1487],{},"On a new project, set it as a Pint rule and never think about it again:",[35,1489,1493],{"className":1490,"code":1491,"language":1492,"meta":40,"style":40},"language-json shiki shiki-themes github-dark","{\n    \"rules\": {\n        \"declare_strict_types\": true\n    }\n}\n","json",[20,1494,1495,1499,1507,1517,1521],{"__ignoreMap":40},[44,1496,1497],{"class":46,"line":47},[44,1498,75],{"class":61},[44,1500,1501,1504],{"class":46,"line":72},[44,1502,1503],{"class":65},"    \"rules\"",[44,1505,1506],{"class":61},": {\n",[44,1508,1509,1512,1514],{"class":46,"line":78},[44,1510,1511],{"class":65},"        \"declare_strict_types\"",[44,1513,415],{"class":61},[44,1515,1516],{"class":65},"true\n",[44,1518,1519],{"class":46,"line":106},[44,1520,910],{"class":61},[44,1522,1523],{"class":46,"line":112},[44,1524,109],{"class":61},[16,1526,1527,1530],{},[20,1528,1529],{},".\u002Fvendor\u002Fbin\u002Fpint"," will add the declaration to every file that doesn't have it. Commit, done.",[16,1532,1533],{},"On an existing project, don't try to enable it everywhere in one PR. You'll spend a week chasing type errors in code you weren't planning to touch. Instead:",[1535,1536,1537,1540,1543,1546],"ol",{},[584,1538,1539],{},"Add the Pint rule but don't run it across the whole repo yet.",[584,1541,1542],{},"New files get strict types automatically because Pint runs on commit.",[584,1544,1545],{},"When you touch an existing file for any reason, add the declaration as part of that change and fix the fallout in that file's scope.",[584,1547,1548],{},"Six months later you'll look up and most of the codebase is converted.",[16,1550,1551],{},"That's it. One line per file, paid back in TypeError tracebacks that point at the actual call site instead of three layers deeper where the coerced value finally caused something visible.",[16,1553,1554],{},"If you're already running Pint and PHPStan, you've done most of the cultural work. The declaration is just the runtime enforcement of what your static analyser is already telling you. Belt and braces, and the braces cost nothing.",[1048,1556,1557],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":40,"searchDepth":78,"depth":78,"links":1559},[1560,1561,1562,1563,1564],{"id":1086,"depth":72,"text":1087},{"id":1141,"depth":72,"text":1142},{"id":1198,"depth":72,"text":1199},{"id":1348,"depth":72,"text":1349},{"id":1483,"depth":72,"text":1484},"It's one line at the top of the file. It costs nothing, catches a category of bugs Laravel happily ignores, and has exactly three gotchas worth knowing about.",{"slug":1567,"coverImage":40,"coverImageAlt":1568,"ogTitle":1569,"ogDescription":1570},"laravel-declare-strict-types","A strict, no-nonsense red stamp","Why I put declare(strict_types=1) at the top of every PHP file | DigiFellow","One line, zero cost, catches bugs Laravel happily ignores. Plus the three gotchas worth knowing.","\u002Fblog\u002Flaravel-declare-strict-types","2026-05-06",{"title":1081,"description":1565},"blog\u002Flaravel-declare-strict-types",[1074,39,1576],"code-quality","FySHiBGbWH_Y81GwL_KNS50b1riCcGwvtoCkOfWNQdw",1779652914072]