Short version for the impatient: PHPStan can’t see a typo in a route name, and neither can your tests unless you happen to hit that exact redirect. Symfony just shipped a CLI command that can. If you want to know why I care, and what I do about it in Laravel where no such command exists, read on.
Here’s the thing I got wrong for about a year. I had a Symfony project at level 8 PHPStan, PHP-CS-Fixer on every commit, a test suite that took eleven minutes, and I still shipped redirectToRoute('order_confirmaton') to production. One missing “i”. PHPStan was happy because a string is a string. The test that covered checkout happened to assert on the response status of the form submission, not the redirect target. It sat there for three days until a customer emailed to say the thank-you page was a 500.
That bug is what the new symfony lsp:check command is for, and I’ve been waiting for someone to build it since roughly 2018.
The strings your type checker treats as noise
A Symfony app is full of strings that mean something to the framework and nothing to PHP. Route names. Template paths. Translation keys. Service ids. Config keys in YAML. Twig filter arguments. Every one of them is a plain string to the parser, so every one of them survives static analysis, code review, and usually the test suite too.
Fabien Potencier’s announcement post puts it plainly: your CI verifies types with PHPStan or Psalm, style with PHP-CS-Fixer, behaviour with PHPUnit, and nothing warns you about the typo in a route name. Even the PHPStan Symfony extension, which knows about the container, works at the type level inside PHP files. Templates, translations, routes and config files are out of its reach.
I’d add a fourth category the post only touches on: config that is valid YAML and invalid Symfony. I once spent an afternoon on a framework.cache.pools block that was indented one level too deep. YAML parsed it fine. Symfony silently ignored it. My “cached” query ran uncached for weeks and I only noticed when the database CPU graph looked wrong.
What lsp:check does
The command is a headless version of the diagnostics the official Symfony Language Tools already run in VS Code, Zed and Neovim. Same engine, no editor. You run it from the project root and it reports Symfony-specific problems only:
$ symfony lsp:check
Project .: runtime metadata, environment dev, complete
.:src/Controller/CheckoutController.php:14:40: error [route.not_found] Route "order_confirmaton" does not exist in the selected environment.
.:templates/checkout/confirmation.html.twig:2:13: error [template.not_found] Template "checkout/summary.html.twig" does not exist in the selected environment.
Summary: 2 diagnostics, 2 active, 0 baseline matches, 0 stale baseline entries, 2 blocking
That first line is my bug, caught in about two seconds, with a file and column number.
There are 30 diagnostic codes at launch. Unknown routes and missing required route params. Missing templates and Twig components. Missing translation keys. Unknown services and parameters. Invalid bundle config keys and enum values. Unknown Messenger buses and transports. Unknown security firewalls. Unknown form options. You can list them with symfony lsp:check --list-codes.
The part that matters most is that runtime analysis is on by default. The checker boots your app in the selected environment and reads the real route collection, the real container, the real bundle config. It isn’t guessing from naming conventions. If your routes come from attributes plus a YAML file plus a bundle you forgot about, it sees all of them. If you can’t execute application code in CI for policy reasons, --source-only falls back to static inspection, and the report tells you which mode ran so you don’t mistake a partial pass for a clean one.
It ships with Symfony CLI 5.20 or newer, so if you already use symfony serve you probably have it. Otherwise grab the symfony-lsp binary from the language-tools releases page and run symfony-lsp check. Same flags either way.
Putting it in CI without breaking every existing project
The obvious problem with adding a new checker to an old codebase is that the first run reports two hundred things and the team turns it off by Friday. Symfony borrowed the answer from PHPStan: a baseline.
$ symfony lsp:check --generate-baseline
$ symfony lsp:check --baseline=.symfony-lsp-baseline.json
Baseline matches stay visible in the report but don’t block. New findings do. The baseline is occurrence-specific and survives unrelated line movement, which PHPStan’s older baseline format was bad at. Add --strict-baseline once you’re confident and CI will fail if a stale entry is still listed after someone fixed the underlying bug.
You can also pick which codes block without hiding the rest:
$ symfony lsp:check --fail-on=route.not_found,template.not_found
Unknown codes are rejected, so a renamed diagnostic can’t quietly weaken your policy. I like that detail more than I expected to.
For GitHub Actions the whole job is about a dozen lines, straight from the announcement post:
name: Symfony diagnostics
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
tools: symfony-cli
- run: composer install --no-progress
- run: symfony lsp:check --format=github
--format=github emits workflow annotations on the exact file and line in the pull request diff. There’s also --format=sarif if your org uses code scanning, and --format=json for anything else. Exit codes are sensible: 0 clean, 10 blocking findings, 11 bad invocation, 12 the checker itself failed. That last one matters. A crash in one diagnostic provider doesn’t discard the others, but it does flip the exit to 12 so CI won’t treat a half-finished report as green. I’ve been burned by linters that exit 0 when they crash. This one doesn’t.
If you’ve already got a reusable workflow setup like the one I described in my post on GitHub Actions reusable workflows, this slots in as one more job that runs after composer install and before tests.
The bit aimed at coding agents
There’s a section in the announcement titled “Built for Agents Too” and I think it’s the real reason this shipped now rather than three years ago.
Coding agents work the way CI works. No editor attached, no squiggly red underline, just a terminal and whatever validation commands you give them. An agent that invents a route name (they do this constantly, they’ll write route('orders.confirm') because it sounds right) has no way of knowing it’s wrong unless something tells it. PHPStan won’t. The tests might, if you’re lucky. symfony lsp:check will, deterministically, in the same format every time, which is exactly what an agent needs in its loop.
I’ve been giving Claude Code a similar check as a post-edit hook on my Symfony projects for a few months, hand-rolled and flaky. Replacing that with an official command that the framework maintainers keep in sync with each release is a straight upgrade. I wrote about a related workflow in my Laravel security audit post, and the same principle applies: the agent is only as good as the feedback you wire into it.
Laravel doesn’t have this, so here’s what I do
Most of my client work is Laravel, and I want to be honest that there’s no equivalent command. Larastan does great work on Eloquent types and container resolution, but I’ve never found a rule in it that validates the string you pass to route() or view() against what actually exists. If someone knows one, I’d like to hear about it.
Until then I keep a test that does the dumb thing on purpose: grep the codebase for route and view calls, then ask the framework whether each one resolves.
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Symfony\Component\Finder\Finder;
use Tests\TestCase;
class FrameworkStringsTest extends TestCase
{
public function test_every_named_route_exists(): void
{
$missing = [];
foreach ($this->scan("/\broute\(\s*'([a-z0-9_.\-]+)'/i") as $file => $names) {
foreach ($names as $name) {
if (! Route::has($name)) {
$missing[] = "$file: route('$name')";
}
}
}
$this->assertSame([], $missing, implode("\n", $missing));
}
public function test_every_view_exists(): void
{
$missing = [];
foreach ($this->scan("/\bview\(\s*'([a-z0-9_.\-]+)'/i") as $file => $names) {
foreach ($names as $name) {
if (! View::exists($name)) {
$missing[] = "$file: view('$name')";
}
}
}
$this->assertSame([], $missing, implode("\n", $missing));
}
private function scan(string $pattern): array
{
$found = [];
$finder = (new Finder())->files()->in([app_path(), resource_path('views')])->name(['*.php']);
foreach ($finder as $file) {
if (preg_match_all($pattern, $file->getContents(), $m)) {
$found[$file->getRelativePathname()] = array_unique($m[1]);
}
}
return $found;
}
}
It’s crude. It misses dynamic names, it misses Blade @include directives unless you extend the regex, and it will complain about routes that only exist in a package you conditionally register. I’ve accepted a couple of exclusions for exactly that reason. It has still caught four real typos in the last year, which is four more than PHPStan did.
For the config-validity problem, Laravel is actually in better shape than Symfony was, because config is PHP and a typo in a key just gives you null at runtime. That’s its own trap, so for anything security-adjacent I read the value through a typed accessor that throws on null rather than trusting config('services.stripe.secret') to be set.
Where I’ve landed
I ran symfony lsp:check on the three Symfony projects I still maintain. One was clean. One reported six missing translation keys in a locale we’d stopped shipping, all baseline-able. The third found a route reference in a Twig template to a route I’d renamed in March. That template was for a password reset email. Nobody had noticed because nobody had reset a password from that particular flow since. That’s the kind of bug that lives in the gap between “compiles” and “works”, and it’s the whole argument for this tool in one line.
I do have a reservation. The runtime mode boots your app, which means it needs a working environment in CI: a .env, any required env vars, and enough of the container to compile. On a project with a dozen third-party bundles that each want an API key at boot, that setup cost is real. --source-only is the escape hatch, but you lose the accuracy that makes the tool worth running. Budget an hour for the first integration, not five minutes.
If you build on PHP for clients and want to see how I wire this kind of guardrail into a delivery pipeline, there’s more on my work page.
Do this before Friday
Update Symfony CLI to 5.20 or newer, run symfony lsp:check from the root of one project, and read the output. Don’t add it to CI yet. Just read it. If it finds nothing, you’ve lost two minutes. If it finds something, generate a baseline, add the twelve-line workflow above, and set --fail-on=route.not_found,template.not_found so the two most embarrassing bug classes can never get merged again. Laravel people: copy the test above into tests/Feature, run it once, and see what falls out.