Capítulo 8 de 9

Tutorial Laravel 13 #8: motor de búsqueda para el blog

verificado el 2 septiembre 2026 · 5 min

Respuesta rápida

Bastan un formulario en GET, una consulta where('title', 'like', "%$terme%") y una vista. Ojo: en SQLite, LIKE ignora las mayúsculas solo en ASCII. Buscar «demonstration» sin acento no encuentra «démonstration», mientras que MySQL y MariaDB sí lo logran.

Al final de este capítulo, tu blog tendrá una búsqueda que funciona, y sabrás exactamente qué encuentra y qué se le escapa.

Un motor de búsqueda se resume en tres piezas: un formulario que envía la consulta, un método que interroga la base de datos y una vista que muestra los resultados. Este capítulo los monta y después mide lo que esa búsqueda sabe hacer, y lo que no sabe hacer.

El formulario

Ya está en la plantilla escrita en el capítulo 5, en la parte superior de cada página:

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>

Cuentan tres detalles. El método es GET, no POST: la búsqueda no modifica nada, y la dirección resultante se puede compartir e indexar. El atributo value="{{ request('q') }}" conserva el término escrito después del envío. El <label> está oculto visualmente, pero los lectores de pantalla lo leen.

Nada de token CSRF en un formulario GET

Las versiones antiguas de este tutorial ponían {{ csrf_field() }} en este formulario. Es inútil y perjudicial: la protección CSRF solo afecta a las peticiones que modifican el estado del servidor, y el token acaba expuesto en la URL. De hecho, Laravel nunca comprueba el token en una petición GET.

La ruta

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

El método de búsqueda

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(),
    ]);
}

Hay cuatro puntos que merecen atención.

La validación. validate() rechaza una consulta de más de cien caracteres o de un tipo inesperado. Sin ella, un visitante puede enviar ?q[]=x y provocar un error de PHP al pasar un array donde se espera una cadena.

La agrupación de las condiciones. La función anónima que se pasa a where() mete las dos condiciones orWhere entre paréntesis en el SQL generado. Sin ella, la consulta se convierte en is_published = 1 AND title LIKE … OR content LIKE …, y la prioridad del OR haría aflorar borradores cuyo contenido coincide. Es un bug discreto, que solo se ve cuando hay un borrador en la base.

when(). La condición solo se aplica si el término no está vacío. Una búsqueda en vacío muestra entonces todos los artículos en lugar de una página vacía.

withQueryString(). Sin esa llamada, los enlaces de paginación pierden el parámetro q y la página 2 muestra todos los artículos en vez de los resultados.

La vista de resultados

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() da el número de resultados del conjunto de páginas, no solo el de la página mostrada.

Lo que esta búsqueda sabe hacer

El comportamiento de LIKE depende de la base de datos, no de Laravel. La diferencia es clara y conviene conocerla antes de poner un sitio en producción. El 2 de septiembre de 2026 se consultaron doce artículos titulados «Article de démonstration» en los dos motores.

Término buscado SQLite 3.46.1 MariaDB 11.8.9 (utf8mb4_unicode_ci)
démonstration lo encuentra lo encuentra
demonstration (sin acento) no encuentra nada lo encuentra
DÉMONSTRATION (mayúsculas con acento) no encuentra nada lo encuentra
ARTICLE (mayúsculas sin acento) lo encuentra lo encuentra

Dicho de otro modo: en SQLite, LIKE ignora las mayúsculas únicamente en los caracteres ASCII. En cuanto entra en juego un acento, la comparación vuelve a ser estricta y una búsqueda sin acento no devuelve nada. Es una limitación de la implementación por defecto de SQLite, documentada por el propio proyecto.

MariaDB y MySQL no tienen ese problema con una collation utf8mb4_unicode_ci o utf8mb4_general_ci: la comparación ignora a la vez las mayúsculas y los acentos.

Las soluciones alternativas

Si te quedas en SQLite y el asunto te importa, hay tres opciones, de la más sencilla a la más sólida.

  • Guardar una copia sin acentos. Añade una columna title_search rellenada con Str::ascii($title) al guardar el registro, y busca ahí después de aplicar la misma transformación al término escrito.
  • Pasar a MySQL o PostgreSQL en producción. Es de todos modos la elección habitual en cuanto un sitio recibe tráfico.
  • Usar un motor de búsqueda dedicado. Laravel Scout conecta la aplicación con Meilisearch, Typesense o Algolia. Gestionan los acentos, las erratas y la relevancia, algo que LIKE no hará nunca.

Las limitaciones asumidas

Esta búsqueda es deliberadamente simple, y conviene saber lo que no hace.

  • No tolera ninguna errata: «larvel» no encontrará «Laravel».
  • No ordena por relevancia. Un artículo cuyo título coincide exactamente sale al mismo nivel que otro que menciona el término una sola vez en su contenido, porque la ordenación va por fecha.
  • Busca en el HTML en bruto del contenido. Buscar strong devolverá todos los artículos que lleven texto en negrita.
  • El LIKE '%terme%' no puede usar ningún índice. Con unos pocos miles de artículos es indoloro, más allá, la consulta se ralentiza en proporción al tamaño de la tabla.

Para un blog personal es suficiente. A partir de ahí, Scout es el siguiente escalón.

El último capítulo añade la paginación y los artículos relacionados.

Errores frecuentes

Un token CSRF en un formulario GET Inútil y perjudicial: la protección CSRF solo apunta a las peticiones que modifican el estado, y el token acaba expuesto en la URL. Laravel nunca lo comprueba en un GET.
Borradores entre los resultados where('is_published', 1)->where(...)->orWhere(...) genera un SQL en el que el OR gana al AND. Agrupa los dos orWhere en una función anónima pasada a where().
La página 2 de los resultados pierde el término buscado Añade withQueryString() después de paginate(), o los enlaces de paginación olvidarán el parámetro q.
Una búsqueda sin acento no devuelve nada Es comportamiento de SQLite, no de Laravel. Guarda una copia sin acentos con Str::ascii(), o pásate a MySQL, o usa Laravel Scout.
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.