A Sentry alert came in on a Monday afternoon, right after I'd shipped a feature to a client's Symfony portal. The feature was about who's allowed to impersonate whom, so I'd touched the voter that makes that decision. The error was about that voter:

TypeError: App\Security\Voter\SwitchUserVoter::__construct(): Argument #4 ($roleHierarchy)
must be of type Symfony\Component\Security\Core\Role\RoleHierarchyInterface,
Symfony\Component\Security\Core\Authorization\AuthorizationChecker given,
called in /var/www/html/var/cache/prod/ContainerXvAzWWg/getSwitchUserVoterService.php on line 32

My first reaction was the obvious one: I broke the wiring. Except I hadn't. Nobody writes new SwitchUserVoter(...) by hand, the voter is autowired, and the constructor in the repo was perfectly happy:

public function __construct(
    private readonly VendorService $vendorService,
    private readonly UserRepository $userRepository,
    private readonly AccountRepository $accountRepository,
    private readonly RoleHierarchyInterface $roleHierarchy,
) {}

Autowiring sees RoleHierarchyInterface, finds the role hierarchy service, done. Tests passed. Locally it worked. So where on earth was an AuthorizationChecker coming from?

The "given" type is a fossil

Here's the constructor from the commit before mine:

public function __construct(
    private readonly VendorService $vendorService,
    private readonly UserRepository $userRepository,
    private readonly AccountRepository $accountRepository,
    private readonly AuthorizationCheckerInterface $authorizationChecker,
) {}

Argument #4 used to be an AuthorizationCheckerInterface, and the service behind that interface is, you guessed it, AuthorizationChecker. Prod wasn't passing a random wrong thing. It was passing exactly what the previous version of the class asked for.

The second clue is in the last line of the error, the part everyone skims past. The caller isn't a controller or one of my services. It's var/cache/prod/ContainerXvAzWWg/getSwitchUserVoterService.php, a file Symfony generated. That's the compiled container: a big pile of plain PHP factories, one per service, with every autowiring decision already resolved and written down. Line 32 of that file is basically new SwitchUserVoter($a, $b, $c, $authorizationChecker), frozen at the moment the container was compiled.

So prod was running today's src/ with last release's container. The class came from the new code, the instructions for building it came from the old code, and they disagreed about argument #4.

Why prod never notices

In dev you'd never see this, because Symfony rebuilds the container whenever a file it depends on changes. In prod it deliberately doesn't. This is the check in Kernel::initializeContainer():

if (is_file($cachePath) && \is_object($this->container = include $cachePath)
    && (!$this->debug || (self::$freshCache[$cachePath] ?? $cache->isFresh()))
) {

Read the second line slowly. With debug off, which is what prod runs with, !$this->debug is true and the freshness check never runs. If a compiled container file exists, Symfony uses it. No timestamps, no questions. That's the whole point: checking every config file and every service class on every request would be slow, so prod trusts the cache completely.

Which is great, right up until the code underneath the cache changes and nobody tells it.

The fix

Throw the old container away and let Symfony compile a new one:

php bin/console cache:clear --env=prod

cache:clear also warms the cache, so the first request after it doesn't pay for the compile. If you run PHP-FPM with opcache.validate_timestamps=0 (common on prod, and a good idea), OPcache may still hold the old compiled files in memory, so reload FPM too:

systemctl reload php8.5-fpm

If the app runs in Docker, run that inside the container. And if var/cache is baked into the image or sits on a volume, that's where the stale container lives, so clear it there.

How a deploy skips the rebuild

On a stock Symfony Flex project you almost get this for free. composer.json has:

"auto-scripts": {
    "cache:clear": "symfony-cmd",
    "assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
    "@auto-scripts"
],

So every composer install clears the cache. That's also why this bug hides so well: most deploys happen to run composer install, the cache gets rebuilt as a side effect, and nobody ever writes "clear the cache" down as a real step. Then one deploy pulls new code without running Composer, or runs it with --no-scripts, or copies files into a container whose var/cache survived from the last release. Every one of those paths ends with fresh src/ and a stale container.

It also doesn't need a dramatic change to bite. Renaming a constructor argument's type is enough. So is adding a new argument, removing a service, or changing a services.yaml binding. Anything autowiring resolves at compile time is frozen until the next compile.

What I'd do on any Symfony deploy, regardless of how it ships:

  • Make cache:clear --env=prod (or cache:warmup into a fresh directory) an explicit deploy step, not a Composer side effect.
  • If you build images, warm the cache during the image build and don't put var/cache on a persistent volume.
  • If you deploy with release directories and a symlink, each release gets its own var/cache, so a stale container can't follow you from one release to the next.

A side note on getting the error out of Sentry

I debug with an agent in the terminal these days, so I pasted it the Sentry share link. Sentry now answers bots on that URL with a polite page saying the web UI is for humans and pointing at their MCP server, CLI and API. Fair enough. But the share link already makes the issue public, and the JSON behind it is available without auth:

curl -s "https://<org>.sentry.io/api/0/organizations/<org>/shared/issues/<share-id>/"

<share-id> is the hex string from the share URL. You get the title, culprit and the latest event with the full stack trace, which is exactly what you want to paste into a debugging session.

Takeaway

When a TypeError says your constructor got the wrong type and the "given" type is what the constructor used to ask for, don't debug the class. Read the "called in" path. If it points into var/cache/prod, your code is fine and the container is from the last release. Recompile it, and make that a step your deploy can't skip.


Fighting a Symfony deploy that works everywhere except prod? Send me the details and I'll take a look.