Utiliser DeferrableProvider pour différer le chargement de ses services
Lors du développement d'une application Laravel, il est courant d'enregistrer des services dans des ServiceProvider pour les rendre disponibles via le conteneur d'injection de dépendances (Laravel Service Container).
Cependant, certains services ne devraient pas être chargés au démarrage de l'application. C'est là que DeferrableProvider entre en jeu.
Dans cet article, je vais te présenter un cas concret où j'ai rencontré un problème durant un déploiement en raison d'un service qui tentait d'accéder à la base de données avant que les migrations n'aient pu s'exécuter.
Je vais t'expliquer comment DeferrableProvider a résolu ce problème et, plus largement, quand tu devras l'utiliser dans tes projets.
Le problème
Laisse-moi te montrer comment j'avais structuré mon application avant de découvrir ce problème.
Le provider AppServiceProvider enregistrait des services que j'ai créé pour un plugin CMS en tant que singletons :
final class AppServiceProvider extends ServiceProvider{ public array $singletons = [ LoginResponse::class => \App\Http\Responses\LoginResponse::class, LogoutResponse::class => \App\Http\Responses\LogoutResponse::class, LegalPagesService::class => LegalPagesService::class, LegalPagesHelper::class => LegalPagesHelper::class, ];}
Ces services étaient utilisés dans le template de footer qui s'affiche sur chaque page :
@php $pageHelper = app(\Universy\Cms\Helpers\LegalPagesHelper::class);@endphp <footer> @if ($legalUrl = data_get($pageHelper->getAllUrls(), 'legal_notice')) <a href="{{ $legalUrl }}">{{ __('words.footer.privacy_policy') }}</a> @endif</footer>
Et voilà le LegalPagesService qui faisait les requêtes à la base de données:
final class LegalPagesService{ public function getLegalNotice(?string $locale = null): ?Page { $locale ??= app()->getLocale(); $cacheKey = "legal_pages.legal_notice.{$locale}"; return Cache::remember($cacheKey, 86400, function () use ($locale) { return Page::query() ->select('id', 'title', 'slug', 'parent_id') ->where("slug->{$locale}", 'legal-notice') ->first(); }); }}
En apparence, tout semble logique. Cependant, en production, voici ce qui se passait :
- L'application démarre et charge les services depuis
AppServiceProvider - Le premier utilisateur charge une page
- Le footer se rend et demande le
LegalPagesHelper - Le service essaie d'interroger la table
pagespour récupérer les URLs légales - Erreur : La table
pagesn'existe pas encore, car les migrations ne se sont pas exécutées
Le problème n'était pas architectural en soi, c'était plutôt une question de timing. Les migrations n'avaient pas eu le temps de s'exécuter avant que l'application ne tente d'accéder à la base de données.
Pourquoi cela s'est-il produit ?
La vraie raison, c'est que j'avais enregistré mes services CMS comme singletons au niveau global dans AppServiceProvider. Laravel instancie ces singletons lors du bootstrap de l'application, indépendamment de leur utilisation.
C'est un pattern correct dans de nombreux cas, mais il pose problème quand tes services dépendent de ressources qui pourraient ne pas être disponibles au démarrage.
Ce n'est pas un bug, c'est une violation du principe de responsabilité unique (Single Responsibility Principle). Les services CMS devraient être gérés par le CmsServiceProvider, pas par le provider global de l'application.
Quand utiliser DeferrableProvider ?
Avant de montrer la solution, c'est important de comprendre dans quels cas DeferrableProvider est approprié. Cette interface n'est pas pour tous les services, elle a des cas d'usage spécifiques.
Tu devrais utiliser DeferrableProvider si :
- Ton service n'est pas utilisé sur toutes les requêtes - Par exemple, un service utilisé seulement dans le footer n'est pas nécessaire pour une requête API
- Ton service dépend de ressources externes - Base de données, API externe, système de fichiers. Si la ressource ne peut pas être garantie au démarrage, défère le chargement
- Ton service est optionnel pour le bootstrap - Il ne participe pas à la configuration ou l'initialisation critique de l'application
- Tu veux optimiser les performances - Éviter de charger du code inutilisé réduit le temps de bootstrap
Tu devrais éviter DeferrableProvider si :
- Ton service est utilisé sur presque toutes les requêtes - Repousser le chargement cause juste une surcharge à la première résolution
- Ton service est utilisé au démarrage - Par exemple, dans le
boot()d'autres providers, il doit être disponible immédiatement - Ton service configure des éléments critiques - Middleware, routes globales, ou authentification
La solution: DeferrableProvider
Laravel propose une interface exactement pour ce cas d'usage : DeferrableProvider. Cette interface permet à un service provider de déclarer les services qu'il fournit, et Laravel ne chargera le provider que lorsque l'un de ces services sera réellement résolu par le conteneur.
C'est le même pattern utilisé en interne par Laravel pour les services Mail, Cache, Queue, et autres. Ces services ne sont pas chargés à chaque requête, ils le sont uniquement quand on en a besoin.
Voici comment j'ai restructuré mon CmsServiceProvider:
<?php declare(strict_types=1); namespace Universy\Cms\Providers; use Illuminate\Contracts\Support\DeferrableProvider;use Universy\Cms\Helpers\LegalPagesHelper;use Universy\Cms\Services\LegalPagesService;use Universy\PluginSystem\Providers\PackageServiceProvider; final class CmsServiceProvider extends PackageServiceProvider implements DeferrableProvider{ protected static string $name = 'cms'; public function packageRegistered(): void { $this->registerCmsServices(); Panel::configureUsing(function (Panel $panel): void { if ($panel->getId() === 'admin') { $panel->plugin(CmsPlugin::make()); } }); } private function registerCmsServices(): void { $this->app->singleton( LegalPagesService::class, fn (): LegalPagesService => new LegalPagesService() ); $this->app->singleton( LegalPagesHelper::class, fn (): LegalPagesHelper => new LegalPagesHelper( $this->app->make(LegalPagesService::class) ) ); } /** * Get the services provided by this provider. * Laravel uses this to determine when to load this provider. * * @return array<int, string> */ public function provides(): array { return [ LegalPagesService::class, LegalPagesHelper::class, ]; } }
Et dans l'AppServiceProvider, j'ai simplement retiré les enregistrements CMS :
final class AppServiceProvider extends ServiceProvider{ public array $singletons = [ LoginResponse::class => \App\Http\Responses\LoginResponse::class, LogoutResponse::class => \App\Http\Responses\LogoutResponse::class, // Les services CMS ne sont plus ici ];}
Comment ça fonctionne ?
Quand tu implémentes DeferrableProvider et que tu définis une méthode provides(), voici ce que Laravel fait en coulisses :
- Au démarrage de l'application, Laravel compile une liste des services fournis par les deferred providers
- Le provider lui-même ne charge pas - juste sa déclaration de services
- Lorsqu'on demande un service (par exemple,
app(LegalPagesHelper::class)), Laravel vérifie si ce service est fourni par un deferred provider - Si oui, il charge d'abord le provider, qui enregistre le service, puis le résout
En pratique, cela signifie que le CmsServiceProvider ne charge que lorsque quelqu'un demande réellement LegalPagesService ou LegalPagesHelper. Généralement, c'est lors du premier rendu du footer.
À ce moment-là, les migrations ont déjà été exécutées, la table pages existe, et tout fonctionne normalement.
Les détails importants
Deux points sont cruciaux dans cette implémentation :
1. Utiliser des closures pour les singletons
$this->app->singleton( LegalPagesService::class, fn (): LegalPagesService => new LegalPagesService());
Remarque la closure fn (). C'est important car elle retarde l'instantiation du service. Sans la closure, Laravel instancierait le service lors de l'enregistrement.
2. Lister tous les services dans provides()
public function provides(): array{ return [ LegalPagesService::class, LegalPagesHelper::class, ];}
Laravel utilise cette liste pour savoir quand charger le provider. Si tu oublies un service, il ne sera pas trouvé par le conteneur.
Le résultat
Après cette modification, mon déploiement sur Dokploy s'est déroulé sans heurts. L'application a démarré, les migrations se sont exécutées, et quand le premier utilisateur a chargé une page avec le footer, le service CMS s'est résolu correctement.
Plus important encore, cette approche améliore aussi les performances. Les services CMS ne sont jamais instanciés pour les requêtes qui n'en ont pas besoin, par exemple, les requêtes API ou les commandes Artisan qui n'affichent pas le footer.
Leçons retenues
Ce problème m'a rappelé quelque chose d'important en architecture Laravel : la clarté des responsabilités. Les services CMS devraient être gérés par le CmsServiceProvider, pas par le provider global. Cela aurait été plus cohérent dès le départ.
De plus, DeferrableProvider n'est pas juste une optimisation. C'est une décision architecturale qui dit : "Ce service est optionnel pour le démarrage de l'application." Elle force à penser à quels services sont vraiment essentiels et lesquels ne le sont pas.
Si tu déploies une application Laravel avec des dépendances externes complexes, prends un moment pour vérifier tes ServiceProviders. Identifie lesquels pourraient être deferrable. Cela économisera probablement du debug en production.