Skip to content

Multi-tenancy

Tenancy is resolved from the hostname, once, before anything else in the request.

  1. IdentifyTenant reads the host: {slug}.flowery.com.ua names the workspace by slug; any other host names it by a shop’s own domain, and only while that domain is live (workspace_domains, ADR-022); the root domain has no tenant. It loads the active workspace, stores it in the request attributes (workspace, and customDomain when the request came in on the shop’s own domain) and sets the workspace URL default so links built during the request point at the subdomain. It runs before route model binding.
  2. BelongsToWorkspace on every tenant-owned model adds a global workspace_id scope, fills the column on create, and overrides route binding so a record from another tenant is a 404 rather than a 403. The host tenant overwrites any submitted workspace_id.
  3. EnsureUserBelongsToWorkspace guards the control panel; SetWorkspaceContext shares the workspace, the user’s role and the capability map with the front end.
  4. Tenant-scoped validation. Form requests validate foreign keys with existsInTenant() instead of a bare exists rule.
  • Read the tenant from the request, never from the user’s current_workspace_id. A user can be a member of several workspaces, and the host decides which one a request is about.
  • Do not rely on the global scope alone for relationship safety. Policies check tenant alignment explicitly, and new tables carry workspace_id even when a parent already does.
  • Wayfinder route helpers for control-panel routes need the workspace slug because the routes are declared on {workspace}.<domain>. Pass workspaceSlug() from @/lib/workspace-url; an empty string produces a URL with an empty host and ESLint rejects it.
  • Storage paths are not yet segmented per workspace. Catalogue images are public and randomly named, so this is acceptable; private media must use a per-workspace prefix.
  • Only the storefront and the Storefront API exist on a shop’s own domain. Their routes are registered twice: on {workspace}.<domain> and on {customHost} constrained by WorkspaceDomain::routePattern(), which never matches the platform domain or a host under it (routes/storefront-api.php and routes/custom-domains.php). The web group answers only on platform hosts (PlatformHostsOnly, first in the group), so sign-in, the control panel and every other web route are a 404 on a foreign host before a session starts; the custom-host routes are loaded outside web. A new storefront path goes into StorefrontUnavailableController::PATHS and nginx’s location line, and a new Storefront API route into the shared closure in routes/storefront-api.php.

The tenant boundary suite asserts, for every resource, that another tenant’s record returns 404 on read, update and delete, that a foreign key from another tenant fails validation, and that no side effect occurs on a rejected request.

A shop lives at {slug}.flowery.com.ua. App\Support\WorkspaceSlug is the one rule for every way a slug is set: the address generated from the shop’s name, the onboarding check and the onboarding save.

Format. 3 to 63 characters: lowercase Latin letters, digits and single hyphens, not at the start or end. This is a DNS label. -- is refused, so xn--… look-alike (punycode) addresses cannot be registered. The storefront runtime and the nginx map accept exactly these hosts.

Reserved names. These include:

  • Infrastructure: www, api, mail, cdn, status, release, staging, dev; connect and shops-origin, the hosts shops’ own domains point at (ADR-022).
  • The platform’s own pages: docs, help, login, checkout, storefront, flowery, shop, store.

The full list is WorkspaceSlug::RESERVED. Add a name there before using a new platform host.

Generated addresses. The address generated when a shop is created comes from its name in Latin letters (“Квіти” becomes kviti):

  • A reserved or too-short result gets -shop added (“Admin” becomes admin-shop).
  • A name with no usable letters becomes shop.
  • A taken address is numbered (rose-1).

Onboarding. The availability check answers with the reason (“reserved”, the format, or “already taken”). Saving refuses what the check refuses: keeping the generated address is no longer a way around the rules.

Found and fixed 5 October 2026, while preparing the storefront:

  • Only eight names were reserved.
  • The generated address was never checked against them, so a shop named “API” got api.
  • -shop, shop- and xn--… passed the format check, although the storefront and nginx refuse them.

At the time, production’s only shop (batata) already met the new rule.

A shop’s own domain. A shop can also be served from a domain it owns, such as www.myflowers.ua (ADR-022). The domain is a workspace_domains row: one per workspace, the hostname in lowercase punycode, with a status (pending, active, moved, error). Only active resolves. The shop keeps its subdomain throughout: staff sign in there, previews open there, and once the domain is live the storefront on the subdomain redirects customers to it. Workspace::storefrontHost() is the one place that decides which host customers see; Workspace::controlPanelUrl() always builds subdomain links for staff.