---
title: "White Label SaaS Laravel Guide: Tenant Logos, Colors, Domains"
description: "A white label SaaS Laravel guide: per-tenant logos, favicons and brand colors with Tailwind v4 CSS variables, contrast checks, dark mode, emails and PDFs."
pageClass: blog-page
date: 2026-11-04
author: erag
category: multi-tenancy
tags: [Multi-tenancy, Branding, Tailwind]
---
# White Label SaaS in Laravel: Per-Tenant Logos, Colors and Domains
Sooner or later a bigger customer asks: "Can it look like our product?" They want their logo, their color, their name on every email, and ideally their own domain. That's a white label SaaS. In Laravel it mostly comes down to where you store the branding and how you get it into every page, email and PDF without a separate frontend per customer.
In this post we build tenant branding for a multi-tenant Laravel and Inertia app with stancl/tenancy: a logo, favicon, brand color and app name per tenant, uploads resized into tenant storage, branding shared through Inertia, colors applied at runtime with Tailwind v4 CSS variables (no rebuild per tenant), contrast checks, dark mode, and the brand carried into the login page, emails and PDFs.
## What "white label" means in practice
White-labeling comes in levels. Decide which one you're selling before you build:
| Level | What the customer gets | Effort |
| --- | --- | --- |
| Basic branding | Logo, primary color, app name | Small |
| Full white label | Plus favicon, login page, emails, PDFs, no "Powered by" | Medium |
| Own domain | Plus `app.customer.com` with its own TLS certificate | Medium, mostly ops |
| Reseller | The customer rebrands and resells to their own clients | Large |
Most B2B products need the first three. We skip reseller setups here.
## Store the branding per tenant
With stancl/tenancy, the central `tenants` table is the natural home for laravel tenant branding. The current tenant model is already loaded on every tenant request, so reading the brand costs no extra query, and it stays readable from the central admin.
stancl's `Tenant` model uses virtual columns: any attribute not listed in `getCustomColumns()` is saved in the `data` JSON column. So these fields need no migration:
| Attribute | Example | Notes |
| --- | --- | --- |
| `brand_name` | `Acme Portal` | Falls back to the app name |
| `brand_logo` | `branding/logo-x7Kq2mPa.png` | Path on the tenant's public disk |
| `brand_icon` | `branding/icon-Zt9eL1sQ.png` | Square, used for the favicon |
| `brand_primary` | `#4f46e5` | Six-digit hex only |
| `brand_primary_dark` | `#818cf8` | Optional, for dark mode |
If each of a tenant's domains needs its own look, put the same fields on the domain and fall back to the tenant.
## Validate the input strictly
Brand colors end up inside a `
@endif
```
We use `html[data-brand]` instead of `:root` on purpose. It's more specific than the `:root` and `.dark` rules in `app.css`, so it wins whichever stylesheet loads last. That matters in development, where Vite injects CSS from JavaScript after the page loads.
Override a few tokens only. Customers want their color on buttons and highlights, not a lime-green sidebar.
## Check color contrast before you save
**Text on the brand color.** Don't let the customer pick the button text color. Compute it. For any background, pure white or pure black text reaches at least 4.58:1, which passes WCAG AA for normal text (4.5:1). So picking the better of the two always works:
```php
final class ColorContrast
{
public static function readableTextOn(string $background): string
{
return self::ratio($background, '#ffffff') >= self::ratio($background, '#000000') ? '#ffffff' : '#000000';
}
public static function ratio(string $a, string $b): float
{
$la = self::luminance($a);
$lb = self::luminance($b);
return (max($la, $lb) + 0.05) / (min($la, $lb) + 0.05);
}
private static function luminance(string $hex): float
{
[$r, $g, $b] = array_map(function (string $pair): float {
$c = hexdec($pair) / 255;
return $c <= 0.04045 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
}, str_split(ltrim($hex, '#'), 2));
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}
}
```
This is the WCAG relative luminance formula, which is why we validated six-digit hex.
**The brand color on the page.** Focus rings, links and active menu items put the brand color directly on the background, and WCAG asks for 3:1 for UI elements like these. A bright yellow like `#facc15` on white is about 1.5:1. We don't block the save. The settings form shows a warning when `ColorContrast::ratio($primary, '#ffffff') < 3` and suggests a darker shade.
### Dark mode
Run the same check against your dark background. A navy like `#1e3a8a` is under 2:1 against `#0a0a0a`, so it almost disappears. That's what the optional `brand_primary_dark` is for. If it's empty and the main color fails 3:1 on dark, fall back to your default dark palette color. A second logo for dark backgrounds helps too. How the `.dark` class gets on the page without a white flash is covered in [Laravel Inertia dark mode](/blog/laravel-inertia-dark-mode.html).
## A branded login page on every domain
In a database-per-tenant app, the login page lives on the tenant's domain, so tenancy is initialized before it renders. The `branding` prop is there for guests too, and the login page shows the customer's logo and name before anyone signs in. On the central domain `current()` returns `null` and your own brand stays.
- **Unknown subdomains should 404.** Don't render a default-branded login page for a domain that belongs to nobody.
- **Guests can see the prop.** Keep it to public branding fields. No plan names or user counts.
## Favicon and browser title
The favicon is the most visible thing in a browser tab, and the one people forget:
```blade
@if ($brand['iconUrl'] ?? null)
@else
@endif
```
Browsers cache favicons hard, so random file names help here too. For the title, set `app.name` per tenant when tenancy initializes.
## Branded emails and PDFs
A white label SaaS breaks the moment a password reset arrives with your logo on it.
**Emails.** Publish Laravel's mail components (`php artisan vendor:publish --tag=laravel-mail`) and put the tenant's logo and name in the header. Use PNG and an absolute HTTPS URL, since several major email clients don't show SVG. Emails go out from a queue worker with no request host, so `tenant_asset()` would build the URL on `APP_URL`. Build it from the tenant's primary domain instead, or store logos on S3 with public URLs. More on tenant names, queues and languages in emails in [Laravel SaaS transactional emails](/blog/laravel-saas-transactional-emails.html).
**PDFs.** Don't make the PDF renderer fetch the logo over HTTP: dompdf blocks remote files by default, and a headless browser would need access to the tenant's domain. Embed it as a data URI in the tenant context:
```php
$logo = $tenant->brand_logo
? 'data:image/png;base64,'.base64_encode(Storage::disk('public')->get($tenant->brand_logo))
: null;
```
## Custom domains finish the white label Laravel app
The last step of a white label Laravel app is the address bar. With stancl/tenancy a custom domain is another row in the `domains` table, and the branding follows automatically because it's keyed on the tenant, not the host. The hard parts are verifying ownership and issuing TLS certificates, covered in [custom domains for tenants](/blog/laravel-tenant-custom-domains.html).
## Make "Powered by" removal a paid feature
A "Powered by YourApp" line on the login page and footer is free marketing, and removing it is a classic upsell. We define it as a Pennant feature scoped to the tenant:
```php
Feature::define('white-label', fn (Tenant $tenant) => in_array($tenant->plan, ['business', 'agency'], true));
```
Scope it to the tenant, not the user: the login page has no user, and the line must be gone there too. The `poweredBy` field above already reads this flag. Our [Laravel Pennant guide](/blog/laravel-pennant-feature-flags.html) explains where the features table lives in a database-per-tenant app, and [Stripe subscriptions for a SaaS](/blog/laravel-saas-stripe-billing.html) shows how a plan gets onto the tenant. Put the branding settings page behind the same flag.
## Mistakes we see
- **Accepting any color string.** `red; background: url(...)` inside a `