Query scopes en Laravel 13: consultas reutilizables con Eloquent
El mismo where('status', 'published') copiado en diez controllers es el olor a código más común en aplicaciones Laravel. Los query scopes encapsulan esos filtros en el modelo para escribir Post::published()->recent(), y Laravel 13 suma la sintaxis #[Scope] a la clásica scopeNombre.
El problema: lógica de consulta duplicada en toda la app
El olor a código del where() repetido
Empieza con un filtro inocente: "solo posts publicados". Lo copias en el controller del listado, luego en el del detalle, después en un job que genera el sitemap y más tarde en un test. Cuando la regla cambia, por ejemplo a "publicado y no archivado", hay que encontrarlos y actualizarlos todos. El riesgo no es solo la repetición: una de las copias puede quedarse atrás y consultar datos que ya no debería mostrar.
Qué es un query scope y qué resuelve (DRY y expresividad)
Un query scope es un método del modelo Eloquent que encapsula un conjunto de constraints reutilizable. La consulta Post::published() sustituye a Post::where('status', 'published'), y la regla vive en un único sitio: el modelo. Es la aplicación directa del principio DRY a las consultas, y de paso hace que las queries se lean como frases.
Local scopes: la sintaxis clásica con prefijo scope
scopePublished: el scope más simple
Un local scope es un método con prefijo scope que recibe el query builder y lo devuelve:
Lee también
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published');
}Al invocarlo, Eloquent elimina el prefijo: Post::published() ejecuta exactamente ese where.
Encadenar scopes con otros métodos del query builder
Como el scope devuelve el query builder, se encadena con cualquier otro método: with() para eager loading, orderBy(), paginate()... Post::published()->with('author')->recent()->paginate(10) es una consulta completa y legible. Los scopes y la carga ansiosa se combinan sin fricción.
Dynamic scopes: scopes con parámetros
scopeByCategory($slug) y cómo pasar argumentos
Los dynamic scopes aceptan parámetros después de $query. El primer argumento es siempre la consulta; el resto, los que tú definas:
public function scopeByCategory(Builder $query, string $slug): Builder
{
return $query->whereHas('category', fn ($q) => $q->where('slug', $slug));
}Se invoca como Post::byCategory('laravel') y sirve para cualquier filtro parametrizado: por estado, por autor o por rango de fechas.
Valores por defecto y validación dentro del scope
Puedes dar un valor por defecto al parámetro o validarlo dentro del scope y devolver $query sin modificar cuando no aplica. Así, una llamada sin argumentos no rompe la cadena, y el scope decide si el filtro tiene sentido.
La sintaxis #[Scope] de Laravel 12/13
Scopes con atributos PHP en métodos del modelo
Desde Laravel 12 existe una alternativa basada en atributos PHP, vigente en Laravel 13, lanzado el 17 de marzo de 2026 como vimos en la guía de novedades. El atributo #[Scope] marca un método estático del modelo como scope:
use Illuminate\Database\Eloquent\Attributes\Scope;
#[Scope]
public static function published(Builder $query): Builder
{
return $query->where('status', 'published');
}La invocación es idéntica: Post::published(). La diferencia está en la definición, que pasa de método de instancia con prefijo a método estático con atributo.
Scopes como clases reutilizables con el atributo
El atributo también admite una clase dedicada que implementa __invoke. Eso permite compartir la misma lógica entre varios modelos:
#[Scope]
public static function active(): ActiveScope
{
return new ActiveScope;
}La clase ActiveScope encapsula el filtro active y puede aplicarse a User, Post o cualquier modelo que lo necesite.
Convivencia de ambas sintaxis en Laravel 13
En Laravel 13 las dos sintaxis funcionan a la vez: puedes migrar modelos poco a poco y mantener scopes clásicos donde ya funcionan. No hay prisa por reescribir, y mezclarlas dentro del mismo proyecto no causa conflictos.
Global scopes: constraints en todas las consultas
Registrar con booted() y addGlobalScope
Un global scope se aplica automáticamente a todas las consultas del modelo. Se registra en el método booted() con addGlobalScope, pasando una clase o un closure:
protected static function booted(): void
{
static::addGlobalScope('locale', fn (Builder $query) =>
$query->where('locale', app()->getLocale()));
}A partir de ahí, toda consulta del modelo incluye el filtro, sin que nadie tenga que acordarse de añadirlo.
El soft delete como ejemplo de global scope integrado
El trait SoftDeletes usa global scopes internamente: añade un where deleted_at is null a todas las consultas y lo elimina cuando consultas con withTrashed(). Es el ejemplo perfecto de por qué existen: una regla transversal que ningún desarrollador debería escribir a mano.
withoutGlobalScope y withoutGlobalScopes para excepciones puntuales
Para saltarte la regla en un caso concreto: withoutGlobalScope(NombreClase::class) quita uno, withoutGlobalScopes() los quita todos. El panel de administración, por ejemplo, puede necesitar ver registros que la aplicación pública oculta.
Buenas prácticas y errores comunes
La trampa del orWhere dentro de un scope
El error más peligroso: combinar orWhere dentro de un scope. El orWhere se evalúa sin el contexto del where anterior y puede romper el filtro, llegando incluso a exponer datos que el scope debía ocultar:
// PELIGROSO: el orWhere puede anular el filtro anterior
return $query->where('status', 'published')
->orWhere('featured', true);La solución es agrupar las condiciones con closures para que el OR quede dentro del grupo correcto.
Nombres, retornos del query builder y scopes cortos
Tres reglas sencillas: nombres en presente y descriptivos, published en lugar de recentPosts; devolver siempre el query builder, porque si te olvidas la cadena se rompe; y mantener cada scope corto, con una única responsabilidad. Si un scope necesita más de unas pocas líneas, probablemente escondes lógica que merece su propio método o clase.
Ejemplo real: scopes en el modelo Post de un blog Laravel 13
published, recent, featured y byCategory encadenados
En un blog como este, el modelo Post puede combinar todo lo anterior:
Post::published()->featured()->byCategory($slug)->recent()->paginate(10)Cada scope aporta un filtro, la cadena se lee sola y el controller no conoce ningún where.
Un global scope de idioma/tenant y su excepción en el admin
Si el blog es multilingüe, un global scope de locale evita que cualquier consulta olvide el idioma. El admin, que gestiona todos los idiomas a la vez, lo desactiva puntualmente: Post::withoutGlobalScope(LocaleScope::class).
El controller antes y después
Antes, el controller repetía filtros a mano:
$posts = Post::where('status', 'published')
->where('featured', true)
->whereHas('category', fn ($q) => $q->where('slug', $request->category))
->orderByDesc('published_at')
->paginate(10);Después, con scopes:
$posts = Post::published()->featured()
->byCategory($request->category)
->recent()->paginate(10);Menos líneas, menos duplicación y la regla de negocio vive en el modelo. Los datos limpios que llegan por Form Requests alimentan directamente estos filtros.
Testing de scopes
Probar el scope con un modelo de fábrica y assert
Los scopes se testean como cualquier consulta: con RefreshDatabase, una fábrica y una aserción sobre el resultado:
Post::factory()->create(['status' => 'published']);
Post::factory()->create(['status' => 'draft']);
$this->assertSame(1, Post::published()->count());Si prefieres comprobar el SQL generado, toSql() te muestra la consulta exacta y detectas regresiones en los filtros antes de que lleguen a producción.
Conclusión
Los query scopes convierten consultas repetidas en métodos expresivos del modelo: local scopes para filtros reutilizables, dynamic scopes para parámetros, global scopes para reglas transversales y el atributo #[Scope] como sintaxis moderna. Empieza por el scope published de tu modelo Post y deja que el resto de la app lo consuma. Sigue leyendo el blog para más guías de Laravel 13.


