Webshop - Tenants & White-Labeling¶
Overview¶
The webshop supports multiple tenants (white-label webshops) on a single platform. Each tenant can have their own branding, pricing, and customer base while sharing the same product catalog.
Tenant Entity¶
#[ORM\Entity]
class Tenant
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 100)]
private string $name;
#[ORM\Column(length: 50, unique: true)]
private string $subdomain; // e.g., "acme" for acme.tyrecloud.be
#[ORM\Column(length: 255, nullable: true)]
private ?string $customDomain; // e.g., "shop.acmewheels.be"
#[ORM\Column(length: 255, nullable: true)]
private ?string $logoPath;
#[ORM\Column]
private bool $isActive = true;
#[ORM\Column]
private bool $vatNumberRequired = true;
#[ORM\Column]
private bool $approvalRequired = true;
#[ORM\Column]
private bool $autoApprovalEnabled = false;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $autoApprovalRules = null;
#[ORM\Column(length: 255)]
private string $fromEmailAddress; // info@[subdomain].tyrecloud.be
#[ORM\OneToMany(mappedBy: 'tenant', targetEntity: TenantVisibleBrand::class)]
private Collection $visibleBrands;
#[ORM\OneToMany(mappedBy: 'tenant', targetEntity: TenantVisibleCategory::class)]
private Collection $visibleCategories;
}
Domain Configuration¶
Subdomain Pattern¶
Every tenant gets a subdomain under the main domain:
[subdomain].tyrecloud.be
Examples:
- atraxion.tyrecloud.be (main tenant)
- acme.tyrecloud.be
- wheelshop.tyrecloud.be
Custom Domains¶
Tenants can optionally have a custom domain that points to their subdomain:
shop.acmewheels.be → acme.tyrecloud.be
Technical implementation: - DNS CNAME or A record pointing to platform - SSL certificate (Let's Encrypt wildcard or per-domain) - Nginx/Apache virtual host configuration
Tenant Resolution¶
class TenantResolver
{
public function resolve(Request $request): ?Tenant
{
$host = $request->getHost();
// Check for custom domain first
$tenant = $this->tenantRepository->findByCustomDomain($host);
if ($tenant) {
return $tenant;
}
// Extract subdomain from tyrecloud.be
if (str_ends_with($host, '.tyrecloud.be')) {
$subdomain = str_replace('.tyrecloud.be', '', $host);
return $this->tenantRepository->findBySubdomain($subdomain);
}
return null;
}
}
White-Labeling¶
Branding Elements¶
| Element | Customizable | Storage |
|---|---|---|
| Logo | Yes | File upload, path in tenant |
| Subdomain | Yes (on creation) | Tenant entity |
| Custom domain | Yes | Tenant entity |
| Email from address | Auto-generated | info@[subdomain].tyrecloud.be |
Email Branding¶
Emails sent to customers of a tenant include:
- Tenant logo in email header
- From address:
info@[subdomain].tyrecloud.be - Reply-to: same (replies go to tenant, not Atraxion)
class TenantAwareMailer
{
public function send(Email $email, Tenant $tenant): void
{
$email->from(new Address(
sprintf('info@%s.tyrecloud.be', $tenant->getSubdomain()),
$tenant->getName()
));
$email->replyTo(new Address(
sprintf('info@%s.tyrecloud.be', $tenant->getSubdomain())
));
// Logo is added via email template
$this->mailer->send($email);
}
}
Registration Configuration¶
Each tenant can configure their customer registration flow.
Configuration Options¶
class Tenant
{
// Is VAT number required during registration?
private bool $vatNumberRequired = true;
// Must new accounts be approved before they can order?
private bool $approvalRequired = true;
// Can accounts be auto-approved based on rules?
private bool $autoApprovalEnabled = false;
// Rules for auto-approval
private ?array $autoApprovalRules = null;
}
Registration Flows¶
B2B Strict (Atraxion default):
VAT required: yes
Approval required: yes
Auto-approval: no
Flow: Register → Enter VAT → Wait for manual approval → Can order
B2B with Auto-Approval:
VAT required: yes
Approval required: yes
Auto-approval: yes (with rules)
Flow: Register → Enter VAT → Auto-approved (temporary) → Can order
Later: Manual review confirms or revokes
B2C Open:
VAT required: no
Approval required: no
Auto-approval: n/a
Flow: Register → Immediate account → Can order
Auto-Approval Rules¶
// Example auto-approval rules
$autoApprovalRules = [
'valid_vat' => true, // VAT number must be valid
'country' => ['BE', 'NL'], // Only these countries
'temporary_days' => 30, // Auto-approval expires after 30 days
];
class AutoApprovalService
{
public function shouldAutoApprove(Customer $customer, Tenant $tenant): bool
{
if (!$tenant->isAutoApprovalEnabled()) {
return false;
}
$rules = $tenant->getAutoApprovalRules();
// Check VAT validity
if ($rules['valid_vat'] ?? false) {
if (!$this->vatValidator->isValid($customer->getVatNumber())) {
return false;
}
}
// Check country
if (isset($rules['country'])) {
if (!in_array($customer->getCountryCode(), $rules['country'])) {
return false;
}
}
return true;
}
public function approve(Customer $customer, Tenant $tenant): void
{
$rules = $tenant->getAutoApprovalRules();
$customer->setApproved(true);
$customer->setApprovalType('auto');
if (isset($rules['temporary_days'])) {
$expiresAt = new \DateTimeImmutable("+{$rules['temporary_days']} days");
$customer->setApprovalExpiresAt($expiresAt);
}
}
}
Visibility Rules¶
Tenants don't own products or vehicles, but can have visibility rules.
Brand Visibility¶
#[ORM\Entity]
class TenantVisibleBrand
{
#[ORM\ManyToOne(targetEntity: Tenant::class)]
private Tenant $tenant;
#[ORM\ManyToOne(targetEntity: ArticleBrand::class)]
private ArticleBrand $brand;
}
Category Visibility¶
#[ORM\Entity]
class TenantVisibleCategory
{
#[ORM\ManyToOne(targetEntity: Tenant::class)]
private Tenant $tenant;
#[ORM\ManyToOne(targetEntity: ArticleCategory::class)]
private ArticleCategory $category;
}
Vehicle Brand Visibility¶
#[ORM\Entity]
class TenantVisibleVehicleBrand
{
#[ORM\ManyToOne(targetEntity: Tenant::class)]
private Tenant $tenant;
#[ORM\ManyToOne(targetEntity: VehicleBrand::class)]
private VehicleBrand $vehicleBrand;
}
Visibility Logic¶
class ProductVisibilityService
{
public function isVisibleForTenant(Article $article, Tenant $tenant): bool
{
// Check if tenant has any visibility rules
$visibleBrands = $tenant->getVisibleBrands();
$visibleCategories = $tenant->getVisibleCategories();
// If no rules, everything is visible (default)
if ($visibleBrands->isEmpty() && $visibleCategories->isEmpty()) {
return true;
}
// Check brand visibility
if (!$visibleBrands->isEmpty()) {
$brandIds = $visibleBrands->map(fn($vb) => $vb->getBrand()->getId());
if (!$brandIds->contains($article->getBrand()->getId())) {
return false;
}
}
// Check category visibility
if (!$visibleCategories->isEmpty()) {
$categoryIds = $visibleCategories->map(fn($vc) => $vc->getCategory()->getId());
if (!$categoryIds->contains($article->getCategory()->getId())) {
return false;
}
}
return true;
}
}
Doctrine Filter for Visibility¶
class TenantVisibilityFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
if ($targetEntity->getReflectionClass()->name !== Article::class) {
return '';
}
$tenantId = $this->getParameter('tenant_id');
// This is simplified; actual implementation would use subqueries
return sprintf(
'%s.brand_id IN (SELECT brand_id FROM tenant_visible_brands WHERE tenant_id = %s)',
$targetTableAlias,
$tenantId
);
}
}
Tenant Pricing¶
Tenants can set their own prices on top of Atraxion base prices.
Price Override Entity¶
#[ORM\Entity]
class TenantArticlePrice
{
#[ORM\ManyToOne(targetEntity: Tenant::class)]
private Tenant $tenant;
#[ORM\ManyToOne(targetEntity: Article::class)]
private Article $article;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $price;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $validFrom;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $validUntil;
}
Price Resolution¶
class PriceResolver
{
public function getPrice(Article $article, Tenant $tenant, ?Customer $customer = null): Money
{
// 1. Check tenant-specific price
$tenantPrice = $this->tenantPriceRepository->findCurrentPrice($article, $tenant);
if ($tenantPrice) {
$basePrice = $tenantPrice->getPrice();
} else {
// 2. Fall back to Atraxion base price
$basePrice = $article->getBasePrice();
}
// 3. Apply customer-specific margin/discount if applicable
if ($customer && $customer->hasCustomMargin()) {
$basePrice = $this->applyCustomerMargin($basePrice, $customer);
}
return Money::EUR($basePrice);
}
}
Admin Interface¶
Super Admin - Tenant Management¶
Super Admin can: - Create new tenants - Configure tenant settings - Set visibility rules - Activate/deactivate tenants - View all tenants' data
Tenant Owner - Own Tenant¶
Tenant Owner can: - Update logo - Configure registration settings - Manage visibility rules (within allowed scope) - Manage own customers, orders - Configure loyalty and coupons
Gherkin Scenarios¶
Feature: Multi-tenancy
As a platform
I want to support multiple tenants
So that resellers can have their own webshops
Scenario: Tenant resolution by subdomain
Given a tenant "ACME" with subdomain "acme"
When I visit "https://acme.tyrecloud.be"
Then I should see the ACME logo
And products should be filtered by ACME visibility rules
Scenario: Tenant resolution by custom domain
Given a tenant "ACME" with custom domain "shop.acmewheels.be"
When I visit "https://shop.acmewheels.be"
Then I should see the ACME logo
Scenario: Email from address
Given I am a customer of tenant "ACME"
When I place an order
Then I should receive an email from "info@acme.tyrecloud.be"
Scenario: B2B registration with approval
Given tenant "ACME" requires VAT and approval
When I register as a new customer
And I enter a valid VAT number
Then my account status should be "pending_approval"
And I should not be able to place orders
Scenario: B2B registration with auto-approval
Given tenant "ACME" has auto-approval enabled
And auto-approval requires valid VAT
When I register with a valid Belgian VAT number
Then my account should be auto-approved
And approval should expire in 30 days
Scenario: B2C registration
Given tenant "CONSUMER" does not require VAT
And tenant "CONSUMER" does not require approval
When I register as a new customer
Then my account should be immediately active
And I should be able to place orders
Scenario: Product visibility by brand
Given tenant "ACME" can only see brands "Michelin" and "Continental"
When I browse the product catalog as an ACME customer
Then I should only see products from "Michelin" and "Continental"
And I should not see products from "Pirelli"
Database Schema¶
-- Tenants
CREATE TABLE tenants (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
subdomain VARCHAR(50) NOT NULL UNIQUE,
custom_domain VARCHAR(255),
logo_path VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
vat_number_required BOOLEAN DEFAULT TRUE,
approval_required BOOLEAN DEFAULT TRUE,
auto_approval_enabled BOOLEAN DEFAULT FALSE,
auto_approval_rules JSON,
from_email_address VARCHAR(255),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- Visibility rules
CREATE TABLE tenant_visible_brands (
tenant_id INT NOT NULL,
brand_id INT NOT NULL,
PRIMARY KEY (tenant_id, brand_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (brand_id) REFERENCES article_brands(id)
);
CREATE TABLE tenant_visible_categories (
tenant_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (tenant_id, category_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (category_id) REFERENCES article_categories(id)
);
CREATE TABLE tenant_visible_vehicle_brands (
tenant_id INT NOT NULL,
vehicle_brand_id INT NOT NULL,
PRIMARY KEY (tenant_id, vehicle_brand_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (vehicle_brand_id) REFERENCES vehicle_brands(id)
);
-- Tenant-specific pricing
CREATE TABLE tenant_article_prices (
id INT PRIMARY KEY AUTO_INCREMENT,
tenant_id INT NOT NULL,
article_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
valid_from DATETIME NOT NULL,
valid_until DATETIME,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (article_id) REFERENCES articles(id),
UNIQUE KEY (tenant_id, article_id, valid_from)
);