Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Validate It at the Door

  • laravel
  • typescript
  • validation
  • architecture

Most bugs I chase in older codebases aren't really logic bugs. They're data bugs. Something weird got in at the front door, and three layers later a perfectly reasonable function falls over trying to deal with it.

The fix is almost never in that function. It's back at the door.

The guessing game

If you don't validate input where it comes in, every piece of code downstream has to wonder about it. Is quantity a number or a string? Can email be null? Is status one of the four values we expect, or whatever someone typed into Postman?

So you get defensive checks scattered everywhere. isset here, ?? 0 there, a trim() somebody added after an incident. None of them agree with each other, and none of them are complete.

That isn't defensive programming. That's the same validation written badly fifteen times.

Laravel already gave you the door

In Laravel, the request is the boundary. Validate there, then only work with what passed.

public function store(Request $request)
{
    $data = $request->validate([
        'email'    => ['required', 'email'],
        'quantity' => ['required', 'integer', 'min:1'],
        'status'   => ['required', 'in:pending,active,cancelled'],
    ]);
 
    Order::create($data);
}

The important part is $data. Not $request->all(). You pass along the validated array, and anything that wasn't in the rules never makes it to your model.

Once the rules start growing, move them into a Form Request. Same idea, just a better place for the rules to live.

TypeScript doesn't check anything at runtime

This is the one that gets people. A type annotation on an API response is a promise you made to yourself. The server never signed it.

const user = (await res.json()) as User; // trust me bro

That cast checks nothing. If the API sends id as a string, or leaves out email, TypeScript is perfectly happy, and you find out later in some component that's nowhere near the fetch call.

Parse it at the boundary instead. Zod is one common way to do it:

import { z } from "zod";
 
const User = z.object({
  id: z.number(),
  email: z.string().email(),
});
 
const result = User.safeParse(await res.json());
if (!result.success) {
  throw new Error("Unexpected user payload");
}
 
const user = result.data; // actually a User now

Now the type is earned instead of asserted.

Where the door is

It isn't just form posts. Anywhere data crosses into your code counts:

  • HTTP requests and webhooks
  • Responses from third-party APIs
  • Queue job payloads (especially ones that sat in the queue through a deploy)
  • CSV imports and anything uploaded by a human
  • Config and environment values

If it came from outside the function you're writing, and especially outside the app, check it once, right there.

Inside the walls, relax

The payoff is that the rest of the code gets simpler. Once data is validated at the edge, your services and models can just trust their inputs. Fewer null checks, fewer "just in case" branches, and errors that point at the actual problem instead of a symptom.

Reject bad data at the door, with a clear message. Then let everything inside be boring.

← All posts