Stop passing arrays around your Laravel app
The problem nobody names
Open any mature Laravel codebase and grep for $request->all(). Then grep for $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.
It doesn't. It never does. You just haven't hit the bug yet.
Here's the smell:
public function store(Request $request)
{
$this->bookingService->create($request->all());
}
// ...somewhere three files away
public function create(array $data)
{
$start = Carbon::parse($data['start_at']);
$client = Client::find($data['client_id']);
// ...
}
What's in $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.
The hand-rolled fix
Before reaching for a package, do this. It's PHP 8.1+ and it's eleven lines:
namespace App\DataObjects;
use Carbon\CarbonImmutable;
final readonly class CreateBookingData
{
public function __construct(
public int $clientId,
public CarbonImmutable $startAt,
public int $durationMinutes,
public ?string $notes = null,
) {}
}
Now your controller has one job — turning a request into a typed object:
public function store(StoreBookingRequest $request)
{
$data = new CreateBookingData(
clientId: $request->integer('client_id'),
startAt: CarbonImmutable::parse($request->string('start_at')),
durationMinutes: $request->integer('duration_minutes'),
notes: $request->string('notes')->toString() ?: null,
);
$this->bookingService->create($data);
}
And your service signature stops lying:
public function create(CreateBookingData $data): Booking
{
// $data->startAt is a CarbonImmutable. Guaranteed.
// $data->clientId is an int. Guaranteed.
// Your IDE autocompletes everything.
}
That's it. No package, no magic. You've replaced "trust me, the array has these keys" with a constructor signature the compiler enforces.
Why this is worth the ten minutes
Four things you get immediately:
- Autocomplete everywhere.
$data->and your IDE lists every field with its type. No more grepping callers to find out what's in the array. - 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.
- One place where parsing happens.
Carbon::parse()and(int)casts live in the constructor, not scattered across the codebase. The rest of your app trusts the types. - Tests get shorter.
new CreateBookingData(clientId: 1, startAt: ..., ...)is two lines. Building an array fixture with the right shape is ten.
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.
When you outgrow the hand-rolled version
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.
That's where spatie/laravel-data earns its install:
namespace App\DataObjects;
use Spatie\LaravelData\Data;
use Carbon\CarbonImmutable;
final class CreateBookingData extends Data
{
public function __construct(
public int $clientId,
public CarbonImmutable $startAt,
public int $durationMinutes,
public ?string $notes = null,
) {}
public static function rules(): array
{
return [
'clientId' => ['required', 'integer', 'exists:clients,id'],
'startAt' => ['required', 'date', 'after:now'],
'durationMinutes' => ['required', 'integer', 'min:15', 'max:240'],
'notes' => ['nullable', 'string', 'max:500'],
];
}
}
Now the same class works in every direction:
// From a form request
$data = CreateBookingData::from($request);
// From a model
$data = CreateBookingData::from($booking);
// Back to JSON for an API
return $data->toJson();
// In a queued job, fully typed and serialisable
dispatch(new SendBookingConfirmation($data));
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.
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.
What I actually do
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 ->toJson() on the DTO, I install spatie. Not before.
The point isn't which version you pick. The point is that array $data in a service signature is a lie, and the cost of telling the truth is ten minutes of typing.