Skip to content

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)
);