php yaml 129 lines · 4 tabs

Resolve the Current Tenant from the Request Host with a Symfony Argument Resolver

Shared by codesnips Aug 2026
4 tabs
<?php

namespace App\Controller;

use App\Entity\Tenant;
use App\Repository\InvoiceRepository;
use App\Tenant\TenantContext;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class DashboardController extends AbstractController
{
    #[Route('/dashboard', name: 'dashboard')]
    public function index(Tenant $tenant, InvoiceRepository $invoices): Response
    {
        return $this->render('dashboard/index.html.twig', [
            'tenant' => $tenant,
            'outstanding' => $invoices->outstandingFor($tenant),
        ]);
    }

    #[Route('/settings', name: 'settings')]
    public function settings(TenantContext $context): Response
    {
        return $this->render('dashboard/settings.html.twig', [
            'tenant' => $context->getTenant(),
        ]);
    }
}
4 files · php, yaml Explain with highlit

Multi-tenant SaaS applications often map a subdomain like acme.app.example.com to a specific tenant record. This snippet shows an idiomatic Symfony approach that keeps that logic out of controllers: a request-scoped TenantContext service holds the resolved tenant for the current request, and a custom ValueResolverInterface injects a Tenant straight into controller actions that type-hint it.

The TenantContext service is a plain, mutable holder. It is declared as a normal service, but because it is populated per request and read within the same request lifecycle it behaves as request-scoped state. The setTenant() method guards against being set twice with a LogicException, which surfaces double-resolution bugs early, and getTenant() throws when nothing has been resolved so callers never silently operate on a null tenant. The reason to centralize this rather than pass the tenant around manually is that many services (query filters, mailers, storage paths) need the current tenant without threading it through every method signature.

The TenantValueResolver implements resolve() from ValueResolverInterface, the Symfony 6.2+ contract. It first checks the argument's type via $argument->getType() and bails out with an empty array for anything that is not a Tenant, which is how a value resolver declines to handle an argument. When it does apply, it extracts the host with $request->getHost(), strips the configured base domain to get the subdomain, and loads the matching record through the TenantRepository. A missing tenant becomes a NotFoundHttpException, turning an unknown subdomain into a clean 404. Crucially it also calls $this->tenantContext->setTenant($tenant) so the same instance is available to the rest of the request, avoiding a second database lookup.

The services.yaml wiring tags the resolver with controller.argument_value_resolver and a priority so it runs before the framework's built-in resolvers, and marks both services non-shared where appropriate. The DashboardController shows the payoff: the action simply type-hints Tenant $tenant and receives it, while other collaborators read the same value from TenantContext. A pitfall worth noting is that the resolver only runs for arguments that are actually type-hinted, so services needing the tenant must depend on TenantContext rather than expecting injection.


Related snips

Share this code

Here's the card — post it anywhere.

Resolve the Current Tenant from the Request Host with a Symfony Argument Resolver — share card
Link copied