Programar un buscador con PHP y MySQL: FULLTEXT, PDO y búsqueda en modo booleano (2026)

Artículo original de noviembre de 2004. Actualizado en mayo de 2026 por David Carrero. El original usaba las funciones mysql_connect() y mysql_query() eliminadas en PHP 7.0 (diciembre de 2015). Esta versión usa PHP 8 con PDO y cubre el modo booleano de FULLTEXT, que el artículo de 2004 no contemplaba.

Por qué el LIKE se queda corto para búsquedas

Implementar un buscador con LIKE '%palabra%' funciona cuando la base de datos tiene cientos de registros. En cuanto la tabla crece a miles o decenas de miles de filas, los problemas aparecen solos: la consulta hace un escaneo completo de la tabla (full table scan), no usa ningún índice, y el tiempo de respuesta sube de forma proporcional al número de filas. Además, LIKE no sabe nada de relevancia: un artículo que menciona la palabra buscada veinte veces devuelve el mismo resultado que uno que la menciona una vez.

MySQL lleva desde la versión 3.23 (año 2001) con soporte para índices FULLTEXT, diseñados exactamente para este problema. A partir de MySQL 5.6, InnoDB también los admite, así que ya no hay excusa para usar LIKE en búsquedas de texto.

Diseño de la tabla de ejemplo

Vamos a construir un buscador sobre una tabla de artículos técnicos. La estructura es deliberadamente sencilla para centrarnos en la parte de búsqueda:

CREATE TABLE articulos (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    titulo      VARCHAR(255) NOT NULL,
    desarrollo  TEXT NOT NULL,
    fecha       DATE NOT NULL,
    visible     TINYINT(1) NOT NULL DEFAULT 1,
    INDEX (fecha)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

El tipo de texto correcto para los campos que van a entrar en el índice FULLTEXT es VARCHAR, CHAR o TEXT (con sus variantes MEDIUMTEXT, LONGTEXT). Tipos numéricos o de fecha no pueden indexarse en FULLTEXT.

Crear el índice FULLTEXT

El índice se crea sobre los campos en los que quieres buscar. Si la búsqueda tiene que cubrir tanto el título como el cuerpo del artículo, ambos van en el mismo índice:

-- Añadir el índice a una tabla existente
ALTER TABLE articulos ADD FULLTEXT idx_busqueda (titulo, desarrollo);

-- O incluirlo directamente en el CREATE TABLE
FULLTEXT KEY idx_busqueda (titulo, desarrollo)

MySQL construye el índice de forma asíncrona en InnoDB. En tablas grandes puede tardar unos segundos o minutos la primera vez, pero solo ocurre al crearlo o al actualizarlo masivamente.

La consulta MATCH...AGAINST en modo natural

La sintaxis básica de búsqueda FULLTEXT es:

SELECT id, titulo
FROM articulos
WHERE visible = 1
  AND MATCH(titulo, desarrollo) AGAINST ('PHP MySQL');

Esta consulta usa el modo natural de lenguaje (IN NATURAL LANGUAGE MODE, que es el predeterminado). MySQL tokeniza la frase, elimina las palabras vacías (stop words como «de», «la», «el») y busca los documentos que contienen los términos restantes. Internamente calcula una puntuación de relevancia para cada fila: cuantas más veces aparece el término y en cuántos documentos aparece, mayor es la puntuación.

Ordenar por relevancia

Para mostrar los resultados más relevantes primero, hay que llamar a MATCH...AGAINST dos veces: una en el WHERE y otra en el SELECT para obtener la puntuación:

SELECT
    id,
    titulo,
    MATCH(titulo, desarrollo) AGAINST ('PHP MySQL') AS puntuacion
FROM articulos
WHERE visible = 1
  AND MATCH(titulo, desarrollo) AGAINST ('PHP MySQL')
ORDER BY puntuacion DESC
LIMIT 50;

La puntuación es un número decimal: cuanto mayor, más relevante es el resultado. MySQL optimiza esto y no ejecuta el índice dos veces; la segunda llamada a MATCH...AGAINST idéntica en el mismo SELECT se resuelve con caché interna.

FULLTEXT en modo booleano

El modo booleano (IN BOOLEAN MODE) da control explícito sobre cada término. Se comporta más como un motor de búsqueda avanzado:

-- Resultados que contengan "PHP" y "MySQL" (ambos obligatorios)
... AGAINST ('+PHP +MySQL' IN BOOLEAN MODE)

-- Resultados con "PHP", excluyendo los que tienen "ASP"
... AGAINST ('+PHP -ASP' IN BOOLEAN MODE)

-- Búsqueda de prefijo: todo lo que empiece por "ges"
... AGAINST ('ges*' IN BOOLEAN MODE)

-- Frase exacta entre comillas dobles
... AGAINST ('"prepared statements"' IN BOOLEAN MODE)

-- Término opcional (suma puntuación si aparece, pero no es obligatorio)
... AGAINST ('+PHP MySQL ~laravel' IN BOOLEAN MODE)

La tabla de operadores es:

Operador

Significado

+

El término debe aparecer en la fila

-

El término no debe aparecer

*

Comodín de prefijo (al final del término)

"..."

Frase exacta

~

Baja la puntuación si el término aparece

> <

Sube o baja la puntuación relativa del término

El problema de las palabras cortas y las stop words

Hay dos comportamientos de FULLTEXT que sorprenden la primera vez:

  • Longitud mínima de palabra. Por defecto MySQL ignora las palabras de menos de 4 caracteres (ft_min_word_len=4 para MyISAM, innodb_ft_min_token_size=3 para InnoDB). Buscar «PHP» (3 letras) con FULLTEXT puede devolver cero resultados si no se ajusta este parámetro. El modo booleano tiene su propio umbral (ft_boolean_syntax).
  • Stop words. MySQL tiene una lista de palabras comunes en inglés que ignora siempre: «the», «this», «that», etc. En una web en español conviene revisar esta lista en INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD.

La solución práctica para palabras cortas es usar un fallback con LIKE cuando el término es demasiado corto, tal como hacía el artículo original de 2004. Solo que ahora con PDO y sin interpolación de variables en la SQL:

Implementación completa en PHP 8 con PDO

<?php
// buscar.php — PHP 8, PDO, preparado contra inyección SQL

declare(strict_types=1);

function conectar(): PDO
{
    $dsn = 'mysql:host=localhost;dbname=mi_base;charset=utf8mb4';
    return new PDO($dsn, 'usuario', 'contraseña', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
}

function buscar(PDO $pdo, string $q, int $limite = 50): array
{
    $q = trim($q);
    if ($q === '') {
        return [];
    }

    // Palabras de 3 o menos caracteres (PHP, CSS, etc.) causan problemas con FULLTEXT
    // — usar LIKE como fallback
    $palabras = preg_split('/s+/', $q);
    $palabraCorta = array_filter($palabras, fn(string $p): bool => mb_strlen($p) <= 3);

    if (count($palabraCorta) > 0) {
        // Búsqueda LIKE: más lenta pero cubre acrónimos cortos
        $termino = '%' . str_replace(['%', '_'], ['%', '_'], $q) . '%';
        $stmt = $pdo->prepare(
            "SELECT id, titulo,
                    MATCH(titulo, desarrollo) AGAINST (:q) AS puntuacion
             FROM articulos
             WHERE visible = 1
               AND (titulo LIKE :like OR desarrollo LIKE :like)
             ORDER BY puntuacion DESC, fecha DESC
             LIMIT :limite"
        );
        $stmt->bindValue(':q',      $q,       PDO::PARAM_STR);
        $stmt->bindValue(':like',   $termino, PDO::PARAM_STR);
        $stmt->bindValue(':limite', $limite,  PDO::PARAM_INT);
    } else {
        // Búsqueda FULLTEXT: rápida, con relevancia
        $stmt = $pdo->prepare(
            "SELECT id, titulo,
                    MATCH(titulo, desarrollo) AGAINST (:q) AS puntuacion
             FROM articulos
             WHERE visible = 1
               AND MATCH(titulo, desarrollo) AGAINST (:q)
             ORDER BY puntuacion DESC
             LIMIT :limite"
        );
        $stmt->bindValue(':q',      $q,      PDO::PARAM_STR);
        $stmt->bindValue(':limite', $limite, PDO::PARAM_INT);
    }

    $stmt->execute();
    return $stmt->fetchAll();
}

// ??? Controlador ?????????????????????????????????????????????????????????????
$termino    = trim($_GET['q'] ?? '');
$resultados = [];
$error      = '';

if ($termino !== '') {
    try {
        $pdo        = conectar();
        $resultados = buscar($pdo, $termino);
    } catch (PDOException $e) {
        $error = 'Error en la búsqueda. Inténtalo de nuevo.';
        error_log($e->getMessage()); // log al servidor, nunca al usuario
    }
}

// ??? Vista ????????????????????????????????????????????????????????????????????
?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Buscador</title>
</head>
<body>

<form method="get" action="buscar.php">
  <input type="search" name="q"
         value="<?= htmlspecialchars($termino, ENT_QUOTES) ?>"
         placeholder="Buscar artículos..."
         maxlength="200">
  <button type="submit">Buscar</button>
</form>

<?php if ($error): ?>
  <p style="color:red"><?= htmlspecialchars($error) ?></p>
<?php elseif ($termino !== '' && $resultados === []): ?>
  <p>No se encontraron resultados para <strong><?= htmlspecialchars($termino) ?></strong>.</p>
<?php else: ?>
  <ul>
    <?php foreach ($resultados as $fila): ?>
      <li>
        <a href="articulo.php?id=<?= (int) $fila['id'] ?>">
          <?= htmlspecialchars($fila['titulo']) ?>
        </a>
        <small>(relevancia: <?= number_format((float)$fila['puntuacion'], 4) ?>)</small>
      </li>
    <?php endforeach; ?>
  </ul>
<?php endif; ?>

</body>
</html>

Puntos clave respecto al código de 2004

El ejemplo original de este artículo interpolaba la variable $busqueda directamente en la cadena SQL, lo que abre la puerta a inyección SQL. En el código actualizado, todas las variables van como parámetros vinculados con bindValue(): la consulta y los valores viajan por separado hacia MySQL y el motor nunca interpreta los valores como parte de la instrucción SQL.

Otros cambios respecto al original:

  • mysql_* eliminado en PHP 7.0. Si tu servidor corre PHP 7 o superior y el código usa mysql_connect(), obtendrás un error fatal. Usa PDO o MySQLi.
  • El índice FULLTEXT ahora funciona con InnoDB. En 2004, solo MyISAM lo soportaba. InnoDB añadió soporte en MySQL 5.6 (2013). La mayoría de las instalaciones modernas usan InnoDB por sus transacciones y claves foráneas.
  • La puntuación se puede incluir en el SELECT. El artículo original calculaba la puntuación pero no la mostraba. En el código actualizado se devuelve para poder ordenar por relevancia.

Optimizaciones para producción

Algunas mejoras habituales en buscadores de producción:

  • Caché de resultados. Si la base de datos es grande y las búsquedas son repetitivas, guardar los resultados en Redis o Memcached durante unos minutos reduce drásticamente la carga.
  • Índice externo para textos muy largos. Si los artículos son documentos de decenas de miles de palabras, herramientas como Elasticsearch o Meilisearch ofrecen más opciones de configuración lingüística (lematización, sinónimos, etc.) que MySQL FULLTEXT.
  • Ajuste de innodb_ft_min_token_size. Si necesitas indexar palabras de 2 o 3 caracteres (siglas, códigos), cambia este parámetro en my.cnf y reconstruye el índice.
  • Paginación. En vez de LIMIT 50 fijo, implementa paginación con LIMIT :por_pagina OFFSET :offset para resultados navegables.

Para la capa de base de datos, consulta Cómo interactuar con MySQL usando PHP 8 y PDO y Tutorial básico de MySQL.

Imagen: Pexels / Ivan Babydov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP