Framework guides
Laravel 13 custom domains guide
Versions
When your Laravel app hosts a website or publication for a customer, they may want it to live on their own domain. Custom domains let them share the pages you host using their own web address, while you keep running the application.
In this guide, we’ll add that feature to an existing Laravel app. You’ll first match two local addresses to different customers, then save domains and connect DNS and HTTPS. We’ll finish with how to serve each customer’s pages and remove a domain safely.
You’ll use your existing customer accounts, login, and page templates. The examples are small enough to adapt to your app, whether your customers are organized as accounts, teams, or something else.
In this guide
1. Match a hostname to a customer locally
Laravel exposes the request’s Host through $request->getHost(). Add a small preview route with middleware that matches each hostname to customer data. This lets you test the feature alongside your existing routes.
Start with two .test names. Run php artisan make:middleware ResolveLocalTenant, then replace the generated handle method with this temporary map.
app/Http/Middleware/ResolveLocalTenant.php
The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.
public function handle(Request $request, Closure $next): Response
{
$tenants = [
'alpha.test' => ['name' => 'Alpha Publishing'],
'beta.test' => ['name' => 'Beta Studio'],
];
$host = strtolower(rtrim($request->getHost(), '.'));
abort_unless(isset($tenants[$host]), 404);
$request->attributes->set('tenant', $tenants[$host]);
return $next($request);
}
routes/web.php
use Illuminate\Http\Request;
Route::get('/__domain-preview', function (Request $request) {
return view('tenant', [
'tenant' => $request->attributes->get('tenant'),
]);
})->middleware('tenant.local');
Add the alias inside your existing withMiddleware callback in bootstrap/app.php:
$middleware->alias([
'tenant.local' => App\Http\Middleware\ResolveLocalTenant::class,
]);
Create resources/views/tenant.blade.php with a small preview. Keep Blade's normal escaped output:
<h1>{{ $tenant['name'] }}</h1>
If your app or web server filters allowed hosts, admit alpha.test, beta.test, and unknown.test for this check. Run the server in one terminal and the curl requests in another:
php artisan serve --host=127.0.0.1 --port=8084
curl --resolve alpha.test:8084:127.0.0.1 http://alpha.test:8084/__domain-preview
curl --resolve beta.test:8084:127.0.0.1 http://beta.test:8084/__domain-preview
curl --resolve unknown.test:8084:127.0.0.1 -i http://unknown.test:8084/__domain-preview
Checkpoint: Alpha and Beta show different content, while the unknown host returns 404. No DNS or certificate setup is needed.
2. Save each customer’s domains
Move the map into an app-owned table. The hostname is globally unique, and each row belongs to one tenant. A UUID generation gives delayed checks a stable identity even if database IDs can be reused elsewhere.
Schema::create('custom_domains', function (Blueprint $table): void {
$table->id();
$table->uuid('generation')->unique();
$table->foreignId('tenant_id')->constrained();
$table->string('hostname')->unique();
$table->string('status')->default('pending');
$table->timestamps();
});
Normalize before every read or write: trim and lowercase the hostname, remove one trailing dot, convert internationalized labels to ASCII, and reject schemes, paths, ports, IP addresses, wildcards, local names, and malformed labels.
Add a CustomDomain model with tenant() as a belongsTo relationship, and Tenant::domains() as hasMany. Allow mass assignment of generation, hostname, and status, then create through the signed-in tenant relationship. Laravel’s web middleware already checks the token produced by @csrf; keep the form on your normal app hostname.
public function store(Request $request): RedirectResponse
{
$tenant = $request->user()->tenant;
$hostname = DomainName::normalize(
$request->validate(['hostname' => ['required', 'string']])['hostname']
);
$tenant->domains()->create([
'generation' => (string) Str::uuid(),
'hostname' => $hostname,
'status' => 'pending',
]);
return back();
}
The browser does not send a trusted tenant ID. Scope every list, update, and removal through the authenticated tenant relationship.
Checkpoint: each customer can reserve a valid hostname, duplicates fail at the database, and cross-customer IDs do not grant access.
3. Connect DNS and HTTPS
The hostname is safely reserved in Laravel. The next step is to point it at the server, hosting platform, or reverse proxy that receives traffic for your app. Give the customer the exact DNS record from that setup.
- For a subdomain such as
docs.example.com, use a CNAME pointing at a stable hostname you operate. - The apex, or root, is
example.com. A records point to IPv4 addresses. To support customer apex domains, give customers a stable IPv4 address that keeps routing to your app. - Many DNS providers do not flatten CNAME records at the apex, so customer onboarding should not depend on that feature.
- Add an AAAA record when your receiving service also provides stable IPv6.
A reverse proxy is a server in front of Laravel that accepts browser traffic and forwards it to the app. Whether you use one or a hosting platform, it needs to accept the customer hostname and preserve the original Host header.
You’ll probably want to check that DNS points directly or indirectly to the service receiving traffic for your app. Tools and managed services can make DNS setup and HTTPS much easier; see the bonus section below for one option.
HTTPS is handled before Laravel receives the request. The receiving service must issue a certificate for every customer hostname and renew it before it expires. Once DNS reaches that service and the certificate is ready, your server-side integration can move the row from pending to active.
curl -I https://customer.example
Checkpoint: the customer hostname reaches your receiving service over HTTPS, with its original Host intact.
4. Serve the customer’s pages
Replace the temporary map with an exact Eloquent lookup. Unknown, pending, and removing hosts must return 404 rather than falling back to a default tenant.
app/Http/Middleware/ResolveTenantDomain.php
public function handle(Request $request, Closure $next): Response
{
$hostname = DomainName::normalizeRequestHost($request->getHost());
$domain = CustomDomain::query()
->with('tenant')
->where('hostname', $hostname)
->where('status', 'active')
->firstOrFail();
$request->attributes->set('tenant', $domain->tenant);
$response = $next($request);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('Vary', 'Host');
return $response;
}
Have normalizeRequestHost share the configured-domain canonicalization from step two, including lowercase, IDNA conversion, label checks, and trailing-dot removal. Generate this middleware as in step one and register it as tenant.domain in the same alias map. Replace the preview route with the public catch-all below, after your normal app routes. Set domains.control_host in your config to your usual app hostname.
Route::domain(config('domains.control_host'))
->middleware('auth')
->group(function (): void {
Route::get('/domains', [DomainController::class, 'index']);
Route::post('/domains', [DomainController::class, 'store']);
Route::delete('/domains/{domain}', [DomainController::class, 'destroy']);
});
Route::middleware('tenant.domain')
->get('/{path?}', function (Request $request) {
return view('tenant', [
'tenant' => $request->attributes->get('tenant'),
]);
})->where('path', '.*');
The fixed Route::domain keeps management endpoints off customer hosts, while the public catch-all can still serve assets and customer paths. Hostname lookup selects public content; it never authorizes control actions.
These headers prevent shared response caching. If you add an application cache, include the normalized hostname in every key; Vary does not change keys inside your own cache. Build absolute URLs from the saved domain, and leave Laravel session cookies host-only unless you deliberately need a wider scope.
Checkpoint: active hostnames render the right Blade view; unknown, pending, and removing names return 404.
5. Remove a domain safely
Disable routing before asking your hosting layer to detach the hostname and certificate. Keep the unique row while cleanup runs so the name cannot be claimed during a retry.
$domain = $tenant->domains()
->where('hostname', DomainName::normalize($input))
->firstOrFail();
$started = $domain->status === 'removing'
|| CustomDomain::query()
->whereKey($domain->id)
->where('tenant_id', $tenant->id)
->where('generation', $domain->generation)
->whereIn('status', ['pending', 'active'])
->update(['status' => 'removing']) === 1;
abort_unless($started, 409, 'Domain changed; reload and try again.');
// Explicit integration point; make this operation safe to retry.
$hosting->removeHostname($domain->hostname);
$hosting is your adapter for the chosen platform, not a Laravel service. Treat timeouts as unknown outcomes, leave the status at removing, and retry the same operation.
After infrastructure confirms it no longer accepts the hostname, delete only where row ID, tenant ID, generation, hostname, and removing status still match. Those conditions keep delayed responses away from a replacement row.
Checkpoint: removal immediately returns 404, retries keep the domain reserved, and the row is released only after cleanup is confirmed.
Bonus: let Approximated handle DNS onboarding, HTTPS, and forwarding
The five steps above work with any infrastructure that can accept customer hostnames. Approximated can handle that part: accepting the domain, forwarding requests, issuing and renewing certificates, checking the connection, and returning DNS instructions.
Your Laravel app still owns customer authorization, the domain-to-content mapping, and activation policy. Approximated calls its domain-to-app mapping a virtual host.
Create the virtual host
After the row is reserved, add a nullable provider_id to it and call the API with Laravel’s server-side HTTP client. Keep the key out of Blade and browser code, use a public app hostname, and set a short timeout.
$apiKey = config('services.approximated.key');
$target = config('domains.target_address');
throw_if(blank($apiKey) || blank($target), LogicException::class, 'Missing server config.');
$hostname = DomainName::normalize($domain->hostname);
$response = Http::baseUrl('https://cloud.approximated.app')
->acceptJson()
->withHeaders(['api-key' => $apiKey])
->timeout(10)
->post('/api/vhosts', [
'incoming_address' => $hostname,
'target_address' => $target,
'target_ports' => '443',
'keep_host' => true,
]);
$response->throw();
$payload = $response->json();
$data = is_array($payload) && is_array($payload['data'] ?? null)
? $payload['data']
: null;
$validIdentity = is_array($data)
&& is_int($data['id'] ?? null)
&& $data['id'] > 0
&& is_string($data['incoming_address'] ?? null)
&& DomainName::normalize($data['incoming_address']) === $hostname;
throw_unless($validIdentity, UnexpectedValueException::class, 'Unexpected virtual-host identity.');
$attached = CustomDomain::query()
->whereKey($domain->id)
->where('tenant_id', $domain->tenant_id)
->where('generation', $domain->generation)
->where('hostname', $hostname)
->where('status', 'pending')
->whereNull('provider_id')
->update(['provider_id' => $data['id']]) === 1;
abort_unless($attached, 409, 'Domain changed; reload and try again.');
The conditional update attaches the confirmed ID only to the same row, tenant, generation, normalized hostname, pending state, and empty provider slot. If creation fails, times out, returns malformed data, or loses that race, keep the reservation. Never find and adopt or delete an uncertain resource by hostname.
Show DNS and connection progress
Approximated returns the DNS instructions in user_message; show it as plain text using Blade’s normal escaped output. Approximated checks DNS and HTTPS automatically. Read GET /api/vhosts/{id} with the confirmed stored ID for the latest saved results; this does not start a fresh check. Keep absent monitor values as unknown:
is_resolvingmeans an HTTP response was observed, possibly from another server.apx_hitmeans traffic reached Approximated.has_sslmeans TLS is ready for this hostname.
Optional fields may be absent, but reject them when present with the wrong type: status and message fields must be strings, monitor fields must be boolean or null, and dns_pointed_at must be a string or null.
Activate only while the mapping still has its confirmed provider ID and hostname, and both apx_hit and has_ssl are strictly true. Keep is_resolving separate: it means an HTTP response was observed somewhere, while apx_hit confirms that traffic reached Approximated.
Remove by the confirmed ID
Set the local row to removing, then call DELETE /api/vhosts/{id}. Its successful plain-text response starts asynchronous removal. Keep the reservation until GET for the same ID returns 404.
On an error or timeout, keep routing disabled and retry DELETE for that ID. Bind delayed refreshes and final local deletion to the original row, tenant, generation, hostname, provider ID, and status.
Complete Approximated example
The Laravel custom domains companion includes a two-tenant app, SQLite store, Laravel HTTP adapter, local transport tests, CSRF and tenant guards, status display, and retry-safe removal.
git clone https://github.com/Approximated-Inc/laravel-custom-domains-example.git
cd laravel-custom-domains-example
cp .env.example .env
docker compose build app
docker compose run --rm app composer install --no-interaction
docker compose run --rm app php artisan key:generate
docker compose run --rm app php artisan migrate:fresh --seed
docker compose up app
See the virtual hosts API documentation for the adapter’s request and response fields.
Checkpoint: Approximated supplies DNS instructions, checks the connection, and manages HTTPS while Laravel keeps authorization and routing decisions.