• Laravel
  • Scopes vs. Builders: La batalla por la claridad en tus consultas de Laravel

    Vamos a hablar de los Query Scopes. Son una herramienta potente, nadie lo duda. Te permiten limpiar tus consultas y hacerlas legibles. Pero tienen un lado oscuro que termina pasando factura, sobre todo en equipos donde no todo el mundo es especialista en backend: la magia implícita.

    Puedes añadir PHPDocs hasta aburrirte, pero al final estás llamando a métodos que no aparecen definidos en tu modelo. Es magia, y la magia no escala bien.

    #¿Qué son los Scopes y por qué nos enamoramos de ellos?

    Imagina esta consulta típica para obtener usuarios activos y populares:

     1use App\Models\User;
     2
     3$users = User::query()
     4    ->where('votes', '>', 100)
     5    ->where('active', 1)
     6    ->orderBy('created_at')
     7    ->get();
    

    Funciona, pero cuando la lógica se repite o se complica, la abstracción mediante scopes es la solución natural. Los defines en el modelo:

     1<?php
     2
     3namespace App\Models;
     4
     5use Illuminate\Database\Eloquent\Builder;
     6use Illuminate\Database\Eloquent\Model;
     7
     8class User extends Model
     9{
    10    public function scopePopular(Builder $query): void
    11    {
    12        $query->where('votes', '>', 100);
    13    }
    14
    15    public function scopeActive(Builder $query): void
    16    {
    17        $query->where('active', 1);
    18    }
    19}
    

    Y tu consulta se transforma en algo mucho más expresivo:

     1$users = User::query()
     2    ->popular()
     3    ->active()
     4    ->orderBy('created_at')
     5    ->get();
    

    La legibilidad mejora de forma evidente. El problema viene después.

    #La factura de la magia: Autocompletado y código inflado

    Tu IDE no tiene ni idea de que esos métodos popular() o active() existen. Se resuelven en tiempo de ejecución gracias al prefijo scope. Para ayudar a tu editor de código, tienes que recurrir a anotaciones:

     1/**
     2 * @method static Builder popular()
     3 * @method static Builder active()
     4 */
     5class User extends Model
    

    Es un parche. El verdadero problema estructural es otro: los modelos se hinchan. Acabas teniendo una clase de modelo que, además de relaciones, mutadores y lógica de negocio, está repleta de docenas de métodos de scope. Pierdes la capacidad de ver lo importante de un vistazo.

    #La alternativa organizada: Constructores de consultas personalizados

    La solución es mover toda esa lógica de consulta a una clase dedicada: un Custom Query Builder. No es más que una clase que extiende Illuminate\Database\Eloquent\Builder.

    Así se crea, por ejemplo, en app/Eloquent/QueryBuilders/UserQueryBuilder.php:

     1<?php
     2
     3namespace App\Eloquent\QueryBuilders;
     4
     5use Illuminate\Database\Eloquent\Builder;
     6
     7class UserQueryBuilder extends Builder
     8{
     9    public function popular(): self
    10    {
    11        return $this->where('votes', '>', 100);
    12    }
    13
    14    public function active(): self
    15    {
    16        return $this->where('active', 1);
    17    }
    18}
    

    Luego, le dices a tu modelo User que utilice este constructor por defecto:

     1<?php
     2
     3namespace App\Models;
     4
     5use App\Eloquent\QueryBuilders\UserQueryBuilder;
     6use Illuminate\Database\Eloquent\Model;
     7
     8class User extends Model
     9{
    10    // Usar nuestro Builder personalizado
    11    public function newEloquentBuilder($query): UserQueryBuilder
    12    {
    13        return new UserQueryBuilder($query);
    14    }
    15
    16    // Para tener hints de tipo correctos
    17    public static function query(): UserQueryBuilder
    18    {
    19        return parent::query();
    20    }
    21}
    

    El resultado es que ahora tu consulta funciona exactamente igual, pero con autocompletado full, navegación directa al código y cero magia:

     1$users = User::query()
     2    ->popular() // Tu IDE sabe que este método existe
     3    ->active()
     4    ->orderBy('created_at')
     5    ->get();
    

    #Ventaja extra: Resolución dinámica de Builders

    Esta aproximación abre la puerta a estrategias más sofisticadas. Imagina que quieres un constructor de consultas diferente según el estado del modelo:

     1public function newEloquentBuilder($query): UserQueryBuilder
     2{
     3    if ($this->status === State::Pending) {
     4        return new PendingUserQueryBuilder($query); // Extiende UserQueryBuilder
     5    }
     6
     7    return new UserQueryBuilder($query);
     8}
    

    De este modo, evitas tener un único Builder gigante y puedes agrupar la lógica de consulta por contextos específicos, manteniendo cada clase cohesionada y con una responsabilidad clara.

    #Conclusión: Cuándo usar cada herramienta

    • Usa Scopes cuando tengas 2 o 3 métodos muy simples y el modelo no esté sobresaturado. Son rápidos de implementar.
    • Apostilla por Custom Query Builders cuando la lógica de consulta crezca, trabajes en equipo o valores la mantenibilidad a largo plazo. El esfuerzo inicial de configurarlos se paga solo con el primer viaje que haga clic derecho -> "Ir a la definición" y acierte.

    Los builders personalizados organizan tu código, eliminan la magia y hacen que tu base sea más robusta y fácil de navegar. Para mí, es el camino claro una vez el proyecto pasa de cierta complejidad.

    profile image of Fran Diez

    Fran Diez

    4 años de experiencia. Técnico en desarrollo de aplicaciones web y en administración de sistemas. León, España 🇪🇸. Especializado en el desarrollo de aplicaciones web únicas.

    Mas posts de Fran Diez