PHP & Laravel — Zero to Hero Episode 20: Form Validation — Validating User Input the Laravel Way

What Are We Doing in This Post?

In Episode 19 we added basic validation to our controller with $request->validate(). But when validation fails, the user gets silently redirected back with no error messages shown. They have no idea what went wrong.

In this episode we cover Laravel's validation system completely — validation rules, displaying error messages in Blade, custom error messages, and Form Request classes that move all validation logic out of controllers into dedicated classes.


How Laravel Validation Works

When you call $request->validate() and validation fails, Laravel automatically redirects the user back to the previous page. It flashes two things to the session — the validation errors and the old input values.

In Blade you access errors through the $errors variable — a MessageBag object that is always available in every view, even when empty.

Old input is accessed through the old() helper — which we already used in Episode 19.


Available Validation Rules

Laravel has over 90 built-in validation rules. Here are the most commonly used ones:


    <?php

    $request->validate([
        'title'      => 'required',
        'title'      => 'required|min:3|max:255',
        'email'      => 'required|email',
        'email'      => 'required|email|unique:users,email',
        'password'   => 'required|min:8|confirmed',
        'age'        => 'required|integer|min:1|max:120',
        'price'      => 'required|numeric|min:0',
        'image'      => 'required|image|mimes:jpg,jpeg,png|max:2048',
        'status'     => 'required|in:draft,published,archived',
        'body'       => 'required|string|min:10',
        'slug'       => 'required|unique:posts,slug',
        'website'    => 'nullable|url',
        'birth_date' => 'nullable|date|before:today',
        'tags'       => 'nullable|array',
        'tags.*'     => 'string|max:50',
    ]);

required — field must be present and not empty.

min:n and max:n — for strings, minimum/maximum character count. For numbers, minimum/maximum value.

email — must be a valid email format.

unique:table,column — value must not already exist in that table column.

confirmed — field must have a matching field_confirmation input. Used for password confirmation fields.

integer, numeric, string — must be the specified type.

in:a,b,c — value must be one of the listed options.

image — must be an image file.

mimes:jpg,png — must be one of the specified file types.

max:2048 — for files, maximum size in kilobytes.

nullable — field is optional. If present, other rules apply. If absent, validation passes.

url — must be a valid URL.

date — must be a valid date.

before:date — must be a date before the specified date.

array — must be an array.

tags.* — the .* syntax validates each item inside the tags array.


Displaying Validation Errors in Blade

Update resources/views/posts/create.blade.php to show error messages:


    @extends('layouts.app')

    @section('title', 'Create Post — MyBlog')

    @section('content')

    <div style="background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:40px; max-width:600px; margin:0 auto;">

        <h1 style="font-size:24px; font-weight:800; margin-bottom:28px;">Create New Post</h1>

        @if($errors->any())
            <div style="background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:16px; margin-bottom:24px;">
                <p style="font-weight:700; color:#991b1b; margin-bottom:8px;">Please fix the following errors:</p>
                <ul style="padding-left:20px; color:#b91c1c;">
                    @foreach($errors->all() as $error)
                        <li style="margin-bottom:4px; font-size:14px;">{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif

        <form method="POST" action="{{ route('posts.store') }}">
            @csrf

            <div style="margin-bottom:20px;">
                <label style="display:block; font-size:14px; font-weight:600; margin-bottom:6px;">
                    Title <span style="color:#ef4444;">*</span>
                </label>
                <input
                    type="text"
                    name="title"
                    value="{{ old('title') }}"
                    style="width:100%; padding:10px 14px; border:1px solid {{ $errors->has('title') ? '#ef4444' : '#e2e8f0' }}; border-radius:8px; font-size:15px;"
                    required
                >
                @error('title')
                    <p style="color:#ef4444; font-size:13px; margin-top:4px;">{{ $message }}</p>
                @enderror
            </div>

            <div style="margin-bottom:20px;">
                <label style="display:block; font-size:14px; font-weight:600; margin-bottom:6px;">
                    Author <span style="color:#ef4444;">*</span>
                </label>
                <input
                    type="text"
                    name="author"
                    value="{{ old('author') }}"
                    style="width:100%; padding:10px 14px; border:1px solid {{ $errors->has('author') ? '#ef4444' : '#e2e8f0' }}; border-radius:8px; font-size:15px;"
                    required
                >
                @error('author')
                    <p style="color:#ef4444; font-size:13px; margin-top:4px;">{{ $message }}</p>
                @enderror
            </div>

            <div style="margin-bottom:20px;">
                <label style="display:block; font-size:14px; font-weight:600; margin-bottom:6px;">
                    Status <span style="color:#ef4444;">*</span>
                </label>
                <select name="status" style="width:100%; padding:10px 14px; border:1px solid {{ $errors->has('status') ? '#ef4444' : '#e2e8f0' }}; border-radius:8px; font-size:15px;">
                    <option value="draft" {{ old('status') == 'draft' ? 'selected' : '' }}>Draft</option>
                    <option value="published" {{ old('status') == 'published' ? 'selected' : '' }}>Published</option>
                </select>
                @error('status')
                    <p style="color:#ef4444; font-size:13px; margin-top:4px;">{{ $message }}</p>
                @enderror
            </div>

            <div style="margin-bottom:28px;">
                <label style="display:block; font-size:14px; font-weight:600; margin-bottom:6px;">
                    Body <span style="color:#ef4444;">*</span>
                </label>
                <textarea
                    name="body"
                    rows="8"
                    style="width:100%; padding:10px 14px; border:1px solid {{ $errors->has('body') ? '#ef4444' : '#e2e8f0' }}; border-radius:8px; font-size:15px; resize:vertical;"
                    required
                >{{ old('body') }}</textarea>
                @error('body')
                    <p style="color:#ef4444; font-size:13px; margin-top:4px;">{{ $message }}</p>
                @enderror
            </div>

            <button type="submit" style="background:#6366f1; color:#fff; padding:12px 28px; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; width:100%;">
                Publish Post
            </button>

        </form>

    </div>

    @endsection

Three error display techniques used here.

$errors->any() — returns true if there are any validation errors. Used to show the summary error box at the top.

$errors->all() — returns all error messages as a flat array. Used to list every error in the summary box.

@error('fieldname') ... @enderror — a Blade directive that renders its content only when that specific field has an error. Inside it, $message contains the error text for that field.

$errors->has('fieldname') — returns true if that field has an error. Used here to change the border color of inputs to red when they have errors.


Custom Error Messages

Laravel's default error messages are clear but sometimes you want custom wording for your specific context.


    <?php

    $request->validate(
        [
            'title' => 'required|min:3|max:255',
            'body'  => 'required|min:10',
            'email' => 'required|email|unique:users,email',
        ],
        [
            'title.required' => 'Please enter a title for your post.',
            'title.min'      => 'The title must be at least 3 characters long.',
            'title.max'      => 'The title cannot exceed 255 characters.',
            'body.required'  => 'The post body cannot be empty.',
            'body.min'       => 'Your post is too short. Write at least 10 characters.',
            'email.required' => 'We need your email address.',
            'email.email'    => 'That does not look like a valid email address.',
            'email.unique'   => 'An account with this email already exists.',
        ]
    );

The second argument to validate() is an array of custom messages. The key format is fieldname.rule — the field name, a dot, and the rule name.


Custom Attribute Names

By default Laravel uses the field name in error messages — "The title field is required." If your field name is something like first_name, Laravel shows "The first name field is required" — it converts underscores to spaces automatically. But for completely custom labels, pass a third argument:


    <?php

    $request->validate(
        [
            'fname' => 'required|min:2',
            'dob'   => 'required|date',
        ],
        [],
        [
            'fname' => 'first name',
            'dob'   => 'date of birth',
        ]
    );

Now errors read "The first name field is required" and "The date of birth field is required" instead of "The fname field is required."


Form Request Classes — The Professional Approach

Putting validation directly inside controller methods works but it clutters the controller with validation logic that does not belong there. Controllers should handle request flow — not validation rules.

Form Request classes are dedicated PHP classes that handle validation. The controller stays clean and focused.

Create a Form Request for storing posts:


    php artisan make:request StorePostRequest

Laravel creates app/Http/Requests/StorePostRequest.php. Update it:

    <?php

    namespace App\Http\Requests;

    use Illuminate\Foundation\Http\FormRequest;

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

        public function rules(): array
        {
            return [
                'title'  => 'required|min:3|max:255',
                'author' => 'required|min:2|max:100',
                'body'   => 'required|min:10',
                'status' => 'required|in:draft,published',
            ];
        }

        public function messages(): array
        {
            return [
                'title.required'  => 'Please enter a title for your post.',
                'title.min'       => 'The title must be at least 3 characters.',
                'author.required' => 'Please enter the author name.',
                'body.required'   => 'The post body cannot be empty.',
                'body.min'        => 'Your post is too short. Write at least 10 characters.',
                'status.in'       => 'Status must be either draft or published.',
            ];
        }
    }

authorize() — returns true if the current user is allowed to make this request. We return true for now — in Episode 21 when we add authentication, we will add real authorization checks here.

rules() — returns the validation rules array.

messages() — returns custom error messages. Completely optional.

Create a Form Request for updating posts:


    php artisan make:request UpdatePostRequest

Update app/Http/Requests/UpdatePostRequest.php:


    <?php

    namespace App\Http\Requests;

    use Illuminate\Foundation\Http\FormRequest;

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

        public function rules(): array
        {
            return [
                'title' => 'required|min:3|max:255',
                'body'  => 'required|min:10',
            ];
        }

        public function messages(): array
        {
            return [
                'title.required' => 'Please enter a title for your post.',
                'title.min'      => 'The title must be at least 3 characters.',
                'body.required'  => 'The post body cannot be empty.',
                'body.min'       => 'Your post must be at least 10 characters long.',
            ];
        }
    }

Now update PostController to use these Form Request classes:


    <?php

    namespace App\Http\Controllers;

    use Illuminate\Http\Request;
    use App\Models\Post;
    use App\Http\Requests\StorePostRequest;
    use App\Http\Requests\UpdatePostRequest;
    use Illuminate\Support\Str;

    class PostController extends Controller
    {
        public function index()
        {
            $posts = Post::latest()->get();
            return view('posts.index', ['posts' => $posts]);
        }

        public function create()
        {
            return view('posts.create');
        }

        public function store(StorePostRequest $request)
        {
            Post::create([
                'title'        => $request->input('title'),
                'slug'         => Str::slug($request->input('title')),
                'body'         => $request->input('body'),
                'status'       => $request->input('status'),
                'published_at' => $request->input('status') === 'published' ? now() : null,
            ]);

            return redirect()->route('posts.index')->with('success', 'Post created successfully!');
        }

        public function show($id)
        {
            $post = Post::findOrFail($id);
            $post->increment('views');
            return view('posts.show', ['post' => $post]);
        }

        public function edit($id)
        {
            $post = Post::findOrFail($id);
            return view('posts.edit', ['post' => $post]);
        }

        public function update(UpdatePostRequest $request, $id)
        {
            $post = Post::findOrFail($id);

            $post->update([
                'title' => $request->input('title'),
                'slug'  => Str::slug($request->input('title')),
                'body'  => $request->input('body'),
            ]);

            return redirect()->route('posts.show', $post->id)->with('success', 'Post updated successfully!');
        }

        public function destroy($id)
        {
            $post = Post::findOrFail($id);
            $post->delete();
            return redirect()->route('posts.index')->with('success', 'Post deleted successfully!');
        }
    }

The store method now type-hints StorePostRequest instead of Request. Laravel automatically resolves it, runs validation, and if validation fails, redirects back with errors — before the method body even executes. The controller method only runs when all validation passes.

This is clean separation of concerns — the controller handles flow, the Form Request handles validation.


Also Update the Edit View With Error Display

Update resources/views/posts/edit.blade.php:


    @extends('layouts.app')

    @section('title', 'Edit Post — MyBlog')

    @section('content')

    <div style="background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:40px; max-width:600px; margin:0 auto;">

        <h1 style="font-size:24px; font-weight:800; margin-bottom:28px;">Edit Post</h1>

        @if($errors->any())
            <div style="background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:16px; margin-bottom:24px;">
                <p style="font-weight:700; color:#991b1b; margin-bottom:8px;">Please fix the following errors:</p>
                <ul style="padding-left:20px; color:#b91c1c;">
                    @foreach($errors->all() as $error)
                        <li style="margin-bottom:4px; font-size:14px;">{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif

        <form method="POST" action="{{ route('posts.update', $post->id) }}">
            @csrf
            @method('PATCH')

            <div style="margin-bottom:20px;">
                <label style="display:block; font-size:14px; font-weight:600; margin-bottom:6px;">Title</label>
                <input
                    type="text"
                    name="title"
                    value="{{ old('title', $post->title) }}"
                    style="width:100%; padding:10px 14px; border:1px solid {{ $errors->has('title') ? '#ef4444' : '#e2e8f0' }}; border-radius:8px; font-size:15px;"
                    required
                >
                @error('title')
                    <p style="color:#ef4444; font-size:13px; margin-top:4px;">{{ $message }}</p>
                @enderror
            </div>

            <div style="margin-bottom:28px;">
                <label style="display:block; font-size:14px; font-weight:600; margin-bottom:6px;">Body</label>
                <textarea
                    name="body"
                    rows="8"
                    style="width:100%; padding:10px 14px; border:1px solid {{ $errors->has('body') ? '#ef4444' : '#e2e8f0' }}; border-radius:8px; font-size:15px; resize:vertical;"
                    required
                >{{ old('body', $post->body) }}</textarea>
                @error('body')
                    <p style="color:#ef4444; font-size:13px; margin-top:4px;">{{ $message }}</p>
                @enderror
            </div>

            <div style="display:flex; gap:12px;">
                <button type="submit" style="background:#6366f1; color:#fff; padding:12px 28px; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer;">
                    Update Post
                </button>
                <a href="{{ route('posts.show', $post->id) }}" style="background:#f1f5f9; color:#475569; padding:12px 28px; border-radius:8px; font-size:15px; font-weight:600; text-decoration:none;">
                    Cancel
                </a>
            </div>

        </form>

    </div>

    @endsection


Testing Validation

Visit http://127.0.0.1:8000/posts/create and submit the form completely empty.

You will see the red error summary at the top listing every failed rule. Each field that failed shows its specific error message below it in red. The field borders turn red. And all fields are repopulated with whatever the user typed — nothing is lost.

Try entering a title with just one character. You get "The title must be at least 3 characters." Try an invalid status value. Try leaving body empty.

This is production-quality form validation with one controller method staying completely clean.


What Did We Learn in This Post?

When validation fails, Laravel automatically redirects back with errors flashed to the session. $errors is always available in every Blade view.

$errors->any() checks if any errors exist. $errors->all() returns all messages. $errors->has('field') checks a specific field. @error('field') directive renders content only when that field has an error.

Laravel has over 90 built-in validation rules — required, min, max, email, unique, confirmed, in, nullable, integer, numeric, image, mimes, and many more.

Custom error messages are passed as the second argument to validate(). Custom attribute names are passed as the third argument.

Form Request classes separate validation completely from controllers. authorize() controls access. rules() defines validation rules. messages() provides custom error text. The controller type-hints the Form Request and validation runs automatically before the method body executes.


What is Coming in Episode 21?

Our blog is fully functional but anyone can create, edit, or delete any post. There is no concept of users, login, or ownership.

Episode 21 covers Laravel Authentication — user registration, login, logout, and protecting routes so only logged-in users can create and manage posts. We build it from scratch using Laravel's built-in Auth system so you understand every piece of it.

See you in the next one.


Next Episode: Authentication — User Registration, Login, and Route Protection

This is Episode 20 of the PHP and Laravel — Zero to Hero series.


No comments:

Post a Comment

PHP & Laravel — Zero to Hero Episode 20: Form Validation — Validating User Input the Laravel Way

What Are We Doing in This Post? In Episode 19 we added basic validation to our controller with $request->validate() . But when validati...