DEV Community

Daniel Mabadeje
Daniel Mabadeje

Posted on

What a Failing Test Suite Taught Me About a Silent Laravel Security Gap

This is more like a heads-up article and a little mortification that I didn't realize this quickly…
Just a little… 😂

I recently spent an evening working through a batch of failing tests in a Laravel API. The failures looked unrelated on the surface. One endpoint was throwing a cryptic cURL error, another was silently accepting data it should have rejected, and a third was returning the wrong HTTP status code entirely. First of all, that was weird… It's an anti-pattern to test a third-party endpoint. So… why is my test calling an endpoint when I have set mocks???

Different endpoints, different symptoms. Same one-line root cause, every single time.

The bug, in its natural habitat
Here’s a simplified version of what I kept finding:

class VerificationController extends Controller
{
    public function verifyBvn(Request $request)
    {
        return $this->verificationService->verify($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing here looks wrong. It compiles. It runs. In the happy path, where the client sends well-formed data, it works exactly as expected.

The problem only shows up when someone sends bad data, a BVN that’s 3 characters instead of 11, an amount that’s negative, or a required field that’s just missing. Because nothing in this controller ever validates the input, that bad data doesn’t stop here. It flows straight into the service layer:

public function verify(Request $request)
{
    return $this->bvnProvider
        ->startVerification($request->bvn); // whatever the client sent, unchecked
}
Enter fullscreen mode Exit fullscreen mode

And straight out to wherever that service layer talks to next. In my case, a real external API call, which then failed with a Guzzle exception that had nothing obviously to do with “the BVN was too short.” A validation problem disguised itself as an infrastructure problem, several layers downstream from where it should have been caught.

The fix looks almost too small.

class VerifyBvnRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'bvn' => ['required', 'digits:11'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode
class VerificationController extends Controller
{
    public function verifyBvn(VerifyBvnRequest $request)
    {
        return $this->verificationService->verify($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

That’s it. One class, one changed type-hint. No new logic in the controller, no new logic in the service. And yet this one change is the entire fix.

Why this isn’t just cosmetic and what Laravel actually does
It’s easy to see Request vs. SomeCustomRequest as a style preference... maybe even a lazy shortcut when you're prototyping and don't want to bother writing rules yet. But in Laravel, the type hint on a controller method parameter isn't just for documentation sake. It's an instruction to the service container about what to construct and how.

Jeffrey Way put it best on one of his Laracasts years ago—"The code in your controller method literally never executes unless the request passes validation first."

I am probably paraphrasing it, but yeah 😂.

When Laravel sees a parameter type-hinted as a class extending FormRequest, it doesn't just hand your controller the raw request. It:

Instantiates your FormRequest class through the container.
Calls authorize() . If it returns false, the request is rejected with a 403 before your controller ever runs.
Runs the input against rules().
If validation fails, it throws a ValidationException immediately. Laravel's exception handler turns that into a 422 JSON response automatically. Your controller method body never executes.
This happens because FormRequest implements Illuminate\Contracts\Validation\ValidatesWhenResolved, and Laravel's container checks for that interface as part of resolving the object—validation is wired into object construction itself, not into something you have to remember to call inside your method.

Type-hint a bare Illuminate\Http\Request instead, and none of that machinery exists. Laravel just hands you the request as-is. There's no gate. Whatever arrived over the wire is what your code operates on, unfiltered, all the way down the call stack.

Why I’m calling it a security gate, not just a bug
It’s tempting to file this under “validation bug” and move on. I’d push back on that framing a little.

A missing FormRequest doesn't just mean “bad UX” (a confusing 500 instead of a clean 422). It means the boundary between untrusted input and your business logic doesn't exist for that endpoint. Every layer downstream—services, external API calls, database writes—is implicitly trusting that validation already happened. When it didn't, that trust is misplaced, and the failure mode is whatever the least forgiving layer in the chain happens to do with garbage input.

In a low-stakes case, that’s a bad error message. In a codebase moving money, verifying identity, or writing to a ledger, “the input was never actually checked” is a much bigger sentence than it sounds.

How I’d catch this faster next time
The good news: this is very greppable.

grep -rn '(Request \$request)' app/Http/Controllers modules #if you use modules
Enter fullscreen mode Exit fullscreen mode

Every match is worth five seconds of attention. Some will be legitimately fine … a GET endpoint with nothing to validate. But on any POST/PUT/PATCH method, a bare Request type-hint is a flag worth chasing down, not a style nit to skip past.

The tests didn’t tell me why things were broken, but they told me where. But the pattern, once I saw it in one place, was exactly the same in four or five others across a codebase I hadn’t fully mapped yet. That’s usually a good sign you’ve found something systemic rather than a one-off typo.

Oh, and the mock issue from the top? Turned out the mock was working exactly as intended the whole time. 😂 I’d just misattributed a failure to the wrong test method a few debugging rounds earlier. Dont underestimate how much time you can lose staring at the right error message for the wrong test.😂

I’m done ranting, I think. 😁

You can connect with me on Twitter: https://twitter.com/mabadejedanphp

Top comments (0)