# Using Laravel validation outside of Controllers & Requests

When you look at the [Validator source code in Laravel](https://github.com/laravel/framework/blob/e3546de2a82133701dbd0b3dd874a41e2393288c/src/Illuminate/Validation/Validator.php#L488), you see that it either throws a `ValidationException` or returns an array of validated attributes:

```php
public function validate()
{
    throw_if($this->fails(), $this->exception, $this);

    return $this->validated();
}
```

And what is [ValidationException](https://github.com/laravel/framework/blob/e3546de2a82133701dbd0b3dd874a41e2393288c/src/Illuminate/Validation/ValidationException.php)? It's nothing else than an object extending a plain old PHP Exception:

```php
<?php

namespace Illuminate\Validation;

use Exception;
// ...

class ValidationException extends Exception
{
    // ...
```

All of the fancy "out-of-the-box" behavior of using Laravel validators in the context of requests, like:

* redirecting back to the previous page or returning 422 on API requests
    
* adding `errors` to session or returned JSON
    
* making sure that the exception is caught, and the app doesn't crash
    

comes from `App\Exceptions\Handler`.

Some of the behavior above is defined in the methods and properties of `ValidationException` class, but this class doesn't have magic abilities, and its methods won't call themselves. It's the exception handler that makes it "automagic".

Whenever you `validate` the request, the only thing that you potentially trigger is `ValidationException`. The rest of the job is done by Laravel via the exception handler.

## Can we throw ValidationException outside of controllers?

I don't see a reason why we can't just throw the `ValidationException` anywhere in the app. If you do it in the context of the web request, the Exception gets wrapped into a `Response`. In other contexts, like Console, if you don't want it to crash the app, you will need to wrap it in try/catch block. In the past, I threw a plan `Exception` when data failed validation outside of the Controller - I thought that `Validator` and `ValidationException` are somehow "reserved" for that purpose. They are not, and you can use both throughout the app if it makes for a task at hand.

## Where do I find it useful?

### Perform an action with validation from multiple controllers

I'm running an [online marketplace for home & office cleaning services](https://spicandspan.net). Our app has separate controller methods for downloading reports on a given `PayoutTransaction` (the money sent to cleaning companies) for `Managers` (admins) and `Providers` (app users). We have separate controllers since `Managers` and `Providers` have different [guards](https://laravel.com/docs/10.x/authentication#introduction). Both `Managers` and `Providers` can download the report only on completed (confirmed) payout transactions.

In this case, we have a method for rendering the report view directly on the `PayoutTransaction` model:

```php
public function getDetailsPDFDocument(): PDFDocument
{
    return PDF::loadHTML($this->getDetailsView());
}

public function getDetailsView(): View
{
    $this->validateDetailsPreview();

    return view('shared.documents.payout-transaction-details', [
        'transaction' => $this,
    ]);
}

protected function validateDetailsPreview(): void
{
    Validator::make([], [])->after(function ($validator) {
        if (!$this->hasStakeholder()) {
            $validator->errors()->add('stakeholder', "Payout transaction must have a stakeholder to show details.");
        }

        if (!$this->isCompleted()) {
            $validator->errors()->add('status', "Details are available only for completed transactions.");
        }
    })->validate();
}
```

In controllers, we can then render the view, and perform the validation in one go:

```php
class DownloadPayoutTransactionDetailsController extends Controller
{
    public function __construct()
    {
        $this->middleware(['auth:manager']);
    }

    public function __invoke(Request $request, PayoutTransaction $transaction)
    {
        return $transaction->getDetailsPDFDocument()
            ->download($transaction->detailsFileName());
    }
}
```

If we want more customized validation (or authorization) in only one of the controllers, we can add it too.

### Perform an action from Controllers and Jobs running on schedule

Our app charges customers for invoices in 2 ways:

* via a manual action of the Manager (i.e. someone clicking on the "charge customer" button in the admin panel)
    
* on schedule, 2 hours after the invoice was finalized
    

In both cases, certain conditions must be met, for example:

* there can be no pending payments already associated with the invoice
    
* there can be no open disputes related to the invoice
    

In the scheduled job, we're not dealing with the user input, so technically using a validator doesn't seem like a good choice here. But if we don't, we have to duplicate the logic in both the Controller validation and in the QueryBuilder conditions (i.e. filtering out invoices by those already meeting validation conditions). That logic shouldn't be getting out of sync. And if it does, since we're dealing with customers' money, I prefer to err on the side of "over-validating", i.e. not charging some customers that should be charged. With that in mind, have the following method on the `CustomerInvoice` model:

```php
public function chargeCustomer($gateway): PaymentTransaction
{
    Validator::make([], [])->after(function ($validator) use ($gateway) {
        if ($this->status != 'final') {
            $validator->errors()->add(
                'invalid_status',
                "Customer cannot be charged for customer invoices with {$this->status} status."
            );
        }

        // a couple other conditions

        if ($this->hasOpenDisputes()) {
            $validator->errors()->add(
                'has_open_disputes',
                "Cannot charge customer for customer invoices with open disptues."
            );
        }
    })->validate();

    $paymentTransaction = $this->customer->paymentTransactions()->create([
        'gateway' => $gateway,
        'amount' => $this->paymentUnallocatedAmount(),
        'currency' => $this->currency,
        'status' => 'new',
        'external_description' => $this->defaultPaymentDescription(),
    ]);

    try {
        $externalPaymentIntent = app('transaction_gateway')->connection($gateway)->charge($paymentTransaction);
    } catch (ValidationException $e) {
        if ($e->validator->errors()->has('missing_payment_mandate')) {
            CustomerInvoiceChargeFailed::dispatch($this);
        }

        throw $e;
    }

    $paymentTransaction->updateWithExternal($externalPaymentIntent);

    $paymentTransaction->linkToPayable($this, $externalPaymentIntent->amount());

    return $paymentTransaction;
}
```

Then, I am using that method in both the controller responsible for the manual charge:

```php
class CustomerInvoiceChargeController extends Controller
{
    public function __construct()
    {
        $this->middleware(['auth:manager']);
    }

    public function __invoke(Request $request, CustomerInvoice $invoice)
    {
        $invoice->chargeCustomer($invoice->defaultPaymentGateway()->getConnectionName());

        return back()->with('success', "Successfully charged the customer.");
    }
}
```

and in the scheduled job:

```php
class AutochargeEligibleCustomerInvoices implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public function handle()
    {
        CustomerInvoice::whereNotIn('status', ['draft', 'void'])
            ->whereNotIn('payment_settlement_status', ['settled', 'not_applicable', 'error'])
            ->whereDoesntHave('appointments', function ($q) {
                $q->whereHas('disputes', fn ($q) => $q->where('status', 'open'));
            })
            ->whereNotNull('finalized_at')
            ->where('created_at', '<=', now()->subHours(48))
            ->where('finalized_at', '<', now())
            ->where('finalized_at', '>=', now()->subHours(2))
            // a few other conditions
            ->get()
            ->filter(fn ($ci) => f_gt($ci->paymentUnallocatedAmount(), 0))
            ->each(function (CustomerInvoice $customerInvoice) {
                try {
                    $customerInvoice->chargeCustomer($customerInvoice->defaultPaymentGateway()->getConnectionName());
                } catch (ValidationException $e) {
                    //
                } catch (Exception $e) {
                    app('sentry')->captureException($e);
                }
            });
    }
}
```

As you can see, in the Controller, we're simply calling the `chargeCustomer` method. In the scheduled job, we have a few filter on top of that - it's on purpose, we want the charges happening in the background to have stricter controls than those triggered with an explicit action of the admin.

### Validating data integrity across the app

I also like to use Validation to avoid situations models are orphaned. For example, if each (payment) Transaction must belong to a certain customer, I cannot delete a customer before deleting all of their payment transactions (e.g. payments couldn't potentially be manually added to the system by mistake). In our app, we use soft-deleted functionality, we can't use [ON DELETE referential action](https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html). The main use here is saving myself from myself, when I need to run some cleanup/admin task on production data from tinker.

Instead of putting the validation logic in controller's `destroy` method, I put it in the `Observer::deleting` method, like below:

```php
public function deleting(Customer $customer)
{
    Validator::make([], [])->after(function ($validator) use ($customer) {
        if ($customer->transactions->isNotEmpty()) {
            $validator->errors()->add('has_transactions', 'Customer with transactions may not be deleted.');
        }
    })->validate();
}
```

There's a "magical" aspect to it that I don't like - controller's delete method looks as if I forgot about the validation. But it's a consistent pattern we use throughout the app, so it doesn't bother me too much. If if does, we can always wrap it in a method like `validateDataIntegrityOnDelete` on the `Customer` model, and call that method from the controller and observer.

## How do we test this?

Normally, when you think of testing validation, you probably think of calling `assertSessionHasErrors` a method like:

```php

class ChargeCustomerTest extends TestCase
{
    public function test_something()
    {
        $this->get('/foo')
            ->assertSessionHasErrors(['some_error', 'another_error']);
    }
}
```

But in the use cases described above, we don't have a `Response` to be asserting things on. So here's a little helper method I came up with:

```php
public function throwsValidationException($callback, $errorKey, $message = null)
{
    if (is_null($message)) {
        $message = "ValidationException with $errorKey key was not thrown.";
    }

    try {
        $result = $callback();

        $this->fail($message);

        return $result;
    } catch (ValidationException $e) {
        $this->assertContains($errorKey, array_keys($e->errors()));
    }
}
```

I place that method in `Tests\TestCase`, as all feature tests in our app extend from that class.

Then, this method is used like below:

```php
/**
 * @test
 */
public function draft_invoice_cannot_charge_the_customer()
{
    foreach (array_keys($this->gateways) as $connectionKey) {
        $invoice = CustomerInvoice::factory()
            ->draft()
            ->for($this->createCustomerWithValidPaymentMandate($connectionKey))
            ->paymentSettleable(12.34)
            ->create();

        $this->throwsValidationException(fn () => $invoice->chargeCustomer($connectionKey), 'invalid_status');
    }
}
```

This saves us from testing the same validation logic in multiple places, since both the controller, and schedule job depend on `chargeCustomer` method to do the validation.

## Summary

`ValidationException` is just another Exception class you can use in your app. It's not reserved for controller methods or even web requests context. You can throw it manually from different places in your app, and save yourself from duplicating the validation logic. Some use cases that I encountered in my day-to-day work were:

* Using the same validation rules in multiple controllers
    
* Checking the same validation conditions in Controllers and Jobs running on schedule
    
* Using validation to preserve data integrity in the app
