Laravel Scout con Driver Local: Búsqueda Full-Text sin Servicios
Búsqueda Full-Text en Laravel Sin Servicios Externos
Laravel Scout es el estándar de facto para implementar búsqueda en aplicaciones Laravel, pero muchos desarrolladores asumen que necesitan servicios externos como Elasticsearch o Meilisearch. La realidad es que desde hace años Scout incluye un driver de base de datos nativo que funciona perfectamente para aplicaciones medianas y, con la optimización correcta, incluso en proyectos grandes.
En este artículo exploraremos cómo sacar el máximo provecho del driver local de Scout, implementar búsqueda full-text eficiente y evitar la complejidad innecesaria de servicios externos.
¿Por Qué Scout con Driver Local?
Antes de profundizar en la implementación, veamos los casos de uso ideales:
Ventajas del driver local:
- Sin infraestructura adicional que mantener
- Búsqueda inmediata sin sincronización
- Indexación automática en tiempo real
- Costos operativos mínimos
- Debugging directo en tu base de datos
Limitaciones a considerar:
- Rendimiento limitado con millones de registros
- Sin fuzzy search o relevancia avanzada nativa
- Mayor carga en la base de datos
- Sin soporte para sugerencias (autocomplete) optimizadas
Para aplicaciones con menos de 500,000 registros o búsquedas no críticas, el driver local de Scout es más que suficiente.
Instalación y Configuración Básica
Comencemos instalando Scout si aún no lo tienes:
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
El archivo config/scout.php se publica automáticamente. Por defecto, Scout viene configurado con el driver null, que debemos cambiar a database:
// config/scout.php
return [
'driver' => env('SCOUT_DRIVER', 'database'),
'database' => [
'mode' => 'BOOLEAN MODE',
],
'chunk' => [
'searchable' => 100,
'unsearchable' => 500,
],
];
El parámetro mode en la configuración de base de datos define cómo MySQL busca: BOOLEAN MODE es más rápido y flexible que NATURAL LANGUAGE MODE.
Preparar tu Modelo Eloquent
El siguiente paso es implementar la interfaz Searchable en el modelo que deseas indexar. Imaginemos un modelo de Article:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Article extends Model
{
use Searchable;
protected $fillable = [
'title',
'slug',
'content',
'summary',
'author_id',
'published_at',
];
/**
* Obtén los índices que deben ser buscables.
*/
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'content' => $this->content,
'summary' => $this->summary,
'author_name' => $this->author->name ?? '',
];
}
public function author()
{
return $this->belongsTo(User::class);
}
/**
* Personaliza el nombre del índice (tabla donde se almacena la búsqueda).
*/
public function searchableAs(): string
{
return 'articles_index';
}
}
El método toSearchableArray() es crucial: define exactamente qué campos se indexarán y en qué orden aparecerán en los resultados. Aquí incluimos el nombre del autor desnormalizado para búsquedas más rápidas.
Crear la Tabla de Índices
Scout crea automáticamente una tabla de índices en tu base de datos. Para generarla, ejecuta:
php artisan scout:sync-index-settings
Si deseas ver la estructura creada, aquí está el esquema resultante:
// Migración típica (Scout la genera internamente)
Schema::create('articles_index', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('articles_id');
$table->fulltext(['title', 'slug', 'content', 'summary', 'author_name']);
$table->timestamps();
$table->foreign('articles_id')
->references('id')
->on('articles')
->onDelete('cascade');
});
Indexar Datos Existentes
Si tu aplicación ya tiene registros, necesitas indexarlos:
php artisan scout:import "App\Models\Article"
Para modelos con muchos registros, personaliza el tamaño de lotes:
php artisan scout:import "App\Models\Article" --chunk=500
Implementar Búsqueda Básica
Con Scout configurado, realizar búsquedas es trivial:
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\Request;
class SearchController extends Controller
{
public function search(Request $request)
{
$query = $request->input('q', '');
if (strlen($query) < 2) {
return response()->json(['results' => []]);
}
$results = Article::search($query)
->where('published_at', '!=', null)
->take(20)
->get();
return response()->json([
'results' => $results->map(fn($article) => [
'id' => $article->id,
'title' => $article->title,
'slug' => $article->slug,
'summary' => $article->summary,
]),
]);
}
}
Y la ruta correspondiente:
// routes/api.php
Route::get('/search', [SearchController::class, 'search'])->name('search');
Búsqueda Avanzada con Filtros
En aplicaciones reales, necesitarás búsquedas más sofisticadas. Aquí combinamos Scout con Query Builder para máxima flexibilidad:
public function advancedSearch(Request $request)
{
$query = $request->input('q', '');
$authorId = $request->input('author_id');
$fromDate = $request->input('from_date');
$toDate = $request->input('to_date');
$sortBy = $request->input('sort_by', 'relevance');
$results = Article::query()
->when($query, function ($q) use ($query) {
// Scout integrado con Query Builder
$q->whereFullText(['title', 'content', 'summary'], $query, ['mode' => 'boolean']);
})
->when($authorId, function ($q) use ($authorId) {
$q->where('author_id', $authorId);
})
->when($fromDate, function ($q) use ($fromDate) {
$q->whereDate('published_at', '>=', $fromDate);
})
->when($toDate, function ($q) use ($toDate) {
$q->whereDate('published_at', '<=', $toDate);
})
->when($sortBy === 'newest', function ($q) {
$q->orderByDesc('published_at');
})
->when($sortBy === 'oldest', function ($q) {
$q->orderBy('published_at');
})
->paginate(15);
return response()->json($results);
}
Este enfoque mantiene la flexibilidad de Eloquent mientras aprovecha la búsqueda full-text de MySQL.
Optimización de Índices para Rendimiento
La clave del rendimiento está en índices bien configurados. Aquí hay una migración optimizada:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('articles_index', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('articles_id')->unique();
// Índice full-text principal
$table->fulltext(
['title', 'content', 'summary'],
'idx_articles_fulltext'
);
// Índices para filtros comunes
$table->index('published_at', 'idx_articles_published');
$table->timestamps();
$table->foreign('articles_id')
->references('id')
->on('articles')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('articles_index');
}
};
Nota importante: Full-text en MySQL requiere un mínimo de 4 caracteres por defecto. Configúralo en my.cnf si necesitas búsquedas más cortas:
[mysqld]
innodb_ft_min_token_size=2
Sincronización Automática en Tiempo Real
Scout sincroniza automáticamente cambios en el índice, pero puedes personalizarlo:
<?php
namespace App\Models;
use Laravel\Scout\Searchable;
class Article extends Model
{
use Searchable;
protected static function booted(): void
{
// Sincronizar solo artículos publicados
static::addGlobalScope('published', function ($query) {
$query->where('published_at', '!=', null);
});
}
/**
* Determina si el modelo debe ser indexado.
*/
public function shouldBeSearchable(): bool
{
return $this->published_at !== null && $this->status === 'active';
}
/**
* Ejecutar lógica después de que el modelo sea indexado.
*/
protected static function booting(): void
{
static::updated(function ($article) {
// Reindexar cuando cambien campos críticos
if ($article->isDirty(['title', 'content'])) {
$article->searchable();
}
});
}
}
Implementar Autocomplete Eficiente
Para sugerencias mientras el usuario escribe, una tabla separada optimizada es superior:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ArticleSearchSuggestion extends Model
{
public $timestamps = false;
protected $fillable = ['phrase', 'count'];
// Obtén sugerencias ordenadas por popularidad
public static function suggestions($prefix, $limit = 10)
{
return self::where('phrase', 'like', $prefix . '%')
->orderByDesc('count')
->limit($limit)
->pluck('phrase');
}
}
Alimenta esta tabla con un job que analiza búsquedas reales:
<?php
namespace App\Jobs;
use App\Models\ArticleSearchSuggestion;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class UpdateSearchSuggestions implements ShouldQueue
{
use Queueable;
public function handle()
{
// Obtén las 100 búsquedas más frecuentes
$topSearches = \DB::table('search_logs')
->select('query', \DB::raw('COUNT(*) as count'))
->where('created_at', '>=', now()->subMonth())
->groupBy('query')
->orderByDesc('count')
->limit(100)
->get();
foreach ($topSearches as $search) {
ArticleSearchSuggestion::updateOrCreate(
['phrase' => $search->query],
['count' => $search->count]
);
}
}
}
Endpoint de Búsqueda para Frontend
Aquí está el controlador completo listo para producción:
<?php
namespace App\Http\Controllers\Api;
use App\Models\Article;
use App\Models\ArticleSearchSuggestion;
use Illuminate\Http\Request;
class SearchController
{
public function search(Request $request)
{
$query = $request->string('q')->trim();
if ($query->length() < 2) {
return response()->json(['results' => [], 'total' => 0]);
}
$results = Article::whereFullText(
['title', 'content', 'summary'],
(string) $query,
['mode' => 'boolean']
)
->where('published_at', '!=', null)
->select('id', 'title', 'slug', 'summary', 'published_at')
->limit(20)
->get();
// Registra la búsqueda para analítica
\DB::table('search_logs')->insert([
'query' => (string) $query,
'results_count' => $results->count(),
'created_at' => now(),
]);
return response()->json([
'results' => $results,
'total' => $results->count(),
]);
}
public function suggestions(Request $request)
{
$prefix = $request->string('q')->trim();
if ($prefix->length() < 2) {
return response()->json(['suggestions' => []]);
}
$suggestions = ArticleSearchSuggestion::suggestions(
(string) $prefix,
10
);
return response()->json(['suggestions' => $suggestions]);
}
}
Monitoreo y Debugging
Para asegurar que tus índices funcionan correctamente:
// En Tinker, verifica el estado de indexación
Article::count(); // Registros en la tabla
Article::search('*')->count(); // Registros indexados
// Busca específica
Article::search('laravel')->get();
// Con Telescope, monitorea las queries SQL generadas
Migración desde Servicios Externos
Si vienes de Elasticsearch o Meilisearch, la transición es simple:
# 1. Cambia el driver en .env
SCOUT_DRIVER=database
# 2. Limpia los índices viejos en el servicio
php artisan scout:flush "App\Models\Article"
# 3. Reindexar en la base de datos
php artisan scout:import "App\Models\Article"
# 4. Prueba exhaustivamente en staging
Puntos clave
- Scout con driver
databasees adecuado para aplicaciones medianas sin infraestructura adicional - El método
toSearchableArray()controla exactamente qué se indexa y cómo - Combina Scout con
whereFullText()para búsqueda avanzada y filtros simultáneos - Los índices full-text requieren configuración MySQL pero ofrecen rendimiento excelente
- Implementa tablas separadas para autocomplete basado en datos reales de búsqueda
- Monitorea la sincronización automática con
shouldBeSearchable()para casos especiales - La búsqueda local es mantenible, barata y suficientemente rápida para la mayoría de casos
- Registra búsquedas para analítica y mejora contin