---
title: "Symfony LSP en ligne de commande : ce que PHPStan ne verra jamais"
excerpt: "PHPStan en niveau max, extension Symfony activée, conteneur compilé sous les yeux : aucune erreur sur une application pourtant cassée en huit points. Le serveur LSP officiel de Symfony les trouve tous, sans éditeur, en une seconde. Tutoriel pas à pas avec dépôt de démonstration."
publishDate: 2026-09-12T00:00:00.000Z
tags: ["symfony", "php", "phpstan", "lsp", "qualite", "tutorial", "ci", "github-actions", "twig", "ia", "agents", "symfony-cli"]
canonical: "https://yoandev.co/symfony-lsp-ligne-de-commande"
---

*🧪 Tutoriel pas à pas. Toutes les commandes et tous les fichiers nécessaires pour reproduire intégralement le projet de démonstration. Testé le 12 septembre 2026 avec Symfony 8.1.6, PHP 8.4.5, PHPStan 2.2, Symfony CLI 5.20.0 et Symfony Language Tools 0.20.1 (bêta).*

## TL;DR

> 📌 **Ce que vous allez constater** :
> - PHPStan en `level: max`, avec l'extension officielle `phpstan-symfony` et le conteneur compilé : **aucune erreur** sur une application cassée en huit points
> - `symfony lsp:check` sur exactement la même application : **8 diagnostics**, 6 familles d'erreurs, code de sortie `10`
> - L'angle mort : noms de routes, chemins de templates, clés de traduction, options de formulaire. Rien de tout cela n'est vérifiable en lisant le code : l'information vit dans le conteneur compilé
> - Le serveur LSP boote l'application pour lire ses métadonnées réelles : environ une seconde ici, sans éditeur, avec des codes de sortie stables
> - Et donc : suppressions `@symfony-lsp-ignore`, baseline par empreinte, intégration continue, et branchement sur un agent de code
>
> **Dépôt de démonstration** : [yoanbernabeu/symfony-lsp-demo](https://github.com/yoanbernabeu/symfony-lsp-demo)

---

## 1. Le problème : ce qu'aucun analyseur statique ne peut voir

Votre agent de code écrit une fonctionnalité. PHPStan passe au vert. Les tests passent. Vous ouvrez la page : erreur 500. Le nom de route n'existe pas.

Ce n'est la faute d'aucun de vos outils. `path('checkout_confrim')` est une chaîne de caractères parfaitement valide dans un fichier Twig. L'information qui prouve que cette route n'existe pas ne se trouve nulle part dans votre code : elle vit dans le **conteneur compilé** de votre application.

C'est exactement la famille d'erreurs qu'un modèle de langage produit le plus, parce qu'il génère du **plausible**. `checkout_confrim` est plausible. `checkout.titel` est plausible. Et rien, dans une stack PHP classique, ne les attrape.

[Symfony Language Tools](https://github.com/symfony/language-tools), le serveur LSP officiel de Symfony annoncé le 17 août 2026, sait les voir, parce qu'il boote l'application et lit ses métadonnées réelles. Et surtout : il fonctionne **sans éditeur**, en ligne de commande, avec des codes de sortie stables.

Ce qui le rend pilotable par une CI. Et par un agent.

---

## 2. Prérequis et installation

- PHP 8.4 ou supérieur
- Composer 2
- Une machine Linux (x64 ou arm64) ou macOS Apple Silicon

### Installer le serveur

Une seule chose à vérifier : **Symfony CLI en version 5.20 ou supérieure**. La commande y est intégrée, et le CLI se charge de télécharger puis de mettre en cache l'exécutable du serveur au premier appel.

```bash
symfony version          # doit afficher 5.20.0 ou supérieur
symfony lsp:check --help
```

Si votre version est antérieure, mettez-la à jour (`symfony self:update`, ou votre gestionnaire de paquets si le CLI a été installé ainsi).

> Si le CLI affiche un avertissement sur l'emplacement de sa configuration (changement introduit en 5.17), suivez les instructions affichées : arrêtez les serveurs (`symfony server:stop --all`, `symfony proxy:stop`) puis déplacez le répertoire de configuration à l'emplacement indiqué. L'avertissement disparaîtra.

C'est tout ce dont vous avez besoin pour la suite.

---

## 3. Récupérer le projet de démonstration

Le terrain d'expérimentation tient en cinq fichiers. Plutôt que de vous les faire recopier, clonez-le :

```bash
git clone https://github.com/yoanbernabeu/symfony-lsp-demo.git
cd symfony-lsp-demo
composer install
php bin/console cache:warmup --env=dev
```

Aucune base de données n'est nécessaire. C'est un projet `symfony new --webapp` standard, Doctrine en fait donc partie, mais l'application ne s'y connecte jamais. Le serveur, qui la démarre pourtant réellement pour lire ses métadonnées, n'en a pas besoin non plus.

### Ce que contient le projet

Cinq fichiers applicatifs, le reste venant du squelette `symfony new --webapp` :

```
src/Controller/CheckoutController.php   2 routes, 1 render(), 1 redirectToRoute()
src/Form/CheckoutType.php               2 champs de formulaire
templates/checkout.html.twig            path(), trans, include (9 lignes)
templates/summary.html.twig             la cible de l'include
translations/messages.en.yaml           le catalogue
```

Un seul mérite d'être lu tout de suite, parce que c'est lui qui portera la moitié des erreurs :

**`templates/checkout.html.twig`**

```twig
<h1>{{ 'checkout.title'|trans({'%shop%': 'Demo Shop'}) }}</h1>

{{ form(form) }}

{% include 'summary.html.twig' %}

<a href="{{ path('checkout_confirm', { reference: 'ORD-1001' }) }}">
    {{ 'checkout.title'|trans({'%shop%': 'Demo Shop'}) }}
</a>
```

Neuf lignes qui font appel à quatre choses extérieures au fichier : une clé de traduction, un placeholder, un autre template, et une route avec son paramètre. **Aucune de ces quatre références n'est vérifiable en lisant ce fichier.**

> Le dépôt contient aussi une branche `broken`, qui porte déjà les erreurs que nous introduirons à la partie 7. Restez sur `main` : les introduire vous-même est plus instructif que de les découvrir toutes faites.

Le catalogue, lui, tient en trois lignes :

**`translations/messages.en.yaml`**

```yaml
checkout:
    title: 'Checkout on %shop%'
    summary: 'Order summary'
```

Le `%shop%` n'est pas décoratif : c'est lui qui permettra de démontrer le diagnostic `translation.placeholders`.

---

## 4. PHPStan, configuré au maximum

Le `composer install` de la partie précédente a déjà installé PHPStan, son extension officielle Symfony et l'`extension-installer` qui l'active. Un fichier `phpstan.dist.neon` est également fourni à la racine :

```neon
parameters:
    level: max
    paths:
        - src
    symfony:
        containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
    ignoreErrors:
        # Méthode fournie par le skeleton Symfony, hors du périmètre de cette démo
        -
            identifier: method.unused
            path: src/Kernel.php
```

Trois choix méritent d'être justifiés, parce que toute la démonstration repose dessus.

**`level: max`.** Le niveau le plus strict que PHPStan propose. Rien ne serait plus facile, et plus malhonnête, que de le brider pour le faire échouer.

**`phpstan-symfony`.** L'extension officielle, celle qui sait lire le conteneur compilé de l'application. Sans elle, on pourrait objecter que PHPStan est aveugle faute d'être outillé. Avec elle, l'objection tombe : il a accès aux mêmes métadonnées que le serveur LSP, et il ne verra pourtant rien.

**`containerXmlPath`.** C'est ce qui donne à l'extension l'accès au conteneur. Elle a donc besoin d'un cache chaud, d'où le `cache:warmup` de la partie 3, à refaire avant chaque analyse si la configuration change.

Vérifions que tout est vert au départ :

```bash
vendor/bin/phpstan analyse --no-progress
```

```
 [OK] No errors
```

---

## 5. Configurer le serveur avec `.symfony-lsp.json`

Le dépôt fournit également un `.symfony-lsp.json` à la racine, et il tient en trois lignes :

```json
{
    "version": 1,
    "translationDiagnostics": true
}
```

Cette unique option a son importance, parce qu'elle est **désactivée par défaut** : sans elle, le diagnostic `translation.not_found` n'est jamais émis. Une clé de traduction absente du catalogue passerait donc inaperçue, et cette démonstration perdrait un de ses huit diagnostics.

C'est le genre de réglage qu'on découvre après coup, en se demandant pourquoi l'outil ne signale pas une erreur qu'on voit à l'œil nu.

Le fichier accepte d'autres clés. Deux servent au quotidien : `excludePaths`, pour ignorer des répertoires (fixtures, code généré), et `environment`, qui indique dans quel environnement Symfony démarrer l'application.

```json
{
    "version": 1,
    "environment": "dev",
    "translationDiagnostics": true
}
```

Par défaut c'est `dev`, et `--environment=prod` en ligne de commande permet d'en changer le temps d'un appel. Ça compte : une route déclarée seulement en dev n'existe pas en prod, et les messages d'erreur le disent d'ailleurs tels quels, *does not exist in the selected environment*.

Le même fichier sert à l'éditeur, à la ligne de commande et à la CI. Une seule configuration à maintenir.

---

## 6. Premier passage de `symfony lsp:check`

```bash
symfony lsp:check
echo "code de sortie : $?"
```

Sortie attendue :

```
Project .: runtime metadata, environment dev, complete
Summary: 0 diagnostics, 0 active, 0 baseline matches, 0 stale baseline entries, 0 blocking
code de sortie : 0
```

La mention `runtime metadata ... complete` indique que l'application a bien été **bootée** et que ses métadonnées réelles ont été lues. C'est toute la différence avec un analyseur statique : il ne lit pas votre code, il interroge votre application en fonctionnement.

> **À savoir :** booter l'application, c'est exécuter du code PHP. L'aide de la commande le dit sans détour : `Runtime analysis executes application code. Use --source-only for untrusted code.` L'option `--source-only` s'en abstient, au prix de presque tous les diagnostics ; on mesurera ce que ça coûte à la partie 7, une fois l'application cassée.

Sur ce projet, l'analyse complète prend **une seconde environ** quand le cache est chaud, et jusqu'à 1,8 s au premier appel, le temps que le serveur compile le conteneur. Le démarrage de l'exécutable en prend déjà 150 ms à lui seul. On est sur une application `--webapp` complète ; un squelette nu descend bien en dessous.

Cet ordre de grandeur compte : à une seconde, on peut lancer la commande à chaque itération, pas seulement en fin de pipeline.

### Explorer la commande

```bash
# Les 31 codes de diagnostic supportés
symfony lsp:check --list-codes

# Cibler des chemins ou des motifs
symfony lsp:check src/ templates/
symfony lsp:check 'config/**/*.yaml'

# Sortie exploitable par une machine
symfony lsp:check --format=json

# Analyse sans exécuter l'application
symfony lsp:check --source-only

# Mesurer le temps passé par phase
symfony lsp:check --profile
```

### Les 31 codes de diagnostic

`--list-codes` les affiche par ordre alphabétique, sans les regrouper. Le classement par famille ci-dessous est le mien. On voit tout de suite que la démonstration n'en exercera qu'une petite partie :

| Famille | Codes |
|---|---|
| **Routes** | `route.not_found`, `route.missing_parameters` |
| **Templates** | `template.not_found` |
| **Traductions** | `translation.not_found`, `translation.domain_not_found`, `translation.placeholders` |
| **Twig** | `twig_callable.unknown_argument`, `twig_component.not_found` |
| **Formulaires et validation** | `form.unknown_option`, `validation.unknown_constraint_option` |
| **Conteneur** | `service.not_found`, `parameter.not_found` |
| **Configuration de bundles** | `config.unknown_key`, `config.deprecated_key`, `config.duplicate_key`, `config.invalid_type`, `config.malformed_structure` |
| **Variables d'environnement** | `env.unknown_processor`, `env.incompatible_type`, `env.malformed_chain` |
| **Messenger** | `messenger.unknown_bus`, `messenger.unknown_transport`, `messenger.invalid_handler_signature` |
| **Security** | `security.unknown_firewall`, `security.unknown_provider` |
| **Console** | `console.unknown_argument`, `console.unknown_option` |
| **Événements** | `event.invalid_listener_method` |
| **Frontend** | `stimulus.unknown_controller`, `importmap.unknown_entrypoint` |
| **Suppressions** | `suppression.invalid` |

Dernière ligne du tableau : l'outil vérifie aussi ses propres directives de suppression. On y reviendra à la partie 8.

### Les codes de sortie

C'est ce qui rend l'outil pilotable.

| Code | Signification |
|------|---------------|
| `0`  | aucun diagnostic bloquant |
| `10` | diagnostics bloquants |
| `11` | invocation ou configuration invalide |
| `12` | analyse incomplète (indexation, délai dépassé, échec de processus) |

La distinction entre `10` d'une part et `11`/`12` d'autre part est capitale dès qu'on automatise : `10` signale une erreur **dans le code**, `11` et `12` signalent un problème **d'outillage**. Un agent, ou un script, ne peut rien faire du second cas. Le traiter comme un échec de code mène à un acharnement stérile.

---

## 7. Casser l'application, et regarder qui s'en aperçoit

On introduit maintenant **six familles d'erreurs**, en huit occurrences. Elles se répartissent en deux natures bien distinctes :

- **l'existence** : la route, le template, la clé ou l'option n'existe pas ;
- **la cohérence d'appel** : l'objet existe, mais il est invoqué de travers.

La seconde catégorie est la plus intéressante : elle prouve que le serveur ne se contente pas de vérifier des noms dans un index, il connaît le *contrat* de ce qu'il indexe.

```bash
# --- EXISTENCE ---

# Famille 1 : route inexistante, en PHP
sed -i '' "s|redirectToRoute('checkout_index')|redirectToRoute('checkout_indx')|" src/Controller/CheckoutController.php

# Famille 2 : template inexistant, dans un render() PHP
sed -i '' "s|render('checkout.html.twig'|render('chekout.html.twig'|" src/Controller/CheckoutController.php

# Famille 2 : template inexistant, dans un include Twig
sed -i '' "s|include 'summary.html.twig'|include 'sumary.html.twig'|" templates/checkout.html.twig

# Famille 3 : clé de traduction inexistante
sed -i '' "8s|'checkout.title'|'checkout.titel'|" templates/checkout.html.twig

# Famille 4 : option de formulaire inconnue (2 champs)
sed -i '' "s|'required' => true|'requiered' => true|g" src/Form/CheckoutType.php

# --- COHÉRENCE D'APPEL ---

# Famille 5 : la route existe, mais son paramètre obligatoire est omis
sed -i '' "s|path('checkout_confirm', { reference: 'ORD-1001' })|path('checkout_confirm')|" templates/checkout.html.twig

# Famille 6 : la clé existe, mais le placeholder fourni n'est pas le bon
sed -i '' "1s|{'%shop%': 'Demo Shop'}|{'%boutique%': 'Demo Shop'}|" templates/checkout.html.twig
```

> Sous Linux, remplacez `sed -i ''` par `sed -i` (sans les guillemets vides).

### Le verdict

```bash
rm -rf var/cache/dev
php bin/console cache:warmup --env=dev

vendor/bin/phpstan analyse --no-progress
```

```
 [OK] No errors
```

**PHPStan, niveau maximum, extension Symfony activée, conteneur compilé sous les yeux : aucune erreur.** L'application est pourtant cassée en huit points, répartis sur trois fichiers et six familles distinctes.

```bash
symfony lsp:check
echo "code de sortie : $?"
```

```
Project .: runtime metadata, environment dev, complete
.:src/Controller/CheckoutController.php:15:31: error [template.not_found] Template "chekout.html.twig" does not exist in the selected environment.
.:src/Controller/CheckoutController.php:23:40: error [route.not_found] Route "checkout_indx" does not exist in the selected environment.
.:src/Form/CheckoutType.php:18:54: error [form.unknown_option] Unknown option "requiered" for form type "Symfony\Component\Form\Extension\Core\Type\TextType".
.:src/Form/CheckoutType.php:19:48: error [form.unknown_option] Unknown option "requiered" for form type "Symfony\Component\Form\Extension\Core\Type\EmailType".
.:templates/checkout.html.twig:1:9: error [translation.placeholders] Translation placeholders do not match the message.
.:templates/checkout.html.twig:5:13: error [template.not_found] Template "sumary.html.twig" does not exist in the selected environment.
.:templates/checkout.html.twig:7:19: error [route.missing_parameters] Route "checkout_confirm" requires parameter "reference".
.:templates/checkout.html.twig:8:9: error [translation.not_found] Translation "checkout.titel" does not exist in domain "messages".
Summary: 8 diagnostics, 8 active, 0 baseline matches, 0 stale baseline entries, 8 blocking
code de sortie : 10
```

Huit diagnostics, chacun avec son fichier, sa ligne et sa colonne. Et un code de sortie exploitable par une machine.

Regardez les deux derniers de près :

```
route.missing_parameters  Route "checkout_confirm" requires parameter "reference".
translation.placeholders  Translation placeholders do not match the message.
```

La route existe. La clé de traduction existe. Aucun index de noms ne les aurait signalées. Pour les voir, il faut connaître la **signature** de la route et le **corps** du message traduit, c'est-à-dire avoir booté l'application.

### La même chose, en JSON

Si la commande doit être lue par un script ou un agent, c'est `--format=json` qu'on lui donnera :

```bash
symfony lsp:check --format=json
```

À la racine : `schemaVersion`, `tool`, `complete`, `coordinates`, `projects`, `diagnostics`, `baseline`, `summary` et `errors`. Voici le diagnostic du `render()` cassé, complet :

```json
{
  "project": ".",
  "path": "src/Controller/CheckoutController.php",
  "workspacePath": "src/Controller/CheckoutController.php",
  "range": {
    "start": { "line": 14, "character": 30 },
    "end":   { "line": 14, "character": 47 }
  },
  "severity": "error",
  "code": "template.not_found",
  "source": "symfony",
  "message": "Template \"chekout.html.twig\" does not exist in the selected environment.",
  "baseline": "active",
  "provenance": {
    "feature": "template",
    "provider": "template",
    "environment": "dev",
    "analysisMode": "runtime"
  }
}
```

> ⚠️ **Avant de parser quoi que ce soit** : ce diagnostic est celui que la sortie humaine affichait en `15:31`. Ici, il est en `line: 14, character: 30`. **Le JSON compte à partir de 0, la sortie humaine à partir de 1.** L'objet `coordinates` de la racine le précise (`{"lineBase": 0, "characterBase": 0, "characterEncoding": "utf-16", "endExclusive": true}`), mais on ne le lit en général qu'après avoir cherché un moment pourquoi le curseur tombe une ligne trop haut.

Il y a aussi `provenance.analysisMode`, à `runtime` ici : chaque diagnostic indique d'où vient son information. Ça servira juste après.

### Ce que coûte de ne pas exécuter l'application

C'est le bon moment pour mesurer l'option `--source-only` évoquée à la partie 6 :

```bash
symfony lsp:check --source-only
```

```
Project .: source-only (runtime indexing is disabled), environment dev, complete
.:templates/checkout.html.twig:1:9: error [translation.placeholders] Translation placeholders do not match the message.
Summary: 1 diagnostics, 1 active, 0 baseline matches, 0 stale baseline entries, 1 blocking
```

**Un seul diagnostic sur huit.** Sans exécuter l'application, le serveur ne peut plus rien affirmer sur les routes, les templates ni les services : ces informations n'existent que dans le conteneur compilé.

Le survivant est instructif. `translation.placeholders` tient parce qu'il se déduit entièrement des sources : le catalogue YAML d'un côté, l'appel `trans()` de l'autre, une comparaison de chaînes entre les deux. Aucun boot nécessaire.

L'outil le dit d'ailleurs lui-même. En JSON, ce diagnostic passe à `"analysisMode": "source-only"`, et l'objet du projet le répète à deux endroits :

```json
"analysis": { "mode": "source-only", "reason": "runtime-indexing-disabled" },
"runtime":  { "state": "disabled",   "reason": "runtime-indexing-disabled" }
```

> Attention à `complete`, qui reste à `true` en `--source-only` alors que sept diagnostics sur huit ont disparu. Ce champ veut dire « l'analyse est allée au bout sans erreur », pas « l'analyse a tout vu ». Pour savoir ce que l'outil a réellement regardé, c'est `analysisMode` qu'il faut lire.

`--source-only` n'est donc pas un mode dégradé, c'est un mode presque aveugle. À réserver au code auquel vous ne faites pas confiance.

### Et ce que coûte de l'exécuter

Pour mettre un chiffre en face, `--profile` détaille le temps par phase, puis par section du framework :

```
Total                                    849.3 ms
Phases:
  Executable startup                       148.6 ms
  Project analysis                         665.3 ms
  Diagnostics                               29.0 ms
Projects:
  Project . (51 files)                     694.0 ms
    Source indexing                            8.3 ms
    Runtime indexing                         655.0 ms
      Application bridge                       533.0 ms
      Runtime sections:
        messenger                                205.7 ms
        container                                 68.2 ms
        console                                   50.9 ms
        configuration                             37.3 ms
        metadata                                  36.3 ms
        routes                                    25.5 ms
        events                                    17.2 ms
        doctrine                                   9.1 ms
        twig                                       7.5 ms
        environment                                5.5 ms
```

L'indexation runtime coûte 655 ms, celle des sources 8,3 ms. C'est le prix des sept diagnostics qui manquaient à `--source-only`.

Et `messenger` pèse 205 ms alors que le projet n'utilise pas Messenger. C'est le pack `--webapp` qu'on paie là, pas le code de démonstration. Sur une application plus légère, la note baisse.

> **Gardez l'application dans cet état** : les parties 8 et 9 s'appuient sur ces huit diagnostics. C'est un projet jetable, il n'y a rien à remettre en état ensuite.

### Deux erreurs volontairement absentes

Pendant la construction de cette démonstration, deux autres candidats ont été testés puis écartés, d'où l'absence de Messenger et de tout paramètre de conteneur dans le projet final :

- un transport Messenger inexistant (`asyncc`)
- un paramètre de conteneur inexistant (`%app.shop_nam%`)

Dans les deux cas, **Symfony refuse lui-même de compiler le conteneur**, avec un message clair. Ce ne sont pas des angles morts : le framework les signale déjà, sans aucun outil tiers. Les retenir aurait gonflé artificiellement la démonstration, et alourdi le projet de quatre fichiers inutiles.

Le serveur a pourtant des codes pour ça, `messenger.unknown_transport` et `parameter.not_found` sont dans le tableau de la partie 6. Mais ici la compilation du conteneur échoue avant qu'il ait pu les émettre. Ils servent quand le conteneur compile et que l'erreur n'apparaît qu'à l'exécution.

---

## 8. Faire taire un diagnostic avec `@symfony-lsp-ignore`

Il arrive qu'une erreur soit intentionnelle : une fixture qui référence un template absent, un test qui vise une route inexistante. Le serveur prévoit deux mécanismes pour ça, et ils ne disent pas la même chose.

### La suppression sur place

Un commentaire **natif** (PHP, Twig, YAML ou XML) placé au-dessus de la ligne fautive. Dans notre template, l'`include` cassé est en ligne 5 :

```bash
sed -i '' "5i\\
{# @symfony-lsp-ignore template.not_found (template volontairement absent) #}
" templates/checkout.html.twig

symfony lsp:check
```

Le fichier contient maintenant :

```twig
{# @symfony-lsp-ignore template.not_found (template volontairement absent) #}
{% include 'sumary.html.twig' %}
```

Et les huit diagnostics sont devenus **sept** : le `template.not_found` de l'`include` a disparu du rapport. (L'`include` est passé en ligne 6, puisque la directive occupe désormais la ligne 5.)

La forme PHP est identique, dans un commentaire de bloc ou de ligne :

```php
/* @symfony-lsp-ignore template.not_found (template volontairement absent) */
$this->render('test/does_not_exist.html.twig');
```

> Cet extrait PHP est une **illustration de syntaxe**, pas une étape du tutoriel : ne le collez pas dans le projet, il ajouterait un neuvième diagnostic et fausserait tous les compteurs qui suivent.

Quelques règles à connaître :

- La directive est **qualifiée par code**. Elle ne fait taire que ce diagnostic précis, pas toute la ligne.
- Un code cité supprime **une** occurrence. Répétez-le pour en supprimer plusieurs sur la même ligne.
- La raison entre parenthèses est facultative, mais c'est tout l'intérêt : elle documente l'intention là où elle se lit.
- Seuls les **commentaires natifs** comptent. Une directive dans une chaîne, dans un bloc `verbatim` Twig, dans un scalaire YAML ou dans une section CDATA n'a aucun effet.

### Ce qui se passe quand la directive est fausse

Remplacez le code de la directive par un code qui n'existe pas :

```bash
sed -i '' 's|@symfony-lsp-ignore template.not_found|@symfony-lsp-ignore template.pas_un_code|' templates/checkout.html.twig
symfony lsp:check
```

```
.:templates/checkout.html.twig:5:4: warning [suppression.invalid] Unknown diagnostic code "template.pas_un_code" in suppression.
Summary: 9 diagnostics, 9 active, 0 baseline matches, 0 stale baseline entries, 8 blocking
```

Deux choses à noter ici.

D'abord, **une suppression invalide ne passe pas inaperçue** : elle produit elle-même un diagnostic. Impossible de croire qu'on a fait taire une erreur alors qu'on a fait une faute de frappe.

Ensuite, regardez les compteurs : **9 diagnostics, mais 8 bloquants**. Ce `suppression.invalid` est un *warning*, pas une *error*. C'est la première fois que les deux chiffres du résumé divergent, et c'est pour ça qu'il y en a deux. Le code de sortie, lui, ne dépend que des bloquants.

### Retirer la directive avant de continuer

La partie suivante repart des huit diagnostics d'origine. Supprimez la ligne ajoutée :

```bash
sed -i '' '/@symfony-lsp-ignore/d' templates/checkout.html.twig
symfony lsp:check          # 8 diagnostics, code de sortie 10
```

---

## 9. Adopter l'outil sur un projet existant : la baseline

La directive de la partie précédente traite les cas à l'unité. Sur une vraie base de code, la première exécution renvoie souvent des centaines de diagnostics : personne ne va les annoter un par un, et personne ne va tout corriger avant de pouvoir commencer à s'en servir.

C'est à ça que sert la baseline : un instantané de la dette existante, écrit dans un fichier `.symfony-lsp-baseline.json` à la racine du projet, que le checker consultera ensuite pour ne plus signaler que les nouveautés.

```bash
# Fige l'existant dans .symfony-lsp-baseline.json
symfony lsp:check --generate-baseline

# Ne bloque plus que sur les nouvelles erreurs
symfony lsp:check --baseline=.symfony-lsp-baseline.json
```

> `--generate-baseline` refuse d'écraser un fichier existant : il sort en code `11` avec `The baseline ... already exists; use --refresh-baseline to replace it.` Pour régénérer, c'est `--refresh-baseline`.

Les huit diagnostics restent affichés, mais suffixés `(baseline)` :

```
Project .: runtime metadata, environment dev, complete
.:src/Controller/CheckoutController.php:15:31: error [template.not_found] Template "chekout.html.twig" does not exist in the selected environment. (baseline)
.:src/Controller/CheckoutController.php:23:40: error [route.not_found] Route "checkout_indx" does not exist in the selected environment. (baseline)
... les six autres, également suffixés (baseline) ...
Summary: 8 diagnostics, 0 active, 8 baseline matches, 0 stale baseline entries, 0 blocking
```

Ne vous laissez pas surprendre par ces huit lignes d'erreur : elles sont toujours détectées et toujours rapportées, mais **aucune n'est active**, et le code de sortie retombe à `0`. La baseline ne masque pas les problèmes, elle les met hors du champ de ce qui bloque.

### Ce que contient réellement le fichier

C'est là que ça devient intéressant. Ouvrez-le : il contient une entrée par diagnostic, donc huit ici. En voici une, celle de la route inexistante :

```json
{
    "version": 1,
    "diagnostics": [
        {
            "project": ".",
            "path": "src/Controller/CheckoutController.php",
            "code": "route.not_found",
            "severity": "error",
            "source": "symfony",
            "message": "Route \"checkout_indx\" does not exist in the selected environment.",
            "fingerprint": "e7caa78ca135b6b026a482636064b600ff56205d994237a6a7327b6809cd9b09",
            "occurrence": 1
        }
    ]
}
```

**Aucun numéro de ligne. Aucune colonne.** Une empreinte SHA-256 et un rang d'occurrence, c'est tout.

La conséquence est très concrète : ajoutez une ligne en haut d'un fichier, tous les numéros de ligne se décalent, et la baseline continue de fonctionner.

```bash
# Sauvegarde, puis on décale tout le template d'une ligne
cp templates/checkout.html.twig /tmp/checkout.bak
{ echo; cat templates/checkout.html.twig; } > /tmp/t && mv /tmp/t templates/checkout.html.twig

symfony lsp:check --baseline=.symfony-lsp-baseline.json
# Summary: 8 diagnostics, 0 active, 8 baseline matches, ... 0 blocking

# On remet le fichier en place
cp /tmp/checkout.bak templates/checkout.html.twig
```

Toujours huit correspondances. C'est le sens de l'expression *occurrence-specific baseline* employée par la documentation : le fichier identifie un diagnostic par sa nature et son contenu, pas par sa position. Un `php-cs-fixer` qui reformate tout le projet ne fait donc pas exploser votre baseline, ce qui est loin d'être le cas de tous les outils du genre.

Le champ `occurrence` traite le cas où le même diagnostic exact apparaît plusieurs fois : chaque instance a son rang.

### L'empreinte survit aussi aux mises à jour du serveur

Autre garantie, moins visible : l'empreinte ci-dessus, `e7caa78c…9b09`, est **la même en 0.19.0 et en 0.20.1**. Mettre à jour le serveur ne casse pas la baseline.

Sur un outil en bêta qui sort une version tous les deux jours, ce n'est pas un détail. Si la formule d'empreinte changeait d'une version à l'autre, il faudrait tout regénérer à chaque montée de version, et la baseline ne voudrait plus rien dire.

### Entretenir la baseline

```bash
# Après avoir corrigé un lot, régénère le fichier
symfony lsp:check --refresh-baseline

# Échoue si des entrées sont devenues obsolètes
symfony lsp:check --baseline=.symfony-lsp-baseline.json --strict-baseline
```

Une entrée devient **stale** quand le diagnostic qu'elle couvre n'existe plus : vous avez corrigé le code, ou vous l'avez neutralisé avec une directive `@symfony-lsp-ignore`. Sans `--strict-baseline`, ces entrées mortes s'accumulent en silence et le fichier finit par ne plus rien vouloir dire. Avec, la CI vous force à faire le ménage.

Trois précisions qui font gagner du temps :

- Le fichier vit à la racine et **a vocation à être versionné** : c'est un contrat d'équipe, pas un artefact temporaire.
- Il n'est **pas** appliqué automatiquement. Un `symfony lsp:check` nu l'ignore complètement ; il faut passer `--baseline=` explicitement, y compris en CI.
- `--strict-baseline` **exige** `--baseline=`. Seul, il échoue avec le code `11`.

### Baseline ou suppression ?

Les deux mécanismes font taire un diagnostic, mais ils n'affirment pas la même chose, et ils se connaissent.

| | Baseline | `@symfony-lsp-ignore` |
|---|---|---|
| Ce que ça dit | « je constate cette dette et je compte la traiter » | « cette erreur est intentionnelle et le restera » |
| Où | un fichier séparé, à la racine | dans le code, collé à la ligne |
| Durée de vie | temporaire, on la régénère | permanente |
| Portée | tout l'existant d'un coup | une occurrence d'un code |
| Justification | aucune | une raison entre parenthèses |

Un diagnostic neutralisé par directive n'est **pas écrit** dans une nouvelle baseline. Et si une entrée de baseline existante couvre un diagnostic désormais supprimé, elle devient stale. Les deux ne se marchent donc jamais dessus.

En pratique : la baseline pour la dette qu'on hérite, la directive pour les cas qu'on assume.

### Bloquer sur une partie des codes seulement

Autre stratégie d'adoption progressive, cumulable avec la baseline :

```bash
symfony lsp:check --fail-on=route.not_found,template.not_found
```

```
Summary: 8 diagnostics, 8 active, 0 baseline matches, 0 stale baseline entries, 3 blocking
```

Les huit sont toujours rapportés, trois seulement font échouer la commande. Pratique pour ouvrir le robinet code par code sur une grosse base.

> La baseline générée plus haut ne gêne pas la suite : un `symfony lsp:check` nu l'ignore, il faut la lui passer explicitement. Vous pouvez néanmoins la supprimer si vous préférez repartir propre : `rm -f .symfony-lsp-baseline.json`.

### Ce que le serveur ne peut pas savoir

Dernier point avant la CI, et sans doute le plus important si vous comptez l'adopter sur un vrai projet : le serveur **ne dit rien sur ce qui n'est pas écrit en dur**.

```twig
{{ path('checkout_' ~ suffixe) }}                → aucun diagnostic
{{ path('nexiste_pas_' ~ suffixe) }}             → aucun diagnostic
{{ cle|trans }}                                  → aucun diagnostic
{% include 'checkout/' ~ nom ~ '.html.twig' %}   → aucun diagnostic
```

```php
$this->render($template);                              // template en variable → aucun diagnostic
$builder->add('email', $type, ['requiered' => true]);  // type de champ en variable → aucun diagnostic
```

Le deuxième cas est le plus parlant. Le préfixe `nexiste_pas_` ne correspond à aucune route et le serveur ne tente rien, alors que dans le même fichier un `path('checkout_confrim')` écrit en dur déclenche bien son `route.not_found`. Ce n'est pas une limite de l'analyse, c'est un choix : la documentation parle de *skips dynamic expressions*, et l'annonce officielle revendique zéro faux positif.

Même règle pour les options de formulaire : le serveur ne les vérifie que s'il peut lire le type du champ en dur. Un type passé en variable ou construit par concaténation, ce que le changelog de la 0.20.0 appelle *computed or concatenated form type expressions*, n'est pas analysé, même dans une classe `AbstractType`. Un formulaire monté à la volée avec `createFormBuilder()` ne l'est pas non plus, faute de classe de formulaire à laquelle rattacher les options.

Il faut le savoir avant de se lancer : **pas de bruit, mais pas de couverture non plus sur les appels dynamiques.** Pour un outil qui tourne en CI c'est le bon choix, un analyseur qui se trompe trop souvent finit désactivé. Et les chaînes écrites en dur sont justement celles qu'un modèle de langage produit.

---

## 10. Intégrer `symfony lsp:check` dans une CI GitHub Actions

Le checker n'a pas besoin d'éditeur, et il renvoie des codes de sortie stables : il est donc directement utilisable dans un pipeline. Le dépôt que vous avez cloné en contient un. Rien à mettre en place : c'est un modèle à lire.

### `.github/workflows/ci.yaml`

```yaml
name: CI

on:
    push:
    pull_request:

env:
    SYMFONY_LSP_VERSION: v0.20.1

jobs:
    phpstan:
        name: PHPStan (niveau max)
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v5

            - uses: shivammathur/setup-php@v2
              with:
                  php-version: '8.4'
                  coverage: none

            - run: composer install --no-progress --no-interaction

            - name: Compile le conteneur (requis par phpstan-symfony)
              run: php bin/console cache:warmup --env=dev

            - run: vendor/bin/phpstan analyse --no-progress

    symfony-lsp:
        name: Symfony LSP
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v5

            - uses: shivammathur/setup-php@v2
              with:
                  php-version: '8.4'
                  coverage: none

            - run: composer install --no-progress --no-interaction

            - name: Installe symfony-lsp
              run: |
                  ARCHIVE="symfony-lsp-${SYMFONY_LSP_VERSION}-linux-x64.tar.gz"
                  BASE="https://github.com/symfony/language-tools/releases/download/${SYMFONY_LSP_VERSION}"
                  curl -sSfL -o "$ARCHIVE" "$BASE/$ARCHIVE"
                  curl -sSfL -o SHA256SUMS "$BASE/SHA256SUMS"
                  grep " $ARCHIVE\$" SHA256SUMS | sha256sum -c -
                  tar xzf "$ARCHIVE"
                  dirname "$(find . -maxdepth 2 -type f -name symfony-lsp -perm -u+x | head -1)" >> "$GITHUB_PATH"

            - name: Diagnostics Symfony
              run: symfony-lsp check --format=github
```

Le format `github` produit des annotations directement sur les lignes fautives dans l'interface de la pull request. La branche `broken` du dépôt le montre en vrai : le job PHPStan y est vert, le job Symfony LSP rouge. C'est la partie 7, rejouée par la forge.

Les deux jobs ne sont pas symétriques. Celui de PHPStan a une étape « Compile le conteneur », celui du serveur LSP n'en a pas, et ce n'est pas un oubli : le serveur compile le conteneur lui-même en démarrant l'application. Sur un cache vide, `symfony lsp:check` sort ses huit diagnostics, et ça lui coûte 0,3 à 0,8 s de plus. PHPStan, dans les mêmes conditions, ne trouve pas moins d'erreurs, il ne démarre pas :

```
In SymfonyContainerResultCacheMetaExtension.php line 46:
  Container .../var/cache/dev/App_KernelDevDebugContainer.xml does not exist
```

L'un a besoin qu'on lui prépare le conteneur, l'autre le fabrique. C'est la même différence qu'à la partie 7, vue depuis le pipeline.

**Pourquoi `symfony-lsp check` et pas `symfony lsp:check` ici ?** Le serveur est aussi distribué en binaire autonome, téléchargeable depuis les [releases GitHub](https://github.com/symfony/language-tools/releases). Sa commande `symfony-lsp check` accepte les mêmes options que `symfony lsp:check` et produit la même sortie, c'est le même programme, invoqué directement plutôt qu'à travers le CLI.

L'intérêt en CI est de pouvoir **épingler une version**. Le Symfony CLI télécharge « la dernière version stable compatible » : parfait sur votre poste, discutable dans un pipeline, où une nouvelle publication de l'outil peut faire virer la CI au rouge sans que personne n'ait touché au code.

Si vous préférez la simplicité au déterminisme, `tools: symfony-cli` dans `setup-php` puis `symfony lsp:check --format=github` fonctionne tout aussi bien.

### Autres formats

GitLab et les systèmes de code scanning ont chacun le leur :

```bash
symfony lsp:check --format=gitlab > gl-code-quality-report.json
symfony lsp:check --format=sarif  > symfony-lsp.sarif
```

> Ces deux fichiers sont écrits à la racine du projet et ne sont pas couverts par le `.gitignore` du skeleton. Pensez à faire le ménage après les avoir inspectés : `rm -f gl-code-quality-report.json symfony-lsp.sarif`.

---

## 11. Brancher la commande sur un agent de code

Ce n'est pas l'objet de ce tutoriel, mais la commande a trois propriétés qui la rendent intéressante à brancher sur un agent :

- **elle est vérifiable** : l'agent n'a pas à juger si son travail est correct, il lance une commande et lit un entier ;
- **elle est rapide** : une seconde ici, moins sur un projet plus léger, trop peu pour justifier de la sauter ;
- **elle distingue l'erreur de code de la panne d'outillage** : `10` signale du code à corriger, `11` et `12` un problème d'environnement sur lequel l'agent ne peut rien. Un agent qu'on ferait boucler sur un cache corrompu s'acharnerait à « corriger » du code parfaitement valide.

C'est d'autant plus pertinent que les erreurs détectées sont exactement celles qu'un modèle de langage produit le plus : des chaînes plausibles qui ne correspondent à rien.

Plusieurs manières de s'en servir existent, de la plus souple à la plus contraignante : l'ajouter aux instructions du projet, la câbler dans un hook de l'agent pour qu'elle ne dépende plus de sa bonne volonté, ou la laisser en intégration continue comme dernier filet. Symfony 8.1 génère d'ailleurs nativement un `AGENTS.md` et un `CLAUDE.md` : le fichier d'instructions destiné aux agents fait désormais partie du framework.

Chacune de ces approches mérite son propre article. Ce qu'il faut retenir ici, c'est que la commande s'y prête sans effort d'adaptation : elle a été conçue pour être lue par une machine autant que par un humain.

---

## 12. Ce qu'il faut retenir

Symfony Language Tools est en **bêta** : version 0.20.1 au moment où j'écris, quatorze versions en moins de quatre semaines depuis l'annonce. La 0.20.1 est d'ailleurs sortie pendant la rédaction de cet article. J'ai tout rejoué : pas un chiffre n'a bougé.

Il ne remplace ni PHPStan, ni vos tests. Il couvre l'angle mort entre les deux : tout ce que votre code référence par une chaîne de caractères et que seul le conteneur compilé peut valider.

Détail qui ne manque pas de sel : Fabien Potencier a annoncé publiquement que le code de Symfony Language Tools avait été écrit et relu par des modèles d'IA. Un outil écrit par des agents, dont l'un des meilleurs usages consiste à encadrer des agents.

L'enseignement dépasse Symfony : **un agent ne se pilote pas avec des bonnes intentions, mais avec un retour déterministe et lisible par une machine, branché au moment précis où il croit avoir terminé.**

---

## Références

- [Le dépôt de démonstration de cet article](https://github.com/yoanbernabeu/symfony-lsp-demo)
- [Symfony Language Tools](https://github.com/symfony/language-tools)
- [Annonce officielle](https://symfony.com/blog/announcing-symfony-language-tools-the-official-symfony-lsp-server)
- [lsp:check en CI](https://symfony.com/blog/introducing-symfony-lsp-check-symfony-aware-diagnostics-in-your-ci)
- [Une première semaine chargée](https://symfony.com/blog/symfony-language-tools-a-busy-first-week)
- [Diagnostics headless (documentation)](https://github.com/symfony/language-tools/blob/main/docs/features/headless-diagnostics.rst)

---

<small>*Cet article a été écrit avec l'assistance d'une IA. Dans un harnais soigneusement réglé, évidemment.*</small>
