Cómo implementar Global Scopes en Laravel
Un global scope en Laravel aplica la misma condición a todas las consultas de un modelo, sin que tengas que escribirla cada vez. Si te has encontrado repitiendo ->where('active', 1) en veinte sitios, esto es lo que buscas.
Probablemente ya usas uno sin saberlo: SoftDeletes es un global scope. Es lo que hace que User::all() no devuelva los registros con deleted_at, sin que tú añadas nada a la consulta.
Punto de partida: el query scope local
En el artículo anterior veíamos el scope local, que hay que invocar a mano:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Scope a query to only include active users.
*/
public function scopeActive(Builder $query): void
{
$query->where('active', 1);
}
}Se usa así, y si olvidas llamarlo, la condición no se aplica:
User::active()->get();Crear el global scope
Un global scope es una clase que implementa la interfaz Scope y tiene un único método, apply(). La convención actual es ponerla en app/Models/Scopes:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('active', 1);
}
}Puedes generarla con Artisan en vez de crearla a mano:
php artisan make:scope ActiveScopeRegistrarlo en el modelo: dos formas
Con el atributo ScopedBy (la forma recomendada)
Es la más limpia y la que conviene usar en código nuevo:
<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([ActiveScope::class])]
class User extends Model
{
//
}El atributo acepta un array, así que puedes aplicar varios scopes de una vez.
Con addGlobalScope en el método booted
La forma clásica, útil cuando el registro depende de alguna condición:
<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* The "booted" method of the model.
*/
protected static function booted(): void
{
static::addGlobalScope(new ActiveScope);
}
}Ojo con el nombre del método: es booted(), no boot(). Los dos existen, pero booted() es el hook que corre después de arrancar el modelo y no te obliga a acordarte de llamar a parent::boot(). Usar boot() sin ese parent::boot() rompe cosas de formas difíciles de rastrear.
Con cualquiera de las dos, ya no hace falta invocar nada:
User::get(); // solo devuelve los usuarios activosCómo quitar un global scope
Aquí está el detalle que más se equivoca, y que hacía que la versión anterior de este artículo no funcionara: el scope se quita pasando la clase, no un string.
User::withoutGlobalScope(ActiveScope::class)->get();La variante con string solo sirve si registraste el scope como una clausura con clave, que es otra forma de hacerlo:
// Registro con clave string...
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('active', 1);
});
// ...y entonces sí se quita por esa clave.
User::withoutGlobalScope('active')->get();Si registraste una clase y pides withoutGlobalScope('active'), Laravel no encuentra nada que quitar, no lanza ningún error, y la consulta sigue filtrando. Ese es el peor tipo de bug: silencioso.
Para varios scopes o para todos:
// Todos
User::withoutGlobalScopes()->get();
// Algunos
User::withoutGlobalScopes([
FirstScope::class, SecondScope::class,
])->get();
// Todos menos los indicados
User::withoutGlobalScopesExcept([
SecondScope::class,
])->get();Cuándo conviene y cuándo no
Un global scope es buena idea cuando la condición es una invariante del modelo: algo que es cierto siempre y cuya ausencia sería un bug. El caso de libro es la multi-tenencia, filtrar por tenant_id en todas las consultas, donde olvidarlo significa filtrar datos de otro cliente. SoftDeletes es el otro.
Donde suele salir mal:
- Cuando la condición no es realmente universal. Si a los tres días estás escribiendo
withoutGlobalScope()en la mitad de las consultas, la condición no era invariante y el scope está estorbando más de lo que ahorra. - Porque es invisible. La consulta que ves en el código no es la que llega a la base de datos. Quien depure eso dentro de seis meses va a mirar el modelo, no el
app/Models/Scopes. Si el resultado no cuadra,->toSql()o->dd()te muestran lo que se está ejecutando de verdad. - En los
updatesydeletesmasivos. El scope también se aplica ahí, y eso normalmente es lo que quieres, pero conviene saberlo antes de lanzar unUser::update(...).
No es una razón para evitarlos, es una razón para usarlos con condiciones que de verdad valgan siempre.
Más recetas de este tipo, de Eloquent al deploy, en Laravel en producción.
Preguntas frecuentes
¿Cuál es la diferencia entre un scope local y un global scope?
El local lo invocas tú (User::active()->get()), el global se aplica solo a todas las consultas del modelo. El local es opcional por diseño; el global es obligatorio salvo que lo quites a propósito.
¿Cómo quito un global scope en una consulta puntual?
Con withoutGlobalScope(NombreDelScope::class), pasando la clase. Si registraste el scope como clausura con clave, entonces sí se usa el string de esa clave.
¿Puedo aplicar varios global scopes al mismo modelo?
Sí. El atributo #[ScopedBy([...])] acepta un array, y con addGlobalScope puedes llamarlo tantas veces como necesites dentro de booted().
¿SoftDeletes es un global scope?
Sí, y es el mejor ejemplo de para qué sirven: es lo que hace que los registros borrados no aparezcan sin que tú añadas nada. withTrashed() es, en el fondo, quitar ese scope.
¿Los global scopes afectan al rendimiento?
El scope en sí no: añade una condición al SQL, igual que si la escribieras a mano. Lo que puede doler es que la columna que filtras no tenga índice, porque entonces esa condición se paga en todas las consultas del modelo en vez de en una. Si vas a filtrar siempre por active o tenant_id, indexa esa columna.
¿Funciona con relaciones?
Sí. Al cargar una relación, el modelo relacionado aplica sus propios global scopes. Es cómodo y a la vez es la fuente habitual de sorpresas: si una relación viene vacía y no entiendes por qué, mira si el modelo del otro lado tiene un scope global filtrando.
¿Tienes un proyecto en mente?
Trabajo con Laravel, WordPress, SEO técnico y servidores MCP. El primer paso es una llamada de descubrimiento, sin costo ni compromiso, donde me cuentas qué necesitas y te digo con honestidad si puedo ayudarte.