Route model binding et multi-tenancy sous Laravel : résoudre le conflit avec forgetParameter()
En travaillant sur une application SaaS multi-tenant, j'ai rencontré un bug que je n'avais jamais rencontré dans mes années de développement sur Laravel. Un TypeError surgit de nulle part après un refactoring qui semblait anodin.
En cherchant la cause, je suis tombé sur une méthode que je ne connaissais pas : forgetParameter().
Je partage ici le contexte, le problème, et surtout le fonctionnement interne de Laravel qui explique tout.
Le contexte
Je développe une application SaaS en utilisant le RILT Stack (React / Inertia / Laravel / Tailwind), et dans mes besoins, chaque marchand possède une boutique accessible via un slug dans l'URL comme dans l'exemple suivant :
https://store.mondomaine.com/store/{url}/productshttps://store.mondomaine.com/store/{url}/settingshttps://store.mondomaine.com/store/{url}/settings/payout-connections/{payoutConnection}
Le paramètre {url} identifie le tenant. Un middleware InitializeTenancyByUrl intercepte chaque requête, résout le store, et le met à disposition via un helper store() :
class InitializeTenancyByUrl{ public function handle(Request $request, Closure $next): Response { $storeUrl = $request->url; $tenant = $this->resolve($storeUrl); $this->registerTenant($tenant); URL::defaults(['url' => $tenant->url]); // Partage du store avec Inertia, Ziggy, etc. return $next($request); }}
URL::defaults(['url' => $tenant->url]) fait en sorte que route() et to_route() remplissent automatiquement le paramètre {url} sans qu'on ait à le passer manuellement. Côté frontend, Ziggy reçoit le même default.
Le code avant refactoring
Tous les controllers recevaient string $url en premier paramètre, même s'ils ne l'utilisaient jamais, voici une partie de mon fichier web.php:
Route::middleware([ InitializeTenancyByUrl::class, // ...]) ->prefix('{url}') ->group(function (): void { Route::get('/', DashboardController::class)->name('dashboard'); Route::get('/products/create', Products\CreateProductController::class)->name('products.create'); Route::delete('/settings/payout-connections/{payoutConnection}', [Settings\StorePayoutConnectionController::class, 'destroy']) ->name('settings.destroy-payout-connection'); // ... })
Et le code du controller qui posait un problème (celui qui m'a poussé à refactorer tous les autres),
final class StorePayoutConnectionController{ public function destroy(string $url, StorePayoutConnection $payoutConnection): RedirectResponse { // $url n'est jamais utilisé ici // Le store est résolu via store() $payoutConnection->delete(); return to_route('store.sellers.settings', ['url' => $url]); }}
Deux choses inutiles ici :
string $urln'est jamais lu, le store est déjà résolu par le middleware['url' => $url]dansto_route()est redondant grâce àURL::defaults()
Le code fonctionnait, mais c'était du bruit. J'ai donc décidé de nettoyer ça sur l'ensemble des controllers.
Le refactoring
Simple en apparence. On retire string $url de la signature et ['url' => $url] des redirections :
public function destroy(StorePayoutConnection $payoutConnection): RedirectResponse{ $payoutConnection->delete(); return to_route('store.sellers.settings');}
Propre et facile. Les tests passent sur la majorité des controllers. Sauf un.
Le bug
TypeError: StorePayoutConnectionController::destroy():Argument #1 ($payoutConnection) must be of type StorePayoutConnection, string given
Laravel passe une string (le slug du store) là où il devrait passer un model Eloquent. Le route model binding ne fonctionne plus.
Pourtant, je n'ai touché ni à la route, ni au middleware, ni au model. J'ai juste retiré un paramètre inutilisé.
Ce qui se passe dans les entrailles de Laravel
Pour comprendre, il faut regarder comment Laravel dispatche les controllers. Le fichier clé est Illuminate\Routing\ControllerDispatcher :
// vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php public function dispatch(Route $route, $controller, $method){ $parameters = $this->resolveClassMethodDependencies( $route->parametersWithoutNulls(), $controller, $method ); return $controller->{$method}(...array_values($parameters)); [tl! highlight]}
La ligne importante c'est array_values($parameters). Cette méthode supprime les clés associatives et convertit le tableau en indices numériques. Les paramètres sont ensuite passés au controller par position, pas par nom.
Concrètement, pour la route /{url}/store/settings/payout-connections/{payoutConnection}, les paramètres de route après le passage du middleware SubstituteBindings sont :
[ 'url' => 'ma-boutique', // string (le slug) 'payoutConnection' => StorePayoutConnection#1 // model résolu par le binding]
Après array_values() :
[ 0 => 'ma-boutique', 1 => StorePayoutConnection#1]
Avant (avec string $url)
public function destroy(string $url, StorePayoutConnection $payoutConnection)// position 0 ✓ position 1 ✓// 'ma-boutique' StorePayoutConnection#1
Chaque paramètre reçoit la bonne valeur. string $url absorbe la position 0, le model atterrit en position 1.
Après (sans string $url)
public function destroy(StorePayoutConnection $payoutConnection)// position 0 ✗// 'ma-boutique' ← string au lieu du model
Le model existe bien en position 1, mais il n'y a personne pour le recevoir. La position 0 envoie le slug du store au paramètre $payoutConnection. D'où le TypeError.
La solution : forgetParameter()
La méthode Route::forgetParameter() retire un paramètre du bag de la route. Si on l'appelle dans le middleware après avoir résolu le tenant, le paramètre url n'existe plus au moment du dispatch :
class InitializeTenancyByUrl{ public function handle(Request $request, Closure $next): Response { $storeUrl = $request->url; $tenant = $this->resolve($storeUrl); $this->registerTenant($tenant); URL::defaults(['url' => $tenant->url]); $request->route()?->forgetParameter('url'); // ... return $next($request); }}
Maintenant, quand le controller est dispatché :
$route->parametersWithoutNulls()// ['payoutConnection' => StorePayoutConnection#1] array_values($parameters)// [0 => StorePayoutConnection#1]
Le model est en position 0. Le controller le reçoit correctement.
Pourquoi c'est safe
À ce stade, on pourrait se demander : est-ce qu'on ne casse pas quelque chose en retirant ce paramètre ?
Non, parce que le paramètre {url} a déjà rempli son rôle à trois niveaux :
- Le routing — Laravel a matché l'URL entrante avec la bonne route. C'est fait.
- Le tenant — Le middleware a résolu le store et l'a enregistré via
tenant()->setTenant(). Le helperstore()fonctionne. - La génération d'URL —
URL::defaults()et les defaults Ziggy garantissent queroute()(PHP et JS) remplissent{url}automatiquement.
Le paramètre n'a plus aucune utilité une fois le middleware passé.
Quand ce problème se manifeste
Ce bug n'apparaît pas sur tous les controllers. Il faut deux conditions réunies :
- La route contient un paramètre préfixe inutilisé par le controller (ici
{url}) - La route contient un autre paramètre avec route model binding (ici
{payoutConnection},{product:public_id}, etc.)
Si le controller n'a pas de route model binding (par exemple un __invoke(StoreProductRequest $request)), il n'y a pas de conflit. Le FormRequest est résolu via le service container, pas depuis les paramètres de route.
Ce que j'en retiens
En plusieurs années de développement sur Laravel et même après l'obtention de ma certification, je n'avais jamais eu besoin de forgetParameter(). C'est le genre de méthode qui existe dans le framework, bien documentée dans le code source, mais qu'on ne croise que dans des cas spécifiques comme le multi-tenancy.
Ce que cette expérience m'a rappelé : quand un bug semble irrationnel, la réponse est souvent dans le code source du framework. Ici, une seule ligne dans ControllerDispatcher.php — array_values($parameters) — explique tout le comportement.
Si vous construisez une application multi-tenant avec un préfixe d'URL résolu par middleware, pensez à forgetParameter(). Ça vous évitera de garder des paramètres morts dans vos controllers juste pour que le dispatch positionnel fonctionne.
Shalom.