Module 3.3 — Similarity Search: Cosine, Euclidean & Dot Product

The Problem We're Solving

You have 10,000 document chunks — all embedded as vectors — sitting in a vector database.

A user asks a question. You embed that question too.

Now you have one question vector and 10,000 document vectors.

How do you find which documents are most similar to the question?

You need a way to measure the distance — or similarity — between vectors.

There are three main ways to do this:

1. Cosine Similarity
2. Euclidean Distance
3. Dot Product

Each one measures "closeness" slightly differently. By the end of this module you'll know exactly what each one does and when to use which.


First — A Simple Setup

Let's use tiny 2D vectors so we can actually visualize what's happening.

Imagine these four pieces of content embedded as 2D vectors:

"Cats are great pets"          → A = (3, 4)
"Dogs make wonderful pets"     → B = (2, 5)
"Pizza is delicious food"      → C = (8, 1)
"I love my cat"                → D = (4, 3)

User question:
"Tell me about pet cats"       → Q = (3, 3)

Visualized:

        ↑
    5   │   • B (2,5)
        │
    4   │   • A (3,4)
        │       • D (4,3)
    3   │   • Q (3,3) ← question
        │
    1   │                       • C (8,1)
        │
    0   └──────────────────────────────→
        0   1   2   3   4   5   6   7   8

Just by looking — A, B, D are close to Q. C is far away.

That makes sense — A, B, D are about pets. C is about pizza.

Now let's see how each metric measures this.


Method 1 — Euclidean Distance

What it is

Euclidean distance is the straight line distance between two points.

You've used this your whole life without knowing it had a name. It's literally just — draw a straight line between two points, how long is it?

        ↑
    4   │   • A (3,4)
        │    \
    3   │   • Q (3,3)
        │
        └──────────────→
        0   1   2   3

The straight line from Q to A — that's the Euclidean distance.

The Formula

Don't worry about memorizing this — just understand what it does:

Distance = √[(x2-x1)² + (y2-y1)²]

Q = (3,3), A = (3,4):
Distance = √[(3-3)² + (4-3)²]
         = √[0 + 1]
         = √1
         = 1.0

Q = (3,3), C = (8,1):
Distance = √[(8-3)² + (1-3)²]
         = √[25 + 4]
         = √29
         = 5.38

What the Numbers Mean

Euclidean Distance:
→ Small number = close = SIMILAR
→ Large number = far = DIFFERENT

Q to A = 1.0    ← very close, very similar ✓
Q to B = 2.24   ← fairly close, similar ✓
Q to D = 1.0    ← very close, very similar ✓
Q to C = 5.38   ← far away, not similar ✗

Correctly finds that A and D are most similar to Q.

The Problem With Euclidean Distance

Euclidean distance cares about both direction AND magnitude (how long the vector is).

This can be a problem.

Imagine:
Short document (2 sentences about cats):
→ Vector: (1, 2)  ← small numbers, short doc

Long document (50 sentences about cats):
→ Vector: (8, 16) ← large numbers, long doc

Same topic. But very different vector lengths.
Euclidean distance would say they're far apart — even 
though they're about the exact same thing.

For text — longer documents naturally produce larger vectors. Euclidean distance penalizes this unfairly.

This is why we usually prefer Cosine Similarity for text.


Method 2 — Cosine Similarity

The Core Idea

Cosine Similarity ignores vector length completely. It only cares about direction.

Think of it like this — two arrows pointing in the same direction are similar, even if one arrow is short and one is long.

Same direction, different lengths:
→ →→→→→

These are "similar" in cosine terms — same direction.

Completely different directions:
→
↑
These are "not similar" — different directions.

The Angle Between Vectors

Cosine Similarity measures the angle between two vectors.

Small angle (pointing roughly same direction):
→ HIGH similarity (close to 1.0)

Large angle (pointing different directions):
→ LOW similarity (close to 0)

Opposite directions (180° angle):
→ NEGATIVE similarity (close to -1.0)

Visualized:

        ↑
        │    ↗ Document about cats (3,4)
        │  ↗  
        │↗ Question about cats (3,3)
        └──────────────→

Small angle between them → HIGH cosine similarity

        ↑
        │↗ Question about cats (3,3)
        │
        │
        └──────────────→ Pizza document (8,1)

Large angle → LOW cosine similarity

What the Numbers Mean

Cosine Similarity range: -1.0 to 1.0

1.0   = identical direction = very similar
0.7   = small angle = fairly similar  
0.3   = medium angle = somewhat similar
0.0   = 90° angle = not related at all
-1.0  = opposite direction = opposite meaning

For our example:

Q and A (both about cats/pets):
→ Cosine similarity ≈ 0.99  ← almost identical direction ✓

Q and C (pets vs pizza):
→ Cosine similarity ≈ 0.49  ← very different direction ✗

Why Cosine Similarity is Best for Text

Short document: "I love cats" → (1, 2)
Long document:  50 sentences about cats → (8, 16)

Both point in the SAME direction — just different lengths.

(1,2) and (8,16) have the SAME angle from origin.
→ Cosine similarity = 1.0 (perfect match)
→ Euclidean distance = large (would say they're different)

Cosine Similarity correctly identifies them as the same topic.

This is why cosine similarity is the default choice for text embeddings in RAG systems.


Method 3 — Dot Product

What it is

Dot Product is the simplest calculation of the three.

You multiply each pair of matching numbers and add them all up.

Vector A = [3, 4]
Vector B = [2, 5]

Dot Product = (3×2) + (4×5)
            = 6 + 20
            = 26

That's it. No square roots. No angles. Just multiply and add.

Dot Product vs Cosine Similarity

Here's the relationship between them:

Dot Product = Cosine Similarity × (length of A) × (length of B)

So Dot Product is basically Cosine Similarity — but it also considers how long the vectors are.

This means:

If two vectors point in the same direction:
→ Both have high Dot Product AND high Cosine Similarity

But if one vector is much longer:
→ Dot Product becomes much larger
→ Cosine Similarity stays the same

When to Use Dot Product

Dot Product is best when vectors are normalized — meaning they all have the same length (length = 1).

Normalized vectors: all have length 1.0
→ Dot Product = Cosine Similarity (they become the same thing)
→ Much faster to compute (no square roots needed)

Many modern embedding models output normalized vectors by default. When this is the case — Dot Product is preferred because:

→ Same result as Cosine Similarity
→ Faster computation
→ Vector databases can optimize it better

OpenAI's text-embedding-3-small outputs normalized vectors. So when using OpenAI embeddings — Dot Product works just as well as Cosine Similarity, and is often faster.


The Three Methods — Side by Side

┌─────────────────┬──────────────────┬────────────────────┐
│                 │                  │                    │
│  EUCLIDEAN      │  COSINE          │  DOT PRODUCT       │
│  DISTANCE       │  SIMILARITY      │                    │
│                 │                  │                    │
├─────────────────┼──────────────────┼────────────────────┤
│                 │                  │                    │
│ Straight line   │ Angle between    │ Multiply + add     │
│ between points  │ two vectors      │ matching numbers   │
│                 │                  │                    │
├─────────────────┼──────────────────┼────────────────────┤
│                 │                  │                    │
│ Small = similar │ Close to 1 =     │ Higher = more      │
│ Large = diff    │ similar          │ similar            │
│                 │ Close to 0 =     │                    │
│                 │ different        │                    │
│                 │                  │                    │
├─────────────────┼──────────────────┼────────────────────┤
│                 │                  │                    │
│ Affected by     │ Ignores vector   │ Affected by        │
│ vector length   │ length           │ vector length      │
│                 │                  │                    │
├─────────────────┼──────────────────┼────────────────────┤
│                 │                  │                    │
│ Good for:       │ Good for:        │ Good for:          │
│ Physical/       │ Text embeddings  │ Normalized         │
│ geographic data │ (most common)    │ vectors (fastest)  │
│                 │                  │                    │
└─────────────────┴──────────────────┴────────────────────┘

In Code — How This Looks

Here's how you calculate cosine similarity between two embeddings in JavaScript:


    // Calculate cosine similarity between two vectors
    function cosineSimilarity(vectorA, vectorB) {
   
    // Step 1: Dot product (multiply matching numbers, add them up)
    const dotProduct = vectorA.reduce(
        (sum, val, i) => sum + val * vectorB[i], 0
    );
   
    // Step 2: Length of each vector
    const magnitudeA = Math.sqrt(
        vectorA.reduce((sum, val) => sum + val * val, 0)
    );
    const magnitudeB = Math.sqrt(
        vectorB.reduce((sum, val) => sum + val * val, 0)
    );
   
    // Step 3: Cosine similarity = dot product / (length A × length B)
    return dotProduct / (magnitudeA * magnitudeB);
    }

    // Example usage
    const catEmbedding  = [0.9, 0.8, 0.1, 0.05]; // "cats are great pets"
    const dogEmbedding  = [0.8, 0.9, 0.1, 0.06]; // "dogs make wonderful pets"
    const pizzaEmbedding = [0.1, 0.2, 0.9, 0.80]; // "pizza is delicious"

    const catVsDog   = cosineSimilarity(catEmbedding, dogEmbedding);
    const catVsPizza = cosineSimilarity(catEmbedding, pizzaEmbedding);

    console.log("Cat vs Dog similarity:  ", catVsDog);   // → 0.97 (very similar)
    console.log("Cat vs Pizza similarity:", catVsPizza);  // → 0.29 (very different)

In practice — your vector database does this calculation for you automatically. You don't calculate it manually. But knowing what's happening underneath makes you a better developer.


How Vector Search Actually Works

Here's the full flow of what happens when a user asks a question in a RAG system:

Step 1 — User asks a question
"What are the side effects of aspirin?"

Step 2 — Embed the question
Question → [0.23, 0.87, 0.41, ...] (1536 numbers)

Step 3 — Compare against all stored embeddings
Vector DB calculates similarity between question 
vector and every stored document vector

Step 4 — Rank by similarity score
Doc 1: "Aspirin risks and warnings"      → 0.94 ← very similar
Doc 2: "Common medication side effects"  → 0.87 ← similar
Doc 3: "History of aspirin"             → 0.71 ← somewhat similar
Doc 4: "Pizza recipe"                   → 0.12 ← not similar
Doc 5: "Car maintenance guide"          → 0.08 ← not similar

Step 5 — Return top K results (e.g. top 3)
Returns Doc 1, Doc 2, Doc 3

Step 6 — Feed to LLM with question
LLM generates answer based on these relevant chunks

This is Top-K search — return the K most similar results. We'll see this in detail in Phase 4.


Which One Should You Use?

Simple decision guide:

Are you using OpenAI embeddings?
→ YES → Use Cosine Similarity or Dot Product
         (both work equally well, dot product is faster)

Are your vectors normalized (length = 1)?
→ YES → Use Dot Product (faster, same result as cosine)
→ NO  → Use Cosine Similarity (safer, handles different lengths)

Are you working with geographic or physical data?
→ YES → Use Euclidean Distance

Are you working with text for RAG?
→ Almost always → Cosine Similarity ✓

For everything we build in this course — RAG, semantic search, document retrieval — Cosine Similarity is the default choice.


A Real Life Analogy — Finding Similar Songs

Think of Spotify's "similar songs" feature.

Every song has an embedding — capturing tempo, energy, mood, genre, instruments, vocals.

Song embeddings (simplified):

"Bohemian Rhapsody" → [0.9, 0.3, 0.8, 0.7]
                       (rock, complex, dramatic, energetic)

"Stairway to Heaven" → [0.8, 0.4, 0.7, 0.6]
                        (rock, complex, dramatic, medium energy)

"Baby Shark"         → [0.1, 0.9, 0.1, 0.2]
                        (pop, simple, happy, low energy)

Cosine similarity:

Bohemian Rhapsody vs Stairway to Heaven → 0.97 (very similar direction)
Bohemian Rhapsody vs Baby Shark         → 0.21 (very different direction)

Spotify correctly recommends Stairway to Heaven after Bohemian Rhapsody. Not Baby Shark.

This is cosine similarity in action — at scale, across millions of songs.


3-Line Summary

  1. Euclidean Distance measures straight-line distance between points — small number means similar — but it's affected by vector length, making it less ideal for text of different sizes.
  2. Cosine Similarity measures the angle between two vectors — close to 1.0 means very similar — it ignores vector length so a short and long document about the same topic correctly score as similar.
  3. Dot Product is the fastest calculation and equals Cosine Similarity when vectors are normalized — use it with OpenAI embeddings since they output normalized vectors by default.

Module 3.3 — Complete ✅

Coming up — Module 3.4 — Practical: Convert Words to Vectors and Compare Them

This is the hands-on module of Phase 3. We write real code — generate actual embeddings using the OpenAI API, compare them using cosine similarity, and SEE the numbers that prove similar words have similar vectors. You'll finally feel embeddings in a tangible way.

Module 3.2 — Vectors, Vector Space & Dimensions

Start With Something You Already Know

Remember Module 3.1 — we said an embedding is a list of numbers:

"cat" → [0.9, 0.8, 0.1, 0.05]
"dog" → [0.8, 0.9, 0.1, 0.06]

But what does this list of numbers actually mean geometrically?

This module answers that — by building up from something very simple.


Start With 1 Dimension

Imagine a straight line. Just a number line.

←──────────────────────────────→
-10    -5     0     5     10

Every point on this line can be described by one number.

Point A = 3
Point B = 7
Point C = -2

This is a 1-dimensional space. One number = one dimension = one axis.

Distance between A and B = 7 - 3 = 4 units apart.

Simple. You've known this since school.


Move to 2 Dimensions

Now add a second axis — vertical.

        ↑
    5   │         • C (2, 4)
        │
    2   │   • A (1, 2)
        │                    • B (5, 1)
    0   └──────────────────────────→
        0    1    2    3    4    5

Now every point needs two numbers to describe it:

Point A = (1, 2)   → 1 on horizontal, 2 on vertical
Point B = (5, 1)   → 5 on horizontal, 1 on vertical
Point C = (2, 4)   → 2 on horizontal, 4 on vertical

This is 2-dimensional space.

A and C are close together. B is far from both.

You can see this visually on the graph.


Move to 3 Dimensions

Add one more axis — depth.

Now every point needs THREE numbers:

Point A = (1, 2, 3)
Point B = (5, 1, 0)
Point C = (2, 4, 2)

This is 3-dimensional space. Like the real physical world we live in — length, width, height.

You can still kind of visualize this — imagine a 3D room with coordinates.


Now — What is a Vector?

A vector is simply an arrow from the origin (0,0) to a point.

2D example:

        ↑
    4   │
        │         ↗ vector for "cat" = (3, 4)
    2   │      ↗
        │   ↗
    0   └──────────────→
        0    1    2    3

The vector is the arrow. Its direction and length encode information.

Two vectors that point in similar directions → similar meaning. Two vectors pointing in very different directions → different meaning.

A vector and an embedding are the same thing. An embedding IS a vector — a list of numbers representing a direction in space.

When people say "vector" and "embedding" in AI — they mean the same thing.

Embedding = Vector = List of numbers = Point in space

Scaling Up — 1,536 Dimensions

Here's where people's brains freeze.

"I understand 2D and 3D — but 1,536 dimensions?? That's impossible to visualize."

You're right — you cannot visualize it. Nobody can.

But here's the key insight:

    The math works exactly the same way — whether it's 2 dimensions or 1,536 dimensions.

The rules don't change. Points that are close together are similar. Points far apart are different. Calculations work the same.

We just can't draw a picture of it.

Think of it like this — you understand that a city has a population number. You can't "see" a population — it's abstract. But you can still compare cities:

Mumbai population:  20,000,000
Delhi population:   32,000,000
My hometown:           50,000

Delhi > Mumbai > My hometown

You compared abstract numbers without needing to visualize them.

Same with 1,536-dimensional vectors — you compare them mathematically even though you can't see them.


What Do the Dimensions Actually Represent?

This is a great question — and the honest answer is:

Nobody fully knows.

The dimensions are learned automatically during training. No human decides "dimension 1 = animal-ness" or "dimension 2 = size."

The model figures out on its own what each dimension should capture — based on billions of examples.

But researchers have found some interesting patterns. In simpler models you can sometimes see:

Dimension 1 might roughly capture:
→ Is this a living thing? (high = yes, low = no)

"cat"   → 0.9  (yes, living)
"dog"   → 0.8  (yes, living)
"car"   → 0.1  (no, not living)
"rock"  → 0.05 (no, not living)

Dimension 2 might roughly capture:
→ Is this an animal vs human-made?

"cat"   → 0.8  (yes, natural animal)
"dog"   → 0.9  (yes, natural animal)
"car"   → 0.9  (yes, human-made)
"pizza" → 0.7  (yes, human-made)

In reality, 1,536 dimensions capture incredibly subtle, complex patterns of meaning — far beyond what humans could label.


Vector Space — The Full Picture

Vector space is just the name for the entire space where all vectors live.

1D vector space = a line
2D vector space = a flat plane (like a map)
3D vector space = a 3D room (like the real world)
1536D vector space = impossible to visualize but mathematically valid

When you embed 10,000 documents — you place 10,000 points into this vector space.

Vector Space (shown in simplified 2D):

                    ↑ technical
                    │
  • Python docs     │  • JavaScript docs
  • coding tutorial │  • React guide
                    │  • Node.js intro
────────────────────┼──────────────────────→ programming
                    │           specific
  • cooking recipe  │
  • pizza guide     │  • travel blog
  • food history    │  • hotel review
                    ↓ non-technical

Documents about similar topics cluster together in the space.

When a user asks a question — that question also gets placed as a point in the same space. The closest document points are the most relevant results.


Why This is Called "Semantic Space"

Because similar meanings end up close together — this vector space is also called Semantic Space or Embedding Space.

"Semantic" just means "related to meaning."

Semantic Space clusters:

Animals cluster:
• cat, dog, fish, bird, wolf → all near each other

Vehicles cluster:
• car, truck, bus, bike, train → all near each other

Food cluster:
• pizza, burger, pasta, rice → all near each other

The clusters emerge automatically from training.
Nobody created them manually.

A Real Life Analogy — The City Map

Think of embedding space like a city map.

Every business is a point on the map. Businesses of the same type naturally cluster in areas:

City Map:

[Hospital District]     [Tech District]
 • Hospital A            • Google HQ
 • Clinic B              • Startup X
 • Medical Lab           • Dev Agency

[Restaurant Row]        [University Area]
 • Italian place         • Main University
 • Pizza shop            • Library
 • Sushi bar             • Student cafe

You want to find "a place that treats injuries."

You don't search all businesses. You go to the Hospital District — because that's where similar businesses cluster.

Vector search works the same way. Your question goes to a "location" in embedding space. The nearest neighbors are the most relevant results.


Dimensions and Information Capacity

Here's an important practical point — more dimensions = more nuance captured.

Low dimensions (e.g. 384):
→ Faster to compute
→ Cheaper to store
→ Less nuance in meaning
→ Good for simple use cases

High dimensions (e.g. 3,072):
→ Slower to compute
→ More expensive to store
→ More nuance in meaning
→ Better for complex use cases

For most applications — 1,536 dimensions (OpenAI's text-embedding-3-small) is the sweet spot:

Dimension options for text-embedding-3-small:
→ You can actually REQUEST fewer dimensions
→ 512  dimensions: fast, cheap, decent quality
→ 1536 dimensions: balanced (default)

For text-embedding-3-large:
→ Up to 3,072 dimensions: best quality, more expensive

Embeddings in the Same Space — Critical Rule

Here is a rule that will save you a lot of debugging pain later:

    You must always use the same embedding model for everything in one system.

When you embed documents → use model X When you embed user questions → use the SAME model X

✓ Correct:
Documents embedded with: text-embedding-3-small
Questions embedded with: text-embedding-3-small
→ They live in the same space → comparison works

✗ Wrong:
Documents embedded with: text-embedding-3-small
Questions embedded with: text-embedding-3-large
→ Different spaces → comparison gives garbage results

Different models create different spaces. Mixing them is like comparing GPS coordinates from two different planets — the numbers mean completely different things.


What "Similar" Actually Means Mathematically

We've been saying "close in space = similar meaning."

But how do you actually calculate closeness between two vectors?

That's what the next module (3.3) covers — Similarity Search and Distance Metrics.

For now just understand:

Two vectors:
A = [0.9, 0.8, 0.1, 0.05]   ("cat")
B = [0.8, 0.9, 0.1, 0.06]   ("dog")
C = [0.1, 0.2, 0.9, 0.80]   ("car")

A and B → numbers are very similar → close in space → similar meaning
A and C → numbers are very different → far in space → different meaning

There are specific mathematical formulas to calculate this closeness. We'll cover exactly those in Module 3.3.


Putting it All Together

Text
  ↓
Embedding Model
  ↓
Vector (list of 1536 numbers)
  ↓
Each number = one dimension
  ↓
The vector is a point in 1536-dimensional space
  ↓
Similar texts → similar vectors → close points in space
  ↓
Finding similar texts = finding close points
  ↓
This is called Vector Search / Semantic Search

3-Line Summary

  1. A vector is just a list of numbers — each number is one dimension — and together they describe a point in a multi-dimensional space where similar meanings live close together.
  2. Real embeddings have hundreds or thousands of dimensions — you can't visualize this, but the math works exactly the same as 2D or 3D space — close vectors mean similar meaning.
  3. All embeddings in one system must come from the same model — mixing models is like using two different maps for the same city — the coordinates won't match and your search will break.

Module 3.2 — Complete ✅

Coming up — Module 3.3 — Similarity Search: Cosine, Euclidean & Dot Product

You know what vectors are and how they live in space. Now we cover the three ways to measure how close two vectors are — cosine similarity, euclidean distance, and dot product. This is how your RAG system will actually find relevant documents. Simple explanations, real intuition, and you'll know exactly which one to use and when.

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.


Module 3.3 — Similarity Search: Cosine, Euclidean & Dot Product

The Problem We're Solving You have 10,000 document chunks — all embedded as vectors — sitting in a vector database. A user asks a questi...