Vous souhaitez nous soutenir ? Devenez sponsor de l'association sur notre page Github

Évitez les bugs de pagination et les bindings cassés avec SelectSub

Publié le 19 décembre 2025 par Ludovic Guénet 🐆
Couverture de l'article Évitez les bugs de pagination et les bindings cassés avec SelectSub

Vous avez déjà galéré avec des sous-requêtes qui cassent votre pagination ? Passé des heures à déboguer des bindings SQL qui se mélangent ? Vu votre application ralentir à cause de requêtes de comptage qui incluent des agrégations inutiles ?

selectSub() est là pour vous sauver. Cette méthode du Query Builder de Laravel résout élégamment tous ces problèmes en une seule ligne de code. Et pourtant, beaucoup de développeurs ne la connaissent pas ou l'ignorent !

Voyons comment selectSub() peut transformer vos requêtes complexes en code propre et performant.

Le problème des sous-requêtes manuelles

Avant de découvrir selectSub(), la plupart des développeurs ajoutent des sous-requêtes comme ça :

1$subquery = Leave::query()
2 ->selectRaw('COUNT(*)')
3 ->whereColumn('leaves.user_id', 'users.id')
4 ->whereDate('leaves.date_at', '>=', $startDate);
5 
6$users = User::query()
7 ->selectRaw("({$subquery->toSql()}) as leaves_count")
8 ->mergeBindings($subquery->getBindings())
9 ->paginate(50);

Ça fonctionne ? Oui. C'est propre ? Non. Et surtout, ça peut casser à tout moment.

Problème #1 : Les bindings qui se mélangent

Le vrai problème apparaît quand votre sous-requête contient des bindings :

1$subquery = Order::query()
2 ->selectRaw('SUM(amount)')
3 ->whereColumn('orders.user_id', 'users.id')
4 ->where('status', '=', ?) // ← Binding de la sous-requête
5 ->whereDate('created_at', '>=', ?); // ← Autre binding
6 
7$users = User::query()
8 ->where('status', 'active') // ← Binding de la requête principale
9 ->selectRaw("({$subquery->toSql()}) as total_spent")
10 ->mergeBindings($subquery->getBindings()) // ← Bindings de la sous-requête
11 ->paginate(50);

Que se passe-t-il ? Laravel génère une requête SQL avec les bindings dans le mauvais ordre :

1-- Ce que Laravel génère (INCORRECT)
2SELECT (SELECT SUM(amount) FROM orders WHERE status = ? AND created_at >= ?) as total_spent
3FROM users
4WHERE status = ?
5-- Bindings: ["completed", "2024-01-01", "active"]
6-- Mais Laravel peut les mélanger !

Cela peut entraîner des erreurs SQL, des données incorrectes, ou pire, des risques d’injection SQL si les bindings ne sont pas correctement gérés.

Problème #2 : La pagination cassée

Le problème le plus fréquent avec les sous-requêtes manuelles : la pagination qui ne fonctionne plus.

Quand vous appelez paginate(), Laravel génère automatiquement une requête de comptage :

1$users = User::query()
2 ->selectRaw("({$subquery->toSql()}) as leaves_count")
3 ->paginate(50);

Sans selectSub(), Laravel génère la requête suivante :

1-- Requête de comptage générée automatiquement
2SELECT COUNT(*) FROM (
3 SELECT users.*,
4 (SELECT COUNT(*) FROM leaves ...) as leaves_count
5 FROM users
6) as count_query;

Cette requête a plusieurs problèmes :

  • La sous-requête s'exécute dans le COUNT (inutile et lent)
  • Si votre sous-requête contient un GROUP BY, ça peut planter
  • Le résultat du comptage peut être incorrect

Avec selectSub, Laravel exclut automatiquement la colonne du COUNT, ce qui donne un comptage rapide, correct et qui fonctionne toujours :

1-- Requête de comptage générée automatiquement
2SELECT COUNT(*) FROM (
3 SELECT users.* -- ← leaves_count est automatiquement exclu !
4 FROM users
5) as count_query;

Problème #3 : La maintenabilité en souffrance

Votre code devient vite illisible dès lors que vous multipliez les sous-requêtes nuisant à la maintenabilité de votre code :

1$subquery1 = Leave::query()->selectRaw('...');
2$subquery2 = Room::query()->selectRaw('...');
3$subquery3 = Shift::query()->selectRaw('...');
4 
5$users = User::query()
6 ->selectRaw("({$subquery1->toSql()}) as leaves_count")
7 ->mergeBindings($subquery1->getBindings())
8 ->selectRaw("({$subquery2->toSql()}) as rooms_count")
9 ->mergeBindings($subquery2->getBindings())
10 ->selectRaw("({$subquery3->toSql()}) as shifts_count")
11 ->mergeBindings($subquery3->getBindings())
12 ->paginate(50);

C'est verbeux, répétitif, et source d'erreurs. Un oubli de mergeBindings() et c'est le drame.

La solution : selectSub() en une ligne

selectSub() résout tous ces problèmes en une seule méthode :

1$subquery = Order::query()
2 ->selectRaw('SUM(amount)')
3 ->whereColumn('orders.user_id', 'users.id')
4 ->where('status', 'completed')
5 ->whereDate('created_at', '>=', now()->subMonth());
6 
7$users = User::query()
8 ->selectSub($subquery, 'total_spent')
9 ->paginate(50);

C'est tout, vraiment ! Voici ce que selectSub() fait automatiquement pour vous

  1. Gère les bindings : fusionne correctement les bindings de la sous-requête avec ceux de la requête principale
  2. Exclut du COUNT : retire automatiquement la colonne des requêtes de comptage pour la pagination
  3. Type safety : fonctionne avec des instances Query Builder, pas des chaînes SQL brutes

Exemple concret : statistiques utilisateur

Imaginons que vous voulez afficher une liste d'utilisateurs avec le nombre de commandes qu'ils ont passées ce mois-ci.

Sans selectSub()

1public function index(Request $request)
2{
3 $startDate = now()->startOfMonth();
4 $endDate = now()->endOfMonth();
5 
6 // Sous-requête avec bindings
7 $subquery = Order::query()
8 ->selectRaw('COUNT(*)')
9 ->whereColumn('orders.user_id', 'users.id')
10 ->where('status', '=', ?) // Binding #1
11 ->whereDate('created_at', '>=', ?) // Binding #2
12 ->whereDate('created_at', '<=', ?); // Binding #3
13 
14 $users = User::query()
15 ->select('users.*')
16 ->selectRaw("({$subquery->toSql()}) as orders_this_month")
17 ->mergeBindings($subquery->getBindings())
18 ->paginate(50);
19 
20 return UserResource::collection($users);
21}

Problèmes :

  • Si vous oubliez mergeBindings(), les bindings sont perdus
  • La pagination peut être cassée (sous-requête dans le COUNT)
  • Le code est verbeux et difficile à maintenir
  • Risque d'erreur si vous modifiez la sous-requête plus tard

Avec selectSub()

1public function index(Request $request)
2{
3 $users = User::query()
4 ->withOrdersThisMonth() // Scope qui utilise selectSub()
5 ->paginate(50);
6 
7 return UserResource::collection($users);
8}
9 
10// Dans le modèle User
11public function scopeWithOrdersThisMonth(Builder $query): void
12{
13 $startDate = now()->startOfMonth();
14 $endDate = now()->endOfMonth();
15 
16 $subquery = Order::query()
17 ->selectRaw('COUNT(*)')
18 ->whereColumn('orders.user_id', 'users.id')
19 ->where('status', 'completed')
20 ->whereDate('created_at', '>=', $startDate)
21 ->whereDate('created_at', '<=', $endDate);
22 
23 // Une seule ligne, et tout fonctionne
24 $query->selectSub($subquery, 'orders_this_month');
25}

Avantages :

  • Les bindings sont gérés automatiquement
  • La pagination fonctionne toujours (colonne exclue du COUNT)
  • Le code est propre et réutilisable
  • Facile à tester et maintenir

Cas d'usage avancés

Multiples sous-requêtes

Vous pouvez chaîner plusieurs selectSub() sans problème :

1$users = User::query()
2 ->selectSub($leavesSubquery, 'leaves_count')
3 ->selectSub($roomsSubquery, 'rooms_count')
4 ->selectSub($shiftsSubquery, 'shifts_count')
5 ->paginate(50);

Chaque sous-requête gère ses propres bindings, et toutes sont exclues du COUNT automatiquement.

Sous-requêtes conditionnelles

Vous pouvez même ajouter des sous-requêtes conditionnellement :

1$query = User::query();
2 
3if ($request->has('include_leaves')) {
4 $query->selectSub($leavesSubquery, 'leaves_count');
5}
6 
7if ($request->has('include_rooms')) {
8 $query->selectSub($roomsSubquery, 'rooms_count');
9}
10 
11$users = $query->paginate(50);

La pagination fonctionnera toujours, peu importe le nombre de sous-requêtes ajoutées.

Sous-requêtes avec relations

Vous pouvez utiliser selectSub() avec des relations Eloquent :

1$subquery = User::find(1)->orders()
2 ->selectRaw('SUM(amount)')
3 ->where('status', 'completed')
4 ->whereDate('created_at', '>=', now()->subMonth())
5 ->getQuery();
6 
7$users = User::query()
8 ->selectSub($subquery, 'recent_orders_total')
9 ->paginate(50);

Performance : selectSub() vs selectRaw()

Vous vous demandez peut-être si selectSub() a un impact sur les performances ?

Réponse courte : non. selectSub() génère exactement le même SQL que selectRaw() avec mergeBindings(), mais de manière plus sûre.

La vraie différence de performance vient de la pagination :

1# Avec selectRaw() - Requête de comptage lente
2SELECT COUNT(*) FROM (
3 SELECT users.*, (SELECT COUNT(*) FROM leaves ...) as leaves_count
4 FROM users
5) as count_query;

Temps d'exécution : ~500ms (sous-requête inutile dans le COUNT)

1# Avec selectSub() - Requête de comptage optimisée
2SELECT COUNT(*) FROM (
3 SELECT users.*
4 FROM users
5) as count_query;

Temps d'exécution : ~50ms (pas de sous-requête dans le COUNT)

Sur une liste paginée avec 1000 utilisateurs, la différence est énorme. selectSub() peut diviser le temps de réponse par 10.

Quand utiliser selectSub() ?

Même si votre sous-requête n'a pas de bindings, utilisez selectSub() dans tous les cas pour :

  1. La pagination : éviter les problèmes de comptage
  2. La maintenabilité : code plus propre et réutilisable
  3. La sécurité : gestion automatique des bindings si vous en ajoutez plus tard
  4. La cohérence : même approche partout dans votre codebase

Exception : Si vous avez vraiment besoin d'une requête SQL brute complexe qui ne peut pas être exprimée avec le Query Builder, utilisez selectRaw(). Mais c'est très rare.

Migration depuis selectRaw()

Si vous avez déjà du code avec selectRaw() et mergeBindings(), la migration est simple :

Avant :

1$subquery = Leave::query()->selectRaw('COUNT(*)');
2 
3$users = User::query()
4 ->selectRaw("({$subquery->toSql()}) as leaves_count")
5 ->mergeBindings($subquery->getBindings())
6 ->paginate(50);

Après :

1$subquery = Leave::query()->selectRaw('COUNT(*)');
2 
3$users = User::query()
4 ->selectSub($subquery, 'leaves_count')
5 ->paginate(50);

C'est littéralement deux lignes en moins, et votre code est plus sûr.

Conclusion

selectSub() est une de ces méthodes Laravel qui paraît simple en surface mais qui résout des problèmes complexes :

  • Sécurité : gestion automatique des bindings SQL
  • Performance : exclusion automatique des colonnes dans les requêtes de comptage
  • Maintenabilité : code plus propre et réutilisable
  • Fiabilité : moins de bugs, moins de surprises

Une seule ligne de code pour éviter des heures de débogage. C'est ça, la magie de Laravel.

La prochaine fois que vous ajoutez une sous-requête à votre requête principale, pensez à selectSub(). Votre futur vous remerciera.

Ludovic Guénet 🐆 avatar
Ludovic Guénet 🐆
software engineer • mentor • bassist

A lire

Autres articles de la même catégorie