<?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(),
]);
}
}
<?php
namespace App\Tenant;
use App\Entity\Tenant;
class TenantContext
{
private ?Tenant $tenant = null;
public function setTenant(Tenant $tenant): void
{
if ($this->tenant !== null) {
throw new \LogicException('Tenant has already been resolved for this request.');
}
$this->tenant = $tenant;
}
public function getTenant(): Tenant
{
if ($this->tenant === null) {
throw new \LogicException('No tenant resolved for the current request.');
}
return $this->tenant;
}
public function hasTenant(): bool
{
return $this->tenant !== null;
}
}
<?php
namespace App\Tenant;
use App\Entity\Tenant;
use App\Repository\TenantRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantValueResolver implements ValueResolverInterface
{
public function __construct(
private readonly TenantRepository $tenants,
private readonly TenantContext $tenantContext,
private readonly string $baseDomain,
) {
}
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
if ($argument->getType() !== Tenant::class) {
return [];
}
if ($this->tenantContext->hasTenant()) {
yield $this->tenantContext->getTenant();
return;
}
$host = $request->getHost();
$suffix = '.' . $this->baseDomain;
if (!str_ends_with($host, $suffix)) {
throw new NotFoundHttpException('No tenant for host ' . $host);
}
$slug = substr($host, 0, -\strlen($suffix));
$tenant = $this->tenants->findOneBy(['slug' => $slug, 'active' => true]);
if ($tenant === null) {
throw new NotFoundHttpException(sprintf('Unknown tenant "%s".', $slug));
}
$this->tenantContext->setTenant($tenant);
yield $tenant;
}
}
parameters:
app.base_domain: '%env(APP_BASE_DOMAIN)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\Tenant\TenantContext: ~
App\Tenant\TenantValueResolver:
arguments:
$baseDomain: '%app.base_domain%'
tags:
- { name: controller.argument_value_resolver, priority: 150 }
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
module Paginatable
extend ActiveSupport::Concern
MAX_PER_PAGE = 100
DEFAULT_PER_PAGE = 25
API Pagination Headers (Link + Total)
class Document < ApplicationRecord
belongs_to :owner, class_name: "User"
has_many :visibilities, class_name: "DocumentVisibility", dependent: :delete_all
scope :public_documents, -> { where(is_public: true) }
Polymorphic “Visible To” Scope with Arel
Share this code
Here's the card — post it anywhere.