DEV Community

Cover image for Laravel Validation after() - After What, Exactly?
Ivan Mykhavko
Ivan Mykhavko

Posted on

Laravel Validation after() - After What, Exactly?

If you've worked with Laravel's Form Requests, you've probably run into the after() method for custom validation. But it works slightly differently that it named.

Common Misconception

When you see a method called after(), you'd figure it runs after all the regular validation passes. That's what I assumed, too. Makes sense, doesn't it? But that's not how it works.

Real Example

Let me show you what I mean with a real example. Here's how you might set up validation rules for storing related articles in catalog:

public function rules(): array
{
    return [
        'article' => ['required', ...DetailArticleRule::rules()],
        'brand_id' => ['required', 'integer', 'min:1'],
        'related_article' => ['required', ...DetailArticleRule::rules(), 'different:article'],
    ];
}

public function after(): array
{
    return [
        function (Validator $validator): void {
            $brandId = $this->integer('brand_id');

            $existsMain = Product::query()
                ->where('article', $this->string('article')->toString())
                ->where('brand_id', $brandId)
                ->exists();

            if (!$existsMain) {
                $validator->errors()->add('article', __('validation.exists'));
            }
            // ... more checks
        },
    ];
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this makes sense. You do your usual validation, then use after() to tack on extra database checks.

The Problem

Ok, try sending "bad" request:

{
  "article": "P900RM11",
  "brand_id": "Hepu",
  "related_article": "P900RM11005"
}
Enter fullscreen mode Exit fullscreen mode

You get a nice error response about invalid brand_id (it's string, not int) and bad articles.
But take a look at your database logs:

select exists(select * from `products` where `article` = 'P900RM11005'...) 
select exists(select * from `products` where `article` = 'P900RM11'...)
Enter fullscreen mode Exit fullscreen mode

Wait a second, validation failed, but you're still hitting the database. Not cool.

And it gets messier. What if someone sends this?

{
  "article": "P900RM11",
  "brand_id": 5,
  "related_article": ["P900RM11005"]
}
Enter fullscreen mode Exit fullscreen mode

Now you get an "Array to string conversion" exception. The after() callback tries to process invalid types, falls over, and you end up with a 500 error instead of a helpful validation message.

What Laravel Docs Actually Say

From the off docs:

The after method accepts a closure or an array of callables which will be invoked after validation is complete.

The important bit is "after validation is complete" - not "after validation passes." That means after() runs even when validation fails. Yeah, the name is misleading.

The Quick Fix

So, if you're using after(), you need to guard yourself:

public function after(): array
{
    return [
        function (Validator $validator): void {
            if ($validator->errors()->isNotEmpty()) {
                return; // Stop if basic validation failed
            }
            // Now it's safe to do DB checks
        },
    ];
}
Enter fullscreen mode Exit fullscreen mode

This way, you avoid unnecessary queries and weird crashes.

The Better Solution

But here's the bigger point: database queries don't belong in HTTP validation. Validation should be fast and stateless. Its job is to check the shape of the data and basic rules-nothing that requires hitting the database.

Mixing in database calls here slows things down, couples your HTTP layer to your persistence layer, and makes testing a pain. Plus, you burn resources on requests that weren't valid from the start.

The better approach? Move this stuff into a service layer:

// In RelatedArticleService
public function createEdge(string $article, int $brandId, string $relatedArticle, int $relatedBrandId): void
{
    $this->validateEdge($article, $brandId, $relatedArticle, $relatedBrandId);

    $this->repository->insertEdge($article, $brandId, $relatedArticle, $relatedBrandId);

    // more business logic...
}

private function validateEdge(string $article, int $brandId, string $relatedArticle, int $relatedBrandId): void
{
    $existsMain = Product::query()
        ->where('article', $article)
        ->where('brand_id', $brandId)
        ->exists();

    if (!$existsMain) {
        throw ValidationException::withMessages([
            'article' => __('validation.exists', ['attribute' => 'article'])
        ]);
    }

     // other checks...
}
Enter fullscreen mode Exit fullscreen mode

Now, your Form Request just handles HTTP validation, and your service takes care of all the business logic and any domain-specific checks.

Final Thoughts

So, to sum it all up: Laravel's after() method can be useful, but keep these things in mind

  1. It runs no matter what - even if the main validation failed.
  2. If you want conditional logic, you'll need to check for errors yourself.
  3. Heavy operations (like database queries) belong in your service layer, not Form Requests.

That's really the best way to keep your code fast, clean, and easy to test.

Author's Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Notes from real-world Laravel.

Top comments (0)