# Multi-tenancy

One installation, one database, several municipalities. Each municipality is a
**tenant**: its own editions, offers, hosts, accounts, participants, bookings
and settings, invisible to every other tenant.

Multi-tenancy is **optional and off by default**. A build that serves a single
Ferienpass needs no tenant configuration, no tenant row, and behaves exactly as
it did before this existed — `tenant_id` stays null everywhere and the query
filter is never enabled.

## Enabling it

```yaml
# config/packages/ferienpass.yaml
ferienpass:
    multi_tenancy:
        enabled: true
```

Then create the first tenant from the command line — the admin UI cannot
bootstrap itself, because with multi-tenancy on and no tenants yet, every host
is answered with 404:

```
bin/console ferienpass:tenant:create nordhorn "Ferienpass Nordhorn" \
    --host=ferienpass-nordhorn.de --host=www.ferienpass-nordhorn.de
```

Add `--admin-host=nordhorn.my-ferienpass.de` to put the admin on a domain of
its own — see [Domains and surfaces](#domains-and-surfaces).

Then give the tenant a website: create a **root page** in the Contao page tree
and select the tenant under *URL-Einstellungen → Mandant*. Without one the
tenant's hosts resolve, the admin works, and the public site has nothing to
render (the tenant list says so).

Converting an installation that has been running single-tenant? Add
`--adopt-orphans`: every row that has no tenant yet becomes this tenant's, which
is what keeps the existing data visible once the filter comes on. Do this once,
before adding a second tenant.

Run `bin/console contao:migrate` for the schema (the `Tenant` and `TenantDomain`
tables, and a nullable `tenant_id` on every tenant-scoped table).

## How a request finds its tenant

`TenantRequestListener` resolves the tenant from the request `Host`, before
anything else runs — in particular before the firewall, so accounts are looked
up inside the resolved tenant.

It is **fail-closed**: a host no enabled tenant claims gets a 404, never a
default tenant. `TenantDomain` carries a unique index on the host, so two
tenants can never claim the same domain.

## Domains and surfaces

A tenant's domains say *what* they serve, not just *who* they belong to. Each
`TenantDomain` carries a `Surface`:

| Surface | Serves | Admin URL |
| --- | --- | --- |
| `frontend` | the public site — CMS, offer list, accounts | `…/admin/angebote`, but only while the tenant has no admin domain |
| `admin` | the admin area, and nothing else | `…/angebote` — at the root of the domain |

Three shapes fall out of that, and they are all just rows in one table:

```
one domain          ferienpass-nordhorn.de          frontend    → site + /admin
split domains       ferienpass-nordhorn.de          frontend    → site only
                    nordhorn.my-ferienpass.de       admin       → admin at the root
admin only          alfeld.my-ferienpass.de         admin       → admin at the root, no public site
```

The moment a tenant names an admin domain, `/admin` on its public site is
**gone** (404). One page, one URL: an admin domain is not an alias of the path
prefix, and a bookmark of the old URL on the *admin* domain itself is answered
with a permanent redirect to the new one.

A tenant with no `frontend` domain has no public site at all: every frontend
route is unreachable for it, the admin hides the affordances that lead there
("Frontend", impersonation), and nothing tries to generate a link into a site
that does not exist.

### The platform console

An installation-wide admin domain, belonging to no tenant:

```yaml
ferienpass:
    multi_tenancy:
        enabled: true
        platform_host: platform.my-ferienpass.de
```

It serves the admin at its root like any admin domain, and is where the people
who run the *installation* work. Two things follow from it having no tenant of
its own:

- **Only global admins can sign in there.** The account behind an e-mail
  address has to be looked up across all tenants — the one place in the
  application where that happens — so `PlatformConsoleUserChecker` refuses
  anyone who is not `ROLE_GLOBAL_ADMIN`. An e-mail address is only unique
  *within* a tenant, so a global admin's address should be unique across the
  installation; the cross-tenant lookup would otherwise find an arbitrary one
  of the accounts sharing it.
- **The tenant comes from the session.** After signing in, the admin works in
  their own municipality; **Mandanten → Wechseln** moves them to another one
  (`TenantSwitcher`, the same mechanism as on a tenant's own admin domain).
  Before sign-in no tenant is active, so nothing tenant-scoped is read — the
  login page renders the default branding rather than some municipality's.

### One URL space, two ways of spelling it

The route collection is compiled once, with the admin under `/admin`. Serving
it at the root of a domain is done at the two edges of the request, and nothing
in between knows:

- **In** — `TenantRequestListener` puts the segment back before routing, the
  firewall, `access_control` and the cache-control rules run, so all of them
  keep matching the one path shape they are written for. `/_fragment`,
  `/_contao` and the profiler paths keep their own meaning on every domain.
- **Out** — `SurfaceAwareUrlGenerator` takes it out of every generated URL, and
  `AdminRedirectListener` out of every `Location` header (the firewall builds
  those from the internal URI).

The same generator is what moves a link that crosses surfaces: "Angebot
ansehen" in the admin becomes an absolute URL on the public site's domain, and
a reminder mail generated by cron links into the admin domain — including from
a worker, which has no request to take a host from.

Two consequences worth knowing:

- **Varnish keys on the URL the browser asked for**, so the eviction patterns
  follow the same rule: `CacheInvalidationListener::ban()` sends an `/admin/…`
  pattern to the domains that use the segment and the stripped pattern to the
  domains that do not (`AdminInvalidatePathListener` does the same for
  `#[InvalidatePath]`).
- **Split domains do not share a session.** Admin impersonation of a frontend
  member seeds the frontend token into the admin's own session, which only
  reaches the public site while the two share a domain; where they do not, the
  admin does not offer it and the endpoint refuses.

## The Contao page tree: one root page, one tenant

Contao is not multi-tenant and this does not make it so. The page tree, the
themes and layouts, the file manager and the back-end users are shared by the
whole installation. What separates one municipality's *website* from the next
is the **root page**: `tl_page.tenant` binds each root page to exactly one
tenant, and everything below it belongs to that tenant.

The field appears under *URL-Einstellungen* on multi-tenant builds only.
`TenantRootPageListener` enforces three rules when a root page is saved, each of
them a way to serve one municipality's site against another's data:

- a root page must name a tenant;
- no two root pages may name the same tenant — one root page, one tenant;
- the root page's `dns` must be one of that tenant's `frontend` hosts — an
  admin domain serves the admin area and nothing else, so a root page pointed
  at one would name a website that is never reached.

Duplicating a root page never goes through that form, so the copy starts out
unassigned rather than inheriting the original's tenant.

That last rule is what keeps the two host mappings from drifting apart. They
decide different halves of the same request: `TenantDomain` decides *whose data*
the request sees, `tl_page.dns` decides *whose pages* Contao renders. A domain
present in one but not the other is precisely the mismatch that would render
Nordhorn's site with the neighbouring municipality's offers.

A tenant may still hold several hosts (`ferienpass-nordhorn.de` and
`www.ferienpass-nordhorn.de`); only one of them is the root page's `dns`, and
the rest are redirects like on any single-tenant Contao site.

### Asking for "the" root page

Code that needs a root page must go through `TenantRootPage`, never
`PageModel::findPublishedRootPages()[0]` — on a shared installation "the first
root page" means "whichever municipality sorts first":

```php
$rootPage = $tenantRootPage->current();   // the active tenant's, or null
$rootPage = $tenantRootPage->forTenant($tenant);
```

On a single-tenant build `current()` returns the first published root page, i.e.
exactly what the old call did.

`CmsUrlGeneratorListener` scopes the same way. During a request it could use the
host, but a cron or worker generating an absolute link has only the tenant — so
with multi-tenancy on it selects pages below the *tenant's* root page and links
nowhere at all when there is none, rather than falling into another tenant's
tree.

### What is still shared

Back-end users, themes, layouts, form definitions, the file manager and the
`tl_cron_job` table are installation-wide. A municipality that needs its own
Contao editors is not covered by this; the operator maintains the page trees.

## How the data is separated

Entities that belong to a tenant implement `TenantAwareInterface` (via
`TenantAwareTrait`, which supplies the `tenant_id` mapping). Two things key off
that interface and nothing else has to be touched:

- **`TenantFilter`**, a Doctrine SQL filter, appends `tenant_id = ?` to every
  SELECT against such an entity — a `find()`, a DQL query, a lazily loaded
  association.
- **`TenantPersistListener`** stamps the active tenant onto new rows, so
  ordinary `persist()` code needs no changes.

`TenantContext` is the only thing that enables, parameterises and disables the
filter. Do not touch `$em->getFilters()` directly: enabling the filter without a
parameter, or setting the parameter without enabling it, are both ways to serve
the wrong municipality's data.

```php
$tenantContext->getTenant();                        // the active tenant, or null
$tenantContext->runFor($tenant, $callback);         // work as another tenant, then restore
$tenantContext->runUnfiltered($callback);           // across all tenants — global-admin surfaces only
$tenantContext->cacheKey('page_config');            // namespace a cache key by tenant
```

Only aggregate roots implement the interface. Rows reachable solely through a
tenant-aware parent (an `OfferDate`, a log entry) are already unreachable across
tenants, because the parent is filtered.

**Caches are not queries.** Anything cached outside the database must be
namespaced with `TenantContext::cacheKey()` — the page config and the
integration credentials already are. A shared key would hand one municipality's
settings to the next request from another host, and no query filter can prevent
that.

### Uniqueness

Values that identify something *within* a municipality are unique per tenant,
not per installation: an account's e-mail address, a host's alias, an offer
category's alias, an integration's provider. The same parent may hold an account
in two neighbouring Ferienpässe.

On a single-tenant build every row has a null `tenant_id`, and MySQL treats each
of those as distinct — so the database index does not enforce uniqueness there.
The application-level `UniqueEntity` checks do (they match `tenant IS NULL`
explicitly), which is also what produces the readable error in the form.

## Console commands

Every command accepts `--tenant=<slug>` (or the `FERIENPASS_TENANT` environment
variable):

```
bin/console ferienpass:cron:send-reminders --tenant=nordhorn
bin/console ferienpass:tenant:list
```

Without it a command runs **unfiltered, across all tenants** — right for
`contao:migrate` or `cache:clear`, wrong for anything that touches tenant data.

## Cron

The scheduled jobs are Contao cron listeners (`#[AsCronJob]`) driven by
`contao:cron`. **Do not run `contao:cron --tenant=…` once per tenant**: Contao
records each job's last run in `tl_cron_job`, which is one shared table here, so
the first run would mark the job done and every later tenant would be skipped.

A tenant-aware job loops inside itself instead:

```php
foreach ($this->tenants->findAllOrdered() as $tenant) {
    $this->tenantContext->runFor($tenant, fn () => $this->sendReminders());
}
```

The loop is not about the query — unfiltered, it would happily find every
tenant's rows in one pass. It is about everything *around* the query, which only
resolves correctly while a tenant is active: the sender address and branding
from `PageConfig`, the notification templates (`Notification` is tenant-scoped),
the tenant's Brevo credentials, the cache keys, and the `tenant_id` stamped on
whatever the job writes. With no tenant active `PageConfigManager` returns
whichever row `findOneBy([])` happens to hit — i.e. some other municipality's
sender address on this municipality's mail.

## Async messages

`TenantMiddleware` stamps the dispatching tenant onto the envelope and the
worker handles the message inside that tenant, so one worker can batch several
tenants' messages — as far as **database-backed** configuration goes. See the
next section for what still does not follow the tenant.

## Branding, settings and links outside a request

The filter and `TenantContext` cover what is read from the database. Three
things sit outside it — they are resolved when the container is compiled, or
read from the environment, or derived from a request that a worker does not
have — and each is handled explicitly.

### The e-mail logo lives in the database

`PageConfig::$logo` holds a reference into the `logos` virtual filesystem, and
`EmailLogoListener` embeds it into every outgoing `TemplatedEmail` and exposes
it to the template as `logoSrc`. Upload it under **Werkzeuge → System →
Allgemein**.

It could not stay a repository file: `email.image('@images/logo_email.png')`
resolves a Twig path bound to `app.id` at compile time, so a worker rendering a
queued mail for another municipality has no way to reach the right one. An
`apps/{id}/templates/email/_header.html.twig` that sets `logoSrc` itself still
wins — a template's own `{% set %}` shadows the context — so single-tenant
builds keep the logo they have today.

### Per-municipality options are rejected at build time

`SystemSettingsManager` and `MailConfigManager` read `$parameter ?? $pageConfig->…`,
so a configured `ferienpass.*` option silently gives every tenant the same
value. With `multi_tenancy.enabled: true` the extension therefore refuses to
build if any of these is set, and names the offenders:

`reply_address`, `receipt_number_prefix`, `friends_booking`,
`require_postal_address`, `erase_data_after`, `remind_attendance`,
`remind_attendance_before`, `payments_enabled`, `payment_allow_select`,
`participant_list_sort`, `age_tolerance`, `withdraw_grace_period`,
`no_withdraw_after_grace_period`, `no_withdraw_after_application_deadline`,
`preferred_postal_codes`.

Each has a per-tenant equivalent on `PageConfig`; set it there instead.
`ADMIN_EMAIL` comes from the environment, which the container cannot inspect
reliably at compile time, so it is handled at runtime instead: on a multi-tenant
build the tenant's own `PageConfig` wins over it.

Deliberately **not** rejected: `MAILER_DSN`, `inbound_domain`/`inbound_secret`
and the integration keys (`BREVO_API_KEY`, `OPENAI_API_KEY`, `PMPAYMENT_*`).
One shared mail account for the whole installation is a legitimate setup, and
the inbound reply domain needs its own MX record and Brevo webhook, so it is
infrastructure rather than a preference; a tenant that needs its own still
overrides the credentials in `IntegrationConfig`.

### Absolute links without a request

`TenantContext` points `router.request_context` at the tenant's primary domain
whenever a tenant is activated and there is no HTTP request — a cron, a worker.
Without it, a reminder mail generated by cron would link into whichever host
`router.request_context.host` names, the same one for every municipality.

The primary domain is the tenant's public site when it has one, because most
links generated outside a request go to parents rather than to staff. Links
that go to staff are moved onto the admin domain by `SurfaceAwareUrlGenerator`
afterwards, when the route — and therefore the surface — is known.

During a request the context already describes the host the visitor actually
used, which is more specific than the canonical domain, so it is left alone. The
same holds in a worker for the dispatching request's context, which the
`router_context` middleware restores *inside* the tenant middleware. Only the
host is set; the scheme stays whatever `framework.router` was configured with.

## Roles

`ROLE_GLOBAL_ADMIN` sits above `ROLE_SUPER_ADMIN` in the role hierarchy (the
bundle prepends that, so an app's `security.yaml` needs no edit):

| Role | Scope |
| --- | --- |
| `ROLE_SUPER_ADMIN` | one municipality — everything inside their own tenant |
| `ROLE_GLOBAL_ADMIN` | the installation — the tenant list, plus every super-admin permission |

A global admin's account belongs to exactly one tenant like everyone else, and
signs in on that tenant's host — or on the [platform console](#the-platform-console),
if the installation has one. The tenant list lives in the admin under
**Systemkonfiguration → Mandanten** (`/admin/system/mandanten`), visible to global
admins only; a super admin manages their own municipality's settings under
**Systemkonfiguration** but never sees another tenant. **Mandanten → Wechseln**
steps into another tenant's data room for the rest of the session
(`TenantSwitcher`), which is how the operator supports a municipality — a
persistent banner and a frame around the viewport mark the switched state on
every admin page until the operator leaves the data room. The switch applies to
the admin only: the public site always answers as the tenant its host names,
whoever is signed in.

## Deleting a tenant

The foreign keys are `RESTRICT`, and the admin refuses to delete a tenant that
still owns rows — deleting a municipality's children, bookings and receipts must
never be a side effect of tidying up a list. The delete dialog names what stands
in the way (participants, offers, seasons, …) and offers the alternative in one
click: taking the tenant offline. Its domains stop resolving with 404 and the
data is kept.

A tenant with a root page is refused for the same reason, but without a foreign
key to lean on: `tl_page` is Contao's table, so nothing at the database level
would stop the delete, and the orphaned root page would attach itself to
whichever tenant reuses the id. Reassign or delete the root page first.
