Optimiser les performances d'une application Laravel : les pratiques qui comptent vraiment

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
16 min de lecture
806 vues

Écouter l'article

On croit souvent que la performance d'une application Laravel se joue à la mise en production : un coup de config:cache, OPcache activé, et on n'en parle plus. En réalité, les vrais problèmes apparaissent bien avant, pendant le développement, dans des requêtes Eloquent écrites sans y penser et des boucles qui déclenchent des dizaines de requêtes SQL sans que personne ne s'en rende compte.

Cet article rassemble les pratiques qui comptent pour garder une application Laravel rapide, du premier php artisan serve jusqu'à la production. Elles tiennent surtout à la rigueur sur les fondamentaux. Les exemples ciblent Laravel 10 et PHP 8.3 ; la différence est signalée quand un comportement change avec Laravel 11 et plus. Aucun chiffre de gain n'est avancé : il dépend de l'application, de l'hébergement et du volume de données, et seule une mesure avant et après permet de le savoir.

Tout ce qui est propre à Eloquent (eager loading, comptages, gros volumes, écritures groupées, index) est détaillé dans la série « Optimiser Eloquent » : partie 1, relations et N+1 et partie 2, gros volumes et index. Ici, l'accent est mis sur le reste de la chaîne : cache, queues, OPcache, profilage et assets.

Le portfolio est déployé en FTP sur un hébergement mutualisé, sans accès root ni worker : lorsqu'une pratique dépend de l'infrastructure, l'article distingue donc le cas d'un VPS et celui d'un mutualisé.

Le cache Artisan : quatre commandes à lancer en production

En développement, Laravel relit les fichiers de configuration, les routes, les vues Blade et les événements à chaque requête. C'est pratique pour voir les changements en direct, mais c'est du travail inutile en production, où le code ne bouge pas entre deux déploiements.

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

Ces quatre commandes compilent la configuration, les routes, les vues et les événements en fichiers que Laravel charge directement. Le gain sur le temps de démarrage dépend du nombre de routes, de fichiers de configuration et de providers : le plus fiable est de mesurer avant et après sur le projet concerné.

php artisan optimize regroupe ces étapes, mais pas à l'identique selon la version. Le code source du framework le montre : en Laravel 10.50, la commande ne lance que config:cache et route:cache ; à partir de Laravel 11.0, elle lance aussi event:cache et view:cache. Le déploiement du portfolio exécute route:cache, event:cache et view:cache explicitement. Autre limite à connaître : route:cache exige des routes sérialisables, et les routes qui pointent vers des contrôleurs restent le choix le plus sûr.

Un piège courant avec config:cache : une fois la configuration mise en cache, le fichier .env n'est plus lu. Tout appel à env() en dehors des fichiers config/*.php retourne alors null. Cas fréquent : le paiement cesse de fonctionner en production parce que la clé API est lue avec env('STRIPE_KEY') dans un contrôleur au lieu de passer par config('services.stripe.key'). En développement sans cache, tout marche. En production, la clé est null et l'erreur apparaît loin de sa cause.

Le bon usage tient en une phrase : env() uniquement dans les fichiers de configuration, config() partout ailleurs.

Le N+1 : détecter avant d'optimiser

Le problème N+1 est le défaut de performance le plus répandu dans les applications Laravel : une requête pour la liste, puis une requête par élément pour charger une relation. La correction (with()), les comptages sans chargement (withCount()), le choix des colonnes et la détection par Model::preventLazyLoading() sont détaillés dans la partie 1 de la série Eloquent. Le portfolio active preventLazyLoading hors production, ce qui transforme chaque accès à une relation non chargée en exception pendant le développement et les tests.

Le cache applicatif : quand et comment l'utiliser

Le cache applicatif de Laravel est puissant, mais souvent mal utilisé. Certains projets cachent tout, y compris des données qui changent à chaque requête ; d'autres ne cachent rien parce que « ça complique la logique ».

Le bon critère pour mettre une donnée en cache : elle est lue souvent et change rarement.

use Illuminate\Support\Facades\Cache;

// Les paramètres globaux du site : lus à chaque page, modifiés rarement par l'admin
$settings = Cache::remember('global_settings', 3600, function () {
    return Setting::all()->pluck('value', 'key')->toArray();
});

// Les catégories : stables, lues sur chaque page du blog
$categories = Cache::remember('blog_categories', 86400, function () {
    return Category::orderBy('name')->get();
});

remember() vérifie si la clé existe dans le cache, la retourne si oui, sinon exécute la closure et stocke le résultat. Il ne gère pas l'invalidation : si un admin modifie un paramètre, le cache continue de servir l'ancienne valeur jusqu'à l'expiration.

La solution est l'invalidation explicite, au même endroit que la modification des données. Le plus fiable est de l'attacher au modèle, pour qu'elle s'applique quel que soit le code qui modifie un réglage :

// Dans le modèle Setting
protected static function booted(): void
{
    static::saved(fn () => Cache::forget('global_settings'));
    static::deleted(fn () => Cache::forget('global_settings'));
}

Ces événements de modèle ne se déclenchent pas sur les écritures de masse (Setting::where(...)->update([...]), insert(), upsert()) : dans ces cas, l'invalidation se fait à la main (voir la partie 2 de la série Eloquent).

Pour des cas plus complexes, les tags de cache permettent une invalidation groupée :

// Stocker avec des tags
Cache::tags(['posts', 'homepage'])->put('featured_posts', $featured, 3600);
Cache::tags(['posts'])->put('recent_posts', $recent, 3600);

// Quand un article est publié, invalider tout ce qui touche aux posts
Cache::tags('posts')->flush();

Les tags ne sont pas supportés par tous les drivers. Redis et Memcached les supportent ; les drivers file et database non, et l'appel à Cache::tags() lève alors une BadMethodCallException. L'erreur n'apparaît donc qu'avec un driver sans tags : en local avec file et en production avec Redis, on peut ne la découvrir qu'au passage d'un environnement à l'autre. Mieux vaut aligner le driver de cache entre les environnements.

Traiter les opérations lourdes en arrière-plan avec les queues

Certaines opérations n'ont pas besoin de bloquer la réponse HTTP : envoyer un e-mail, générer un PDF, redimensionner une image, appeler une API externe. Un worker peut les traiter en arrière-plan, ce qui suppose un processus permanent, donc un VPS ou un serveur dédié. Sur un mutualisé, on reste en général sur QUEUE_CONNECTION=sync, configuration du portfolio : le code s'écrit de la même façon, mais le job s'exécute dans la requête.

// Sans queue : la réponse attend l'envoi de l'e-mail
// (la latence dépend du serveur SMTP, souvent plusieurs centaines de millisecondes)
Mail::to($user)->send(new WelcomeEmail($user));

// Avec queue : l'e-mail est mis en file d'attente, la réponse part tout de suite
Mail::to($user)->queue(new WelcomeEmail($user));

Pour des traitements plus complexes, un job dédié est plus approprié. Un job peut être exécuté plusieurs fois (nouvelle tentative après une exception, timeout, redémarrage du worker). Il doit donc être idempotent : pouvoir être rejoué sans refaire ce qui a déjà réussi. Sans cela, si le troisième e-mail échoue, la nouvelle tentative renvoie aussi les deux premiers.

namespace App\Jobs;

use App\Mail\ContactMessageConfirmation;
use App\Mail\ContactMessageNotification;
use App\Models\ContactMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

class ProcessContactMessage implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public function __construct(
        public ContactMessage $message
    ) {}

    public function handle(): void
    {
        // Chaque étape est marquée une fois terminée : une nouvelle tentative ne la rejoue pas.
        // (admin_notified_at et confirmation_sent_at sont des colonnes d'exemple.)
        if ($this->message->admin_notified_at === null) {
            Mail::to(config('mail.admin_address'))
                ->send(new ContactMessageNotification($this->message));

            $this->message->forceFill(['admin_notified_at' => now()])->save();
        }

        if ($this->message->confirmation_sent_at === null) {
            Mail::to($this->message->email)
                ->send(new ContactMessageConfirmation($this->message));

            $this->message->forceFill(['confirmation_sent_at' => now()])->save();
        }
    }

    public function failed(\Throwable $exception): void
    {
        // Appelée une fois les tentatives épuisées : tracer l'échec
        Log::error('Contact message processing failed', [
            'message_id' => $this->message->id,
            'error' => $exception->getMessage(),
        ]);
    }
}

Avec php artisan queue:work, le nombre de tentatives par défaut est de 1, sauf si l'on passe --tries ou qu'on définit $tries sur le job. Un job qui lève une exception n'est donc pas relancé en silence : une fois les tentatives épuisées, il est enregistré dans la table failed_jobs, où on peut l'inspecter (queue:failed) et le rejouer (queue:retry). La méthode failed() s'y ajoute pour journaliser l'erreur ou prévenir un admin. Sans surveillance de failed_jobs, les échecs restent invisibles.

Le dispatch tient en une ligne dans le contrôleur :

ProcessContactMessage::dispatch($contactMessage);

return redirect()->back()->with('success', 'Message envoyé !');

Avec un vrai driver (database, redis), le visiteur voit la confirmation tout de suite et le traitement lourd se fait dans le worker. Sur un VPS, ce worker se lance avec php artisan queue:work, sous la supervision de Supervisor ou de systemd pour qu'il redémarre après un plantage ou un déploiement. Sur un mutualisé avec QUEUE_CONNECTION=sync, rien n'est différé : si l'envoi doit rester rapide, il faut soit un cron qui lance queue:work --stop-when-empty (si l'hébergeur autorise les tâches planifiées), soit un service d'envoi externe à API HTTP.

Les traitements de masse (envoi à toute une liste d'abonnés, par exemple) se répartissent en jobs, un par lot ou par destinataire, plutôt que dans une seule requête. La façon de parcourir la liste sans saturer la mémoire est décrite dans la partie 2 de la série Eloquent.

OPcache : le gain le plus facile en production

OPcache compile le bytecode PHP une seule fois et le garde en mémoire. Sans lui, chaque requête HTTP recompile chaque fichier PHP inclus. Un projet Laravel charge facilement plusieurs centaines de fichiers par requête (framework, dépendances, application), donc la différence est significative.

; php.ini : configuration de production, à adapter
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

Le paramètre le plus important est validate_timestamps=0. Par défaut, OPcache vérifie si les fichiers PHP ont été modifiés sur le disque (appel système stat()), au plus une fois toutes les opcache.revalidate_freq secondes (2 par défaut). En production, le code ne change pas entre deux déploiements : désactiver cette vérification économise ces appels. La contrepartie : après un déploiement, OPcache continue de servir l'ancien code tant qu'il n'a pas été vidé (voir plus bas).

max_accelerated_files doit dépasser le nombre de fichiers PHP du projet, dépendances Composer comprises. La valeur par défaut (10000) suffit souvent pour une application Laravel moyenne ; on la monte si opcache_get_status() montre que le cache est saturé. memory_consumption (128 Mo par défaut) suit la même logique : on mesure avant d'augmenter.

Le JIT se configure avec deux directives : opcache.jit (le mode) et opcache.jit_buffer_size (la taille du buffer, à 0 par défaut, ce qui le désactive). Mieux vaut écrire les deux plutôt que dépendre de la valeur par défaut du mode :

opcache.jit=tracing
opcache.jit_buffer_size=64M

Sur une application web classique, dominée par les entrées-sorties (base de données, réseau), le JIT apporte peu : ses gains se voient sur du calcul intensif. Il consomme de la mémoire partagée et ajoute de la complexité en cas de bug difficile à reproduire. Mieux vaut le laisser désactivé tant qu'une mesure sur le cas réel ne justifie pas de l'activer.

Après un déploiement avec validate_timestamps=0, le cache OPcache doit être vidé pour que les nouveaux fichiers soient compilés. Or opcache_reset() appelé depuis un script en ligne de commande (un php artisan dans un hook de déploiement) agit sur l'OPcache du processus CLI, pas sur celui de PHP-FPM, qui est un processus distinct avec sa propre mémoire partagée. La méthode dépend de l'hébergement :

  • Sur un VPS (accès root) : recharger PHP-FPM après le déploiement, par exemple avec sudo systemctl reload php8.3-fpm (le nom du service dépend de la version).
  • Sur un mutualisé (cas du portfolio, déployé en FTP) : pas de systemctl. La configuration d'OPcache se règle via l'interface de l'hébergeur ou un fichier .user.ini quand c'est autorisé, et le cache se vide en appelant opcache_reset() depuis une requête HTTP vers un endpoint protégé (secret, IP autorisée), afin qu'il s'exécute dans le contexte FPM. Autre option : garder validate_timestamps=1 avec un revalidate_freq raisonnable, ce qui évite de vider le cache à la main au prix de quelques appels stat().

Mesurer avant d'optimiser : les outils de profilage

Optimiser sans mesurer, c'est deviner. Deux outils transforment les conjectures en données, et ce sont deux outils de développement.

Laravel Debugbar s'intègre dans la page et affiche en direct le nombre de requêtes SQL, leur durée, la mémoire utilisée et les vues rendues. C'est l'outil du quotidien, et le seul des deux installé sur le portfolio (barryvdh/laravel-debugbar, en dépendance de développement). Il doit rester désactivé en production (APP_DEBUG=false, package installé avec --dev).

// Mesurer un bloc de code spécifique
Debugbar::startMeasure('heavy_operation', 'Calcul des statistiques');
$stats = $this->computeStats();
Debugbar::stopMeasure('heavy_operation');

Le panneau « Queries » est le plus utile : chaque requête SQL avec sa durée et le fichier qui l'a déclenchée. C'est là qu'on repère un N+1 : 25 requêtes SELECT * FROM users WHERE id = ? identiques, avec des paramètres différents.

Laravel Telescope, absent du portfolio, enregistre l'activité de l'application dans une base dédiée : requêtes HTTP, jobs, événements, opérations de cache, exceptions, consultables via /telescope.

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

Telescope s'installe en --dev parce qu'il enregistre beaucoup de données et ralentit l'application : c'est un outil de débogage, pas de supervision de production. Son provider s'enregistre alors uniquement en local (if ($this->app->environment('local'))), et la production passe par un outil d'APM ou de logs, ou par Laravel Pulse, qui agrège les requêtes lentes sans tout enregistrer. Telescope devient utile quand le comportement à comprendre est asynchrone (queue, événements, notifications) ou qu'il faut analyser une séquence de requêtes HTTP complexe. Pour un site sans worker comme le portfolio, Debugbar suffit.

Optimiser les assets frontend avec Vite

Le temps de chargement perçu ne dépend pas que du serveur. Les assets CSS et JavaScript pèsent directement sur le First Contentful Paint.

Vite, le bundler par défaut de Laravel, permet de découper les dépendances en fichiers séparés. Le portfolio n'impose aucun chunk « vendor » : AOS reste dans app.js parce qu'il sert sur presque toutes les pages, et Typed.js ainsi que les modules propres à certaines pages sont des chunks chargés à la demande via import(). Le build compresse en plus les assets en gzip et en brotli (vite-plugin-compression). Quand une grosse dépendance n'est utile qu'à une partie du site, manualChunks permet de la séparer. Exemple de configuration (non utilisée sur le portfolio) :

// vite.config.js (exemple)
export default defineConfig({
    plugins: [laravel(['resources/js/app.js', 'resources/css/app.css'])],
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    'typed': ['typed.js'],
                    'aos': ['aos'],
                },
            },
        },
    },
});

manualChunks sépare les dépendances en fichiers indépendants (les noms ci-dessus sont des exemples). Chaque chunk est versionné par un hash dans son nom de fichier (typed-3f8a2c1d.js). Quand le code applicatif change, les bibliothèques tierces restent en cache navigateur parce que leur hash n'a pas changé : seuls les fichiers réellement modifiés sont retéléchargés.

Une précision sur cssCodeSplit : l'option vaut déjà true par défaut dans Vite, il est inutile de la déclarer. Elle fait extraire le CSS importé par un chunk chargé dynamiquement dans un fichier à part, chargé avec ce chunk. La passer à false regrouperait tout le CSS dans un seul fichier.

Le résultat de npm run build est un dossier public/build/ avec un manifest.json qui associe les noms de fichiers source aux noms versionnés. La directive @vite() des vues Blade s'appuie sur ce manifest pour insérer les bonnes balises <script> et <link>.

Ce qu'il faut vérifier avant chaque mise en production

Plutôt qu'une liste abstraite, voici les vérifications concrètes :

  • Cache Artisan activé : config:cache, route:cache, view:cache, event:cache. Oublier l'une d'elles ne donne qu'un gain partiel.
  • Aucun appel env() en dehors de config/*.php : un grep -r "env(" app/ indique tout de suite les violations. Un seul appel oublié dans un service peut provoquer un bug silencieux en production.
  • Eager loading vérifié sur toutes les pages de liste : Debugbar ouvert, on regarde le nombre de requêtes SQL. Une dizaine de requêtes sur une page d'index est un seuil d'alerte raisonnable.
  • OPcache activé avec validate_timestamps=0 : un phpinfo() exécuté via le serveur web, et non en CLI, confirme la configuration. Le vidage d'OPcache fait partie du déploiement (rechargement de PHP-FPM sur un VPS, requête vers l'endpoint de reset sur un mutualisé).
  • Compression gzip ou brotli activée côté serveur : les assets CSS et JS se compressent très bien, pour un coût quasi nul.
  • Assets versionnés : les fichiers de public/build/ portent un hash dans leur nom, donc pas de problème de cache navigateur après un déploiement.
  • E-mails hors du chemin critique : sur un VPS, ils passent par la queue, avec un worker supervisé et une surveillance de failed_jobs. Sur un mutualisé en QUEUE_CONNECTION=sync, un timeout SMTP court évite qu'un envoi bloque la requête HTTP pendant plusieurs secondes.

La performance ne se traite pas une fois pour toutes. Chaque relation non chargée, chaque SELECT * inutile, chaque e-mail envoyé de façon synchrone ajoute une dette, et les outils pour la repérer existent déjà dans l'écosystème Laravel.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.