Chapter 8 of 9

Laravel 13 tutorial #8: build a search engine for your blog

verified on 2 September 2026 · 5 min

Quick answer

A GET form, a where('title', 'like', "%$terme%") query and a view are enough. Watch out: under SQLite, LIKE is case-insensitive on ASCII only. Searching for “demonstration” without the accent does not find “démonstration”, where MySQL and MariaDB do.

By the end of this chapter, your blog will have a working search, and you will know exactly what it finds and what it misses.

A search engine comes down to three pieces: a form that sends the query, a method that queries the database, a view that shows the results. This chapter puts them together, then measures what that search can do, and what it cannot.

The form

It is already in the layout written in chapter 5, at the top of every page:

resources/views/layouts/app.blade.php
<form action="{{ route('search') }}" method="GET" role="search" class="flex gap-2">
    <label for="q" class="sr-only">Rechercher</label>
    <input id="q" type="search" name="q" value="{{ request('q') }}"
           placeholder="Rechercher…"
           class="rounded border border-gray-300 px-3 py-1.5 text-sm">
    <button type="submit" class="rounded bg-gray-900 px-3 py-1.5 text-sm text-white">Go</button>
</form>

Three details matter. The method is GET, not POST: a search changes nothing, and the address you end up with stays shareable and indexable. The value="{{ request('q') }}" attribute keeps the term that was typed after the form is sent. The <label> is hidden visually but read out by screen readers.

No CSRF token on a GET form

Older versions of this tutorial put {{ csrf_field() }} in this form. That is useless and harmful: CSRF protection only concerns requests that change the state of the server, and the token ends up exposed in the URL. Laravel never checks the token on a GET request anyway.

The route

routes/web.php
Route::get('/recherche', [PostController::class, 'search'])->name('search');

The search method

app/Http/Controllers/PostController.php
use Illuminate\Http\Request;

public function search(Request $request): View
{
    $validated = $request->validate([
        'q' => ['nullable', 'string', 'max:100'],
    ]);

    $key = trim($validated['q'] ?? '');

    $posts = Post::published()
        ->when($key !== '', fn ($query) => $query->where(
            fn ($q) => $q->where('title', 'like', '%'.$key.'%')
                ->orWhere('content', 'like', '%'.$key.'%')
        ))
        ->with(['category', 'user'])
        ->latest('published_at')
        ->paginate(5)
        ->withQueryString();

    return view('search', [
        'key' => $key,
        'posts' => $posts,
        'categories' => Category::withCount('posts')->orderBy('name')->get(),
        'tags' => Tag::orderBy('name')->get(),
        'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
    ]);
}

Four points deserve attention.

Validation. validate() rejects a query longer than a hundred characters, or of an unexpected type. Without it, a visitor can send ?q[]=x and trigger a PHP error by passing an array where a string is expected.

Grouping the conditions. The closure passed to where() wraps the two orWhere conditions in brackets in the SQL that is produced. Without it, the query becomes is_published = 1 AND title LIKE … OR content LIKE …, and the precedence of OR would bring back drafts whose content matches. It is a quiet bug, the kind you only see once there is a draft in the database.

when(). The condition only applies when the term is not empty. An empty search therefore shows every post rather than an empty page.

withQueryString(). Without that call, the pagination links lose the q parameter and page 2 shows every post instead of the results.

The results view

resources/views/search.blade.php
@extends('layouts.app')

@section('title', 'Recherche : '.$key)

@section('content')
    <h1 class="mb-6 text-2xl font-bold">
        Résultats pour « {{ $key }} »
        <span class="text-base font-normal text-gray-500">({{ $posts->total() }})</span>
    </h1>
    @include('partials.posts-list')
@endsection

$posts->total() gives the number of results across all the pages, not just the one on screen.

What this search can do

The behaviour of LIKE depends on the database, not on Laravel. The difference is stark and worth knowing before you put a site online. Twelve posts titled “Article de démonstration” were queried on 2 September 2026 on both engines.

Search term SQLite 3.46.1 MariaDB 11.8.9 (utf8mb4_unicode_ci)
démonstration match match
demonstration (no accent) no match match
DÉMONSTRATION (accented capitals) no match match
ARTICLE (capitals, no accent) match match

In other words: under SQLite, LIKE is case-insensitive on ASCII characters only. As soon as an accent comes into play, the comparison turns strict again, and a search without the accent brings back nothing. That is a limit of the default SQLite implementation, documented by the project itself.

MariaDB and MySQL do not have this problem with a utf8mb4_unicode_ci or utf8mb4_general_ci collation: the comparison ignores both case and accents.

The workarounds

If you are staying on SQLite and this matters to you, there are three options, from the simplest to the most solid.

  • Store an unaccented copy. Add a title_search column filled with Str::ascii($title) when the record is saved, and search in that after applying the same transformation to the term that was typed.
  • Move to MySQL or PostgreSQL in production. That is the usual choice anyway as soon as a site gets traffic.
  • Use a dedicated search engine. Laravel Scout plugs the application into Meilisearch, Typesense or Algolia. They handle accents, typos and relevance, which LIKE never will.

The limits we accept

This search is deliberately simple, and you need to know what it does not do.

  • It tolerates no typos: “larvel” will not find “Laravel”.
  • It does not rank by relevance. A post whose title matches exactly comes out level with a post that mentions the term once in its content, since the sort is on the date.
  • It searches the raw HTML of the content. Searching for strong will bring back every post that contains bold text.
  • LIKE '%terme%' cannot use an index. On a few thousand posts it is painless, beyond that, the query slows down in proportion to the size of the table.

For a personal blog, that is enough. Past that point, Scout is the next step.

The last chapter adds pagination and related posts.

Common errors

A CSRF token in a GET form Useless and harmful: CSRF protection only targets requests that change state, and the token ends up exposed in the URL. Laravel never checks it on a GET.
Drafts showing up in the results where('is_published', 1)->where(...)->orWhere(...) produces SQL where the OR wins over the AND. Group the two orWhere calls in a closure passed to where().
Page 2 of the results loses the search term Add withQueryString() after paginate(), otherwise the pagination links forget the q parameter.
A search without the accent returns nothing That is SQLite behaviour, not Laravel. Store an unaccented copy with Str::ascii(), or move to MySQL, or use Laravel Scout.
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.