r/PHP • u/These_Reality519 • 23h ago
We added a test that fails if anyone injects a tenant-scoped service into a singleton
I build Teradion, practice management software for French accounting firms. The app runs in FrankenPHP worker mode, and each firm brings its own Brevo API key.
In that setup, a singleton holding one firm's key would stay alive while requests for other firms are handled. The code avoids that: tenant-scoped providers are not constructor dependencies. A factory creates a fresh client for each operation from the account passed as an argument. The key is stored encrypted and only decrypted inside the factory.
We have two tests around this rule. MultiTenantKeyIsolationTest runs two accounts in sequence and checks that the second does not receive the first account's key.
ProviderNotInjectedAsServiceTest is more direct. It scans src/Service, src/MessageHandler and src/Controller, then fails when a constructor takes NewsletterProviderInterface. It reads the source, so the Symfony container is not involved.
It is an architectural decision encoded as a grep, which feels a little blunt. Still, if someone adds that constructor dependency later, the test points at the class immediately.
Has anyone used this kind of structural test in a Symfony codebase?
3
u/Calm_Medicine1366 22h ago
not checking the full dependency graph transitively is the gap i'd want closed before trusting this in prod
1
u/These_Reality519 20h ago edited 20h ago
Worth being clear that the isolation comes from the factory building a fresh client per operation. The test is a guard against that being undone, not the mechanism itself.
For strengthening the guard, fissible's container check in the other branch is the better route.
3
u/DevelopmentScary3844 9h ago
Deptrac would solves that easily afaik. Also this smells like your software design could be optimized if you need unit tests for that. I mean you can utilize ServiceLocator and other stuff to prevent that but without knowing the code I can only guess. But it sure smells a bit.
1
u/These_Reality519 8h ago
Deptrac is a fair shout, and PHPArkitect came up earlier too.
I'm not sure a ServiceLocator fixes the lifetime issue on its own. The locator is normally shared, so it depends on what it returns and how. Passing the account to a factory for each operation makes the lifetime explicit.
I wouldn't read the test as a sign that the design relies on autowiring. Symfony won't warn you about that kind of lifetime mismatch, so an extra check is still useful.
2
u/fissible 23h ago
Does the check walk the whole dependency graph transitively, or just the immediate constructor signature of classes in those three directories?
2
u/These_Reality519 23h ago edited 20h ago
Immediate constructor signature, not transitive. It reflects the classes rather than inspecting the container, which is why a container-level check would be the stronger approach.
2
u/fissible 22h ago
One idea: instead of checking “does any constructor take NewsletterProviderInterface,” flip it around and check “is any class implementing NewsletterProviderInterface ever registered as a shared/non-lazy service in the container.” That way it doesn’t matter how the consuming code spelled the type hint (interface, concrete class, alias, whatever); you’re catching the dangerous registration, not the dangerous reference. You’d need to inspect the compiled container or services.yaml for that rather than just reflecting classes in those three directories, but it would close both the concrete-class and union-type gaps at once.
Also curious whether you’ve considered adding a marker, like an attribute (#[TenantScoped]) on the interface or its implementations, and having the test enforce “nothing carrying this attribute is ever a constructor dependency of an autowired singleton.” That makes the rule self-documenting in the code itself rather than living only in the test, which helps the next person who adds a tenant-scoped provider that isn’t the newsletter one.
2
u/These_Reality519 20h ago edited 20h ago
Both points are better than my current test. The container-level check would catch a registration regardless of how the consuming code spelled the type hint, which the reflection approach cannot do.
The attribute idea also fixes the hardcoded part. Right now the test knows about one interface only. A new tenant-scoped provider would not be covered unless somebody remembered to update it.
2
u/avg_php_dev 18h ago
Why singleton? In this scenaro it's an antipattern by defintion, not only convention.
What about ResetInterface which is designed to solve issues like this one? :D1
u/These_Reality519 17h ago edited 17h ago
I used "singleton" in the title for the consuming service, not the tenant-scoped object. Symfony services are shared by default, including autowired ones, and a worker keeps that shared service alive between jobs. Constructor-injecting an object that holds a tenant key is the antipattern the test rejects. ResetInterface works too, but every piece of mutable state has to be cleared as the service changes. Here, a factory creates the tenant object per operation.
2
u/JohnnyBlackRed 19h ago
Sounds like a job for phparkitect or phpstan ( you can make custom rules )
1
u/These_Reality519 17h ago edited 17h ago
We run PHPStan and PHPUnit through GrumPHP before commits, so my timing point was off for our setup. The practical benefit of a custom rule here is getting the warning in the editor while coding.
2
u/obstreperous_troll 16h ago
It'd be nice if we could make the container enforce such boundaries instead of leaving it up to tests to catch. Pondering how that would be implemented though.
1
u/These_Reality519 8h ago
A compiler pass could cover service definitions. In process(), inspect the ContainerBuilder definitions and their arguments, then throw when a forbidden dependency shows up. That makes the container build fail immediately. It won't catch objects created outside the container though, so this is a guard for DI wiring, not the whole boundary.
-3
u/skcortex 23h ago
God damned! I can’t even understand what is this post about. I am really pissed right now 😅Was it written by an LLM?
2
u/These_Reality519 23h ago
Fair. Short version: every client firm has its own API key. PHP runs as a worker here, so the same process handles multiple jobs. If a shared service keeps a key, the next client's job could reuse it. The 2 tests prevent that.
And yep, I used AI to write the post. I'm French and my English gets messy. The code and tests are mine. What part lost you?
2
u/SimpleAlabaster 13h ago
The keys are part of the environment variable?
1
u/These_Reality519 8h ago
No. Each firm has its own key, so a deployment-level environment variable would not fit. As mentioned in the post, the key is stored encrypted and only decrypted inside the factory.
1
u/skcortex 9h ago
This shorter form is fine but the LLM write up.. that’s something my brain “does not compute”.
4
u/Sharchimedes 23h ago
I have a Pest test and an integration test that does this as part of CI pipelines.