VS Vikesh Singh

Courses PHP & Laravel Lesson 12

PHP & LARAVEL · LESSON 12 OF 18

Form Validation in Laravel Done Right

· 5 min read

Lesson 7's `store()` method took form input and handed it straight to `Trade::create()`, with one glaring gap.

Lesson 7's store() method took form input and handed it straight to Trade::create(), with one glaring gap: nothing checked whether that input was actually valid first. This lesson fixes that gap properly, because validation is one of those things that looks optional right up until a user submits a form with a negative quantity, an empty required field, or a string where a number was expected, and something downstream breaks in a confusing way.

Validating Requests Directly

Laravel's validation system is built directly into the request object, and the simplest form looks like this:

namespace App\Http\Controllers;

use App\Models\Trade;
use Illuminate\Http\Request;

class TradeController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'symbol' => 'required|string|max:20',
            'quantity' => 'required|integer|min:1',
            'entry_price' => 'required|numeric|min:0.01',
            'exit_price' => 'nullable|numeric|gte:entry_price',
        ]);

        $trade = Trade::create($validated + ['user_id' => auth()->id()]);

        return redirect()->route('trades.show', $trade);
    }
}

$request->validate([...]) does one of two things: either every rule passes and it returns an array containing only the validated fields (notice it is $validated, not $request->all(), that gets passed to create() next; this matters, because it means even if someone sneaks extra fields into their form submission, only the fields you explicitly validated ever make it through), or any rule fails and Laravel automatically throws a ValidationException, which Laravel's exception handling catches for you, redirecting the user straight back to the previous page with their old input preserved and the specific error messages attached, with zero extra code written by you to make that happen.

The rule strings themselves ('required|integer|min:1') read almost like plain English, and that readability is deliberate: required means the field must be present and not empty, integer means it must be a whole number, min:1 means at least 1. gte:entry_price on the exit_price rule is a cross-field rule, meaning "greater than or equal to the value of the entry_price field in this same submission," which is exactly the kind of business rule that is easy to forget to check manually and trivial to express declaratively here.

Form Request Classes

Once a form has more than four or five rules, or the same validation needs to be reused for both creating and updating a record, cramming everything into the controller method gets unwieldy. Laravel's answer is a Form Request, a dedicated class whose entire job is validation and authorization for one specific action:

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'symbol' => 'required|string|max:20',
            'quantity' => 'required|integer|min:1',
            'entry_price' => 'required|numeric|min:0.01',
            'exit_price' => 'nullable|numeric|gte:entry_price',
        ];
    }

    public function messages(): array
    {
        return [
            'quantity.min' => 'You must trade at least 1 unit.',
        ];
    }
}
public function store(StoreTradeRequest $request)
{
    $trade = Trade::create($request->validated() + ['user_id' => auth()->id()]);

    return redirect()->route('trades.show', $trade);
}

Why Form Requests Help

Simply type-hinting StoreTradeRequest $request instead of the generic Request $request is enough; Laravel resolves the Form Request, runs its rules() against the incoming input automatically before your controller method body ever executes, and the method only runs at all if validation already passed. authorize() is a separate, equally important check: it answers "is this specific logged-in user even allowed to perform this action at all," independent of whether their input is well-formed. Returning false from authorize() gives the user a 403 Forbidden response before validation rules are even checked, which matters, because validating the shape of data someone was never allowed to submit in the first place is wasted and sometimes risky work.

Common Validation Mistakes

A mistake worth calling out directly because it defeats the entire point of this lesson: validating on the frontend with JavaScript and treating that as sufficient. Client-side validation is a UX nicety, giving users instant feedback without a round trip to the server, but it is trivially bypassable; anyone can disable JavaScript, use a tool to send a raw HTTP request directly, or simply edit the page before submitting. Server-side validation, exactly what $request->validate() or a Form Request provides, is the only validation that actually protects your database and your application's invariants. Frontend validation is a nice addition on top of server-side validation, never a replacement for it.

Validation Edge Cases

Another detail worth knowing specifically because it changes behavior based on context: Laravel inspects the incoming request to decide how to respond to a failed validation. A normal browser form submission gets redirected back with errors flashed to the session, exactly as described above, retrievable in a Blade view with @error('quantity'). A JSON API request (one sending an Accept: application/json header, which Laravel's API routes typically expect) instead gets a 422 Unprocessable Entity JSON response listing every failed field and its specific error messages, with no redirect at all, because a redirect makes no sense for an API client. This happens automatically, based entirely on the request's headers; you do not write different code for the two cases.

It is also worth knowing that Laravel ships a large library of validation rules beyond the handful shown here, covering things like unique:users,email (checking a value does not already exist in a given table and column, essential for signup forms), confirmed (requiring a matching _confirmation field, the standard way to implement a "repeat your password" input), and exists:trades,id (confirming a submitted ID actually corresponds to a real row). Reaching for the built-in rule, rather than writing an equivalent check by hand in the controller, keeps validation declarative, consistent, and in one place for a reader to scan.

Try it yourself: create a UpdateTradeRequest Form Request with the same rules as StoreTradeRequest but add a custom rule requiring exit_price to be required (not nullable) specifically on update, then wire it into a TradeController::update(UpdateTradeRequest $request, Trade $trade) method, and submit an invalid form on purpose to see Laravel's automatic redirect-with-errors behavior for yourself.