freshheads/multi-site-bundle
A Symfony bundle for running multiple websites from one codebase, with per-site configuration, routing, and content isolation. Simplifies multi-site management without duplicating application logic.
1. Confirm the version constraint against your Symfony version — it changed in 1.3.0. Release 1.3.0 added Symfony 7 support (PR #21), so the previous blocker is gone: if you are on Symfony 7.x you now need ^1.3 explicitly, because 1.2.1 and earlier will not resolve. Conversely, if you are on Symfony 5.4/6.x, check composer.json after composer require freshheads/multi-site-bundle — the resolved constraint tells you immediately whether 1.3.0 widened the matrix or dropped older branches.
2. Register the bundle manually. There is still no Flex recipe, so add it to config/bundles.php yourself:
return [
// ...
Freshheads\MultiSiteBundle\FreshheadsMultiSiteBundle::class => ['all' => true],
];
3. Read three files before writing any config. Documentation is still thin, so the source is the documentation. Open, in this order:
DependencyInjection/Configuration.php (this is the authoritative schema for every YAML key — far more reliable than the README),Also read CHANGELOG.md first. As of 1.3.0 the project enforces changelog entries on every PR (changelog-enforcer, PR #19), so the file is now a genuinely maintained record rather than an afterthought — it is a better signal than the commit log.
4. Define two sites and prove resolution works. The canonical first use case is one codebase, two hostnames (example.com, example.nl), shared templates but different content. Create the site config (typically config/sites.yaml or under a freshheads_multi_site: root key in config/packages/), give each site an identifier, a host, and any locale/attribute data you need, then dump $site from a controller or Twig to confirm which site the request resolved to:
# config/packages/freshheads_multi_site.yaml
freshheads_multi_site:
sites:
nl:
host: example.nl
locale: nl_NL
com:
host: example.com
locale: en_US
If that dump is correct, everything downstream — routing, Twig globals, URL generation — will follow.
Inject the current site rather than re-deriving it. The whole point of the bundle is that resolution happens once, early, and is available as a service. Add a constructor type-hint on the site (or the site provider/context service) and let the container wire it. Reach for $request->getHost() in a controller only when you are deliberately bypassing the abstraction — and then ask why.
Keep resolution strategy and route strategy separate in your head. The bundle's value is two distinct concerns: which site is this request (host, path prefix, or something custom) and how do routes become URLs for that site. Configuration for the first lives in the site definition; the second is Symfony's RequestContext being re-pointed at the resolved site so that $urlGenerator->generate() emits the right host. Once you understand that the bundle is essentially "set the router context before routing," most confusing behaviour becomes explainable.
Use host-based resolution for real domains, path prefixes for staging. Host-based is the low-surprise default: example.com vs example.nl, no route changes, canonical URLs are clean. Path-prefix (/nl/..., /en/...) is easier to test on a single hostname and easier to run behind one TLS cert, but it forces every route to be prefix-aware and makes absolute URL generation the place bugs hide. Many teams run path prefixes in dev and host-based in production — decide early, because switching later means touching route definitions.
Treat site identity as a first-class cache dimension. Symfony's HTTP cache and any reverse proxy cache by URL by default; with host-based sites the host is usually part of the cache key, but if you have custom resolution (cookie, query param, geo) it is not. Set Vary explicitly. Likewise, any cache.app key that holds site-dependent data must include the site identifier in the key — this is the single most common source of "Dutch users see English prices" bugs in multi-site apps.
Filter shared data at the repository/query layer, not in templates. If both sites read from one Page or Product table, add a site column and a query scope. Trying to filter in Twig means the wrong rows still get loaded, paginated, and cached. If the bundle ships a Doctrine integration, use it; if not, a small SiteAwareRepository trait is a better investment than sprinkling andWhere() calls.
Test with an explicit host. In KernelBrowser, $client->request('GET', 'https://example.nl/') sets the host correctly, whereas $client->request('GET', '/') gives you localhost and a resolver that falls back to a default site — which silently makes your "site-specific" test verify nothing. Assert on both the resolved site and the generated absolute URL.
Budget for reading source, not docs. Adopted-by count is still effectively zero — no Stack Overflow answers, no blog posts, nobody to ask. The upside: it is a small bundle, so a focused hour in vendor/freshheads/multi-site-bundle is genuinely sufficient. The downside: you are the maintainer now. Fork it into your VCS or vendor-patch it, because a composer update that drops it — or an abandoned-package marker — should not be able to break your build. Treat adopting this as "adopting a design pattern with a reference implementation," not "installing a supported dependency." 1.3.0 improves the odds (phpstan in CI, changelog enforcement, Symfony 7) but does not change the bus factor: every contribution in this release came from one organisation.
If you fork or patch, keep the new CI gates. 1.3.0 added PHPStan (PRs #18, #20) and moved php-cs-fixer to the php-cs-fixer/shim dev dependency (PR #17), so the repo now type-checks and lints without a global tool install. Run that same phpstan config against your patches — the codebase is now written to satisfy a static analyser, and your additions will look inconsistent if they don't. Also note the "Phpstan deprecations" PR was cleanup of deprecation notices surfaced by the analyser; if you vendor-patched an older copy, re-apply against 1.3.0 rather than carrying stale deprecation workarounds.
Contributing upstream: changelog-enforcer will fail your PR. The workflow in PR #19 rejects PRs that don't touch CHANGELOG.md. Add an entry under an "Unreleased" heading as part of the PR, not after review, or you will burn a CI cycle.
Proxies will break host resolution. Behind a load balancer or CDN, $request->getHost() returns the internal hostname unless symfony/framework-bundle is configured with trusted_proxies and trusted_headers including x-forwarded-host. This is not the bundle's fault, but it is the bundle's most common reported symptom. Verify with bin/console debug:container --env=prod and a real request through the proxy before you blame the resolver. This is especially worth re-checking when you upgrade to the Symfony 7 branch, since framework.trusted_proxies handling and the underlying Request behaviour differ between majors.
CLI and cache warmers have no request. Any command, messenger worker, or cache warmer that touches the site context will find nothing resolved. Make sure the bundle (or your own service) has a sensible default site configured, and never assume getCurrentSite() throws meaningfully — it may return null or the first configured site depending on implementation. Wrap this in your own CurrentSiteProvider with an explicit fallback if you don't like the bundle's behaviour.
Route cache is global. Symfony compiles one route collection for the whole app. Site-specific route names (e.g. nl_homepage) compile fine but bloat the collection, and duplicate route paths across sites are only distinguishable if your strategy actually varies the host or prefix. If you need genuinely divergent route sets per site, use separate route files imported conditionally per site in config/routes.yaml rather than fighting the cache.
Extension point: decorate the resolver. The cleanest way to add custom logic (cookie-based override, geo-IP, ?site= for QA, header-based for API clients) is to decorate or replace the site resolver service, not to fork the bundle. Check bin/console debug:container | grep -i site to find the service ID, then:
services:
app.multi_site.resolver:
decorates: freshheads_multi_site.site_resolver # verify the actual ID
arguments: ['@.inner']
Locale is not site. Resist conflating them. If the bundle stores a locale on the site, it is a default hint, not a guarantee — Symfony's LocaleListener and _locale route parameters still operate independently, and the interaction between the two is where subtle redirect loops appear. One site can legitimately serve two languages; keep the two concepts in separate services.
Pin ^1.3 and read the changelog on every bump. The Symfony 7 bump arrived long after Symfony 7 shipped, and the release cadence is best described as "whenever the maintainers need it." The changelog is now enforced and trustworthy; use it. With no downstream dependents, regressions will not be caught by anyone but you.
How can I help you explore Laravel packages today?