Using Laravel validation outside of Controllers & Requests

Using Laravel validation outside of Controllers & Requests

When you look at the Validator source code in Laravel, you see that it either throws a ValidationException or returns an array of validated attributes:

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

    return $this->validated();
}

And what is ValidationException? It's nothing else than an object extending a plain old PHP Exception:

<?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. 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. 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:

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:

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:

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:

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:

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. 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:

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:


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:

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:

/**
 * @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