Tutorial

Laravel Custom Domains Guide

A tutorial guide for adding custom domains as a feature to your Laravel app, with an example github repo.

This guide is meant to show you how to allow users of your Laravel app to connect their own domains and load up content specific to them. Custom domains - sometimes called vanity domains, whitelabel domains, or customer domains - are a key feature of many apps.

If you need to manage custom domains with SSL for SAAS, marketplaces, blog hosts, or other platforms, then this is the guide for you.

What you'll learn in this guide:

  • Domains. We'll briefly go over web domains, how they work, and how they can be used as custom domains with your Laravel app.
  • Networking. For your Laravel app to handle custom domain requests, they first need to reach your server(s).
  • Routing. Once the request reaches your Laravel app, you'll likely want to route it differently than other requests.
  • Middleware. In Laravel, middleware can help authorize or filter out requests for custom domains.
  • Controllers. Returning the right responses for custom domains. We recommend using separate controllers dedicated to custom domains in Laravel.
  • Views. You may want to have a different layout or other modifications for custom domains at the Laravel view layer.
  • Bonus! How to automate the networking and SSL management easily with Approximated.

Web domains and how they work

You're likely pretty familiar with web domains, but we'll go over a few of the specifics of how domains work that can sometimes cause confusion.

Registrars and ownership

Domains are bought and managed with registrars like Hover, GoDaddy, Namecheap, and many others. This is where a domain is officialy registered and may or may not be where it's DNS is managed. For our purposes, we can assume that it will be the user that owns their own domain, which they would like to point at your app.

DNS and pointing domains

Domains don't do much unless they're pointed at a target server. This is done with DNS, which tells devices where to send requests for a domain, using DNS records. These records are set at the domain's DNS provider, which may or may not be the same as the registrar.

Apex Domains vs Subdomains

Apex domains are the root, naked domain with no subdomain attached. For instance, example.com is an apex domain.

A subdomain is what we call it when we prepend something to an apex domain. For example, sub.example.com or sub2.sub1.example.com are both subdomains.

Technically, subdomains are domains. For the purpose of this guide though, when we use the word domain, we're referring to an apex domain.

SSL, TLS, and encrypting requests

When a request is sent out over the internet, there are various points where it's contents could be inspected and tampered with unless they're encrypted.

This is especially important for any private data such as ecommerce or personal information, and may be legally required in many cases. It's generally a very good idea for all requests to be encrypted, in order to prevent censorship, request injections, snooping and other tampering.

SSL certificates - which are technically TLS certificates now, but often still called by the old name - are the standard by which browsers and other applications encrypt these requests. Using them requires a server with a provisioned SSL certificate for each domain. We'll go more into detail on this in the networking section below.

Networking and SSL for custom domains on Laravel

In order for your app to serve custom domains, the requests for each custom domain need to be networked to reach your server(s). For those requests to be secured, you'll also need to provide and manage an SSL certificate for each custom domain.

Networking - getting the request to your server

Each custom domain will need to have DNS records set to point requests at your app - either directly or indirectly. There are two options for how you can go about this, each with significant trade-offs that you should know about:

  1. DNS A records pointed at an IPv4 address

    DNS A records can only be pointed at an IPv4 address, formatted as x.x.x.x, where each x can be any value between 0 and 255.

    It's important to note that apex domains - those with no subdomain (including www) - can only be pointed with A records. They cannot be pointed with CNAME records.

    This is the easiest and most universal method but also carries the risk that if you ever need to change that IPv4 address, you'll need to have each user change their DNS individually.

  2. CNAME records pointed through an intermediate domain

    CNAME records cannot point an apex domain and cannot point at an IPv4 address, but they can point subdomains at any other domain or subdomain.

    For example, you could point subdomain.example.com at test.com and requests would be sent to wherever test.com is pointing.

    If it's acceptable in your use case to require custom domains have a subdomain, then you can do the following:

    • Point the A record for a subdomain that you own at your server IPv4 address.
    • When a custom domain is created, have your user point a CNAME record at the subdomain above.
    • Requests for the custom domain will reach wherever the intermediate subdomain is pointed.
    • This has the advantage of allowing you to re-point all of the custom domains at once, by changing the A record for the intermediate subdomain.
    • The disadvantage here is that those custom domains cannot be apex domains and must have a subdomain.

SSL Certificates - securing many custom domains

You've likely secured your app already with SSL certificates, but unfortunately those will only apply to requests going directly to that domain. SSL certificates cannot be shared amongst apex domains.

You're going to need SSL certificates for each individual custom domain that points at your app, to encrypt requests as well as ensure security and privacy for that domain specfically.

In order to do that you'll need an automated system to provision SSL certificates automatically when custom domains are created, including:

  • Requesting the generation of an SSL certificate from a Certificate Authority.
  • Accepting and appropriately completing the verification challenge from the Certficate Authority.
  • Renewing within a reasonable window before the certificate expiration date (typically 3 months).
  • Avoiding going over rate limits with Certificate Authorities.
  • Checking for certificates that have been revoked early by Certificate Authorities and replacing them.
  • Monitoring all of it to catch and avoid the many edge cases and unexpected issues that can cause customer facing downtime.

For those that absolutely need to build this in-house, we recommend Caddy as your starting point. However, this is a very complex topic and could easily be many guides worth of content without even covering everything.

Building SSL management and networking systems in-house for more than a few domains is extremely non-trivial even with the help of amazing tools like Caddy. The potential pitfalls are many and the risk of customer facing downtime or security vulnerabilities is reasonably high without considerable experience.

To keep this tutorial from exploding in length, we won't be covering building this in-house here. If you'd prefer to have a third party service handle this for you, please see our bonus section below.

Routing custom domains within a Laravel app

Once requests for custom domains are able to reach your Laravel app, we'll probably want to handle them separately from other routes within the app.

For instance, a blog platform likely wants to display the blog that custom domain is tied to instead of your app home page. This section will explain how you can do that.

Grouping routes for custom domains in the Laravel routers

Most Laravel apps are going to have a web router located at /routes/web.php, and may have others in the routes folder for things like API, auth, and more. We're going to focus on the web routes here, but this could be applied to any of the others as well.

When a request from a browser comes through to your Laravel app, it most likely hits the web router. Inside of here, we can group routes together by domain (for your existing non-custom domain routes) and using a custom domains middleware we'll write.

// in /routes/web.php

// Group your primary routes using the Route::domain() feature.
// You can also set the domain dynamically with i.e. env('PRIMARY_DOMAIN')
// instead of the string "myapp.com".
Route::domain("myapp.com")->group(function () {
	// Routes you want available on your primary domain.
	Route::get('/dashboard', function () {
			return view('dashboard');
	})->middleware(['auth', 'verified'])->name('dashboard');

	// you can also nest other route groups inside of here, like this:
	Route::middleware('auth')->group(function () {
		Route::get('/', [ProfileController::class, 'edit'])->name('profile.edit');
		Route::get('/profile/show', [ProfileController::class, 'show'])->name('profile.show');
		Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
		Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
		Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
	});
});

// Routes for custom domains can go here (they must be BELOW the domain group above).
// We'll explain the custom_domains middleware below.
Route::middleware('custom_domains')->group(function () {
    Route::get('/', [PublicProfileController::class, 'show'])->name('public_profile.show');
    Route::get('/page/{page}', [PublicProfileController::class, 'page'])->name('public_profile.page');
});

The easiest thing to do here is to put all of your existing routes into a domain grouping that matches your primary domain, and then have a separate group for custom domains. Depending on your app you may want to get more sophisticated than this, but this is a good place to start.

With this approach, you can cleanly separate out which routes are available to custom domains and which are available to your primary domain. It also means that you can use the same route paths but do different things for custom domains vs your primary domain.

Using Laravel middleware to authorize custom domains

Middleware in Laravel can be run when a request comes in, before it hits your controller, and it's a great fit for handling custom domains.

We can use middleware to do things like authorize requests for a custom domain, inject something into the request based on the custom domain, or practicaly anything else we might want to do.

For our example here, we're going to create a middleware named CustomDomains. It's going to pass requests to the primary domain through without any changes so that we don't affect non-custom domain features.

When a request for a custom domain comes in, we're going to check it against a record in the database (a user in this case) and 404 if we don't have anything tied to that custom domain.

// in /app/Http/Middleware/CustomDomains.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Models\User;

class CustomDomains
{
  /**
    * Handle an incoming request.
    *
    * @param  \Illuminate\Http\Request  $request
    * @param  \Closure  $next
    * @return mixed
    */
  public function handle($request, Closure $next)
  {
    /** 
    * Check if there's a header with the custom domain, 
    * and if not just use the host header.
    * If you're using approximated.app the default is to 
    * inject the header 'apx-incoming-host' with the custom domain.
    */
    $domain = $request->hasHeader('apx-incoming-host') ? $request->header('apx-incoming-host') : $request->host();
    $primaryDomain = env('APP_PRIMARY_DOMAIN');
    
    if($domain == $primaryDomain) {
        // This is a request to the primary domain, not a custom domain
        return $next($request);
    }

    $user = User::where('custom_domain', $domain)->firstOrFail();

    if (!$user) {
        abort(404);
    }

    // Append domain and user to the Request object
    // for easy retrieval in the application.
    
    $request->merge([
        'domain' => $domain,
        /** 
        * In your own app, the user might be another entity instead, like a blog, store, etc.
        * In this example, we're tying it to a user to display their user profile on a custom domain.
        */
        'user' => $user
    ]);

    return $next($request);
  }
}

For convenience, we're also going to give this middleware an alias of 'custom_domains' in Kernel.php:

// in /app/Http/Kernel.php
protected $middlewareAliases = [
  'auth' => \App\Http\Middleware\Authenticate::class,
  // ...other existing middleware aliases
  'custom_domains' => \App\Http\Middleware\CustomDomains::class,
];

If you look back at the web routes code above, you'll see that we used this middleware in the route group for custom domains.

Handling custom domain requests with Laravel controllers

Once a request has gone through the router and middleware, it should arrive at whatever controller and function you've set in the router.

We recommend splitting out custom domain specific features into their own controllers to make things conceptually simple. It also helps you avoid "crossing wires" and accidentally handling a request for the primary domain with custom domain logic, or vice versa.

For the purposes of our example, we're going to have a PublicProfileController that displays a user's profile in read-only mode on a custom domain. This assumes that we have a user entity with a domain field containing a custom domain.

// in /app/Http/Controllers/PublicProfileController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PublicProfileController extends Controller
{
  public function show(Request $request){
    return view('public_profile.show', [
        'user' => $request->user,
    ]);
  }

  public function page(Request $request, $page){
    return view('public_profile.page', [
        'user' => $request->user,
        'page' => $page,
    ]);
  }
}

This could be any controller and could use any logic you want, this is only an example. For instance, you could have a controller for rendering a view for a store front, or for handling API calls specific to custom domains.

Customising views in Laravel for custom domains

This isn't specific to custom domains, but it's a pretty common use case to need to use a different layout for custom domains instead of the one you use for the primary domain. We'll show you how to do that here.

First we can set the layout that a template extends in the template itself. Note the first @extends line and the @section that tells the layout we're extending where to put content.

// in /resources/views/public_profile/show.blade.php
@extends('layouts.public_profile_layout')
@section('main')
  <div class="max-w-7xl mx-auto sm:p-6 lg:p-8 space-y-6 mt-24 bg-white shadow sm:rounded-lg">
  <div class="text-xl">This should only load on {{ $user->custom_domain }}</div>
  <div class="text-xl">The user's name is {{ $user->name }}</div>
  </div>
@endsection

And here is the layout template that extends:

// in /resources/views/layouts/public_profile_layout.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
	<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ $user->name.' - '.config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />

    <!-- Scripts -->
    @vite(['resources/css/app.css', 'resources/js/app.js'])
	</head>
	<body class="font-sans antialiased">
    <div class="min-h-screen bg-gray-100">
      @include('layouts.public_profile_navigation')

      <!-- Page Heading -->
      @if (isset($header))
        <header class="bg-white shadow">
          <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
            {{ $header }}
          </div>
        </header>
      @endif

      <!-- Page Content -->
      <main>
          @yield('main')
      </main>
    </div>
	</body>
</html>

This is everything we need to render templates completely customised for custom domains. You can get as sophisticated with this as you need and use all of the many Laravel view features you'd normally have!

Bonus: Automating networking and SSL with Approximated

Remember how we said above that building a system for managing SSL certificates at scale is difficult, expensive, and error prone? Well, we know that from personal (painful) experience.

It's why we built Approximated - so that you don't have to. And at a price you can afford, starting at $20/month instead of thousands.

Approximated is your fully managed solution for automating custom domains and SSL certificates. We stand up a dedicated reverse proxy cluster, with edge nodes globally distributed around the world, for each and every customer.

You get a dedicated IPv4 address with each cluster so that you have the option of allowing your users to point their apex domains. Custom domain traffic goes through the cluster node nearest your user and straight to your app.

The best part? Your cluster will provision and fully manage SSL certificates automatically along the way. No need to worry about them ever again.

All you need to do is call our simple API from your app when you want to create, read, update, or delete a custom domain and it will handle the rest.

So what does that look like in our example so far?

The controller for updating the user profile looks like this:

// in /app/Http/Controllers/ProfileController.php

namespace App\Http\Controllers;

use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
use App\Services\Approximated;

class ProfileController extends Controller
{
    /**
      * Display the user's profile form.
      */
    public function edit(Request $request): View
    {
        return view('profile.edit', [
            'user' => $request->user(),
        ]);
    }

    public function show(Request $request): View
    {
        return view('profile.show', [
            'user' => $request->user(),
        ]);
    }

    /**
      * Update the user's profile information.
      */
    public function update(ProfileUpdateRequest $request): RedirectResponse
    {
        $current_domain = $request->user()->custom_domain;
        $request->user()->fill($request->validated());

        if ($request->user()->isDirty('email')) {
            $request->user()->email_verified_at = null;
        }

        $apx = new Approximated;

        // These nested ifs below are gross but easier to understand for the example
        
        // did the custom domain field change?
        if($request->filled('custom_domain')){
            // was there a current domain to update in our DB? 
            // If yes, we want to update on Approximated, not create.
            if ($current_domain) {
                // double check the vhost exists on Approximated
                $vhost_check = $apx->get_vhost($current_domain);
                if($vhost_check['success']){
                    // It exists, update it
                    $apx->update_vhost($current_domain, ['incoming_address' => $request->input('custom_domain')]);
                }else{
                    // It doesn't exist, create it
                    $apx->create_vhost($request->user()->incoming_address, env('APP_PRIMARY_DOMAIN'));
                }
            }else{
                // No previous custom domain, create one
                $apx->create_vhost($request->user()->incoming_address, env('APP_PRIMARY_DOMAIN'));
            }
        }elseif($current_domain){
            // They've blanked the custom domain, and there was one previously.
            // Delete the vhost on Approximated.
            $apx->delete_vhost($current_domain);
        }

        $request->user()->save();


        return Redirect::route('profile.edit')->with('status', 'profile-updated');
    }

    /**
      * Delete the user's account.
      */
    public function destroy(Request $request): RedirectResponse
    {
        $request->validateWithBag('userDeletion', [
            'password' => ['required', 'current_password'],
        ]);

        $user = $request->user();

        Auth::logout();

        $user->delete();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return Redirect::to('/');
    }
}

And this is what the Approximated service referenced above looks like with full doc blocks:

// in /app/Http/Controllers/ProfileController.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class Approximated {

    public $api_url = "https://cloud.approximated.app/api";


    /**
      * Create a virtual host.
      *
      * @param datatype $incoming_address The incoming address.
      * @param datatype $target_address The target address.
      * @param array $opts Optional fields. See https://approximated.app/docs/#create-virtual-host for more details.
      * @return array 
      * [
      *     "success" => true,
      *     "data" => [
      *         "id": 445922,
      *         "incoming_address": "acustomdomain.com",
      *         "target_address": "myapp.com",
      *         "target_ports": "443",
      *         "user_message": "In order to connect your domain, you'll need to have a DNS A record that points acustomdomain.com at..."
      *     ]
      * ]
      * @return array  
      * [
      *     "success" => false,
      *     "errors" => [
      *         "incoming_address" => [
      *                 "This incoming address has already been created on the reverse proxy server you selected.",
      *         ],
      *     ],
      * ]
      */
    public function create_vhost($incoming_address, $target_address, $opts = []) 
    {
        // see https://approximated.app/docs/#create-virtual-host for optional fields
        $data = array_merge($opts, [
                    'incoming_address' => $incoming_address,
                    'target_address' => $target_address,
                ]);
                
        $response =  Http::withHeaders(['api-key' => env('APPROXIMATED_API_KEY')])
                        ->post($this->api_url . "/vhosts", $data);


        return $this->handle_response($response);
    }

    /**
      * Updates a virtual host.
      * Any fields not passed into options will remain the same.
      *
      * @param datatype $current_incoming_address The current incoming address.
      * @param array $opts Optional fields. See https://approximated.app/docs/#update-virtual-host for more details.
      * @return array 
      * [
      *     "success" => true,
      *     "data" => [
      *          "apx_hit" => true, // requests are reaching the cluster
      *          "created_at" => "2023-04-03T17:59:28", // UTC timezone
      *          "dns_pointed_at" => "213.188.210.168", // DNS for the incoming_address
      *          "has_ssl" => true,
      *          "id" => 405455,
      *          "incoming_address" => "adifferentcustomdomain.com",
      *          "is_resolving" => true, // is this returning a response
      *          "last_monitored_humanized" => "1 hour ago",
      *          "last_monitored_unix": 1687194590,
      *          "ssl_active_from" => "2023-06-02T20:19:15", // UTC timezone
      *          "ssl_active_until" => "2023-08-31T20:19:14", // UTC timezone, auto-renews
      *          "status": "ACTIVE_SSL",
      *          "status_message" => "Active with SSL",
      *          "target_address" => "myapp.com",
      *          "target_ports" => "443"
      *     ]
      * ]
      * @return array  
      * [
      *     "success" => false,
      *     "errors" => [
      *         "incoming_address" => [
      *                 "This incoming address has already been created on the reverse proxy server you selected.",
      *         ],
      *     ],
      * ]
      */
    public function update_vhost($current_incoming_address, $opts = []) 
    {
        // see https://approximated.app/docs/#update-virtual-host for optional fields
        $data = array_merge($opts, [
                    'current_incoming_address' => $current_incoming_address
                ]);

        $response =  Http::withHeaders(['api-key' => env('APPROXIMATED_API_KEY')])
                        ->post($this->api_url . "/vhosts/update/by/incoming", $data);


        return $this->handle_response($response);
    }
    
    /**
      * Gets a virtual host.
      *
      * @param datatype $incoming_address The incoming address.
      * @return array 
      * [
      *     "data" => [
      *          "apx_hit" => true, // requests are reaching the cluster
      *          "created_at" => "2023-04-03T17:59:28", // UTC timezone
      *          "dns_pointed_at" => "213.188.210.168", // DNS for the incoming_address
      *          "has_ssl" => true,
      *          "id" => 405455,
      *          "incoming_address" => "adifferentcustomdomain.com",
      *          "is_resolving" => true, // is this returning a response
      *          "last_monitored_humanized" => "1 hour ago",
      *          "last_monitored_unix": 1687194590,
      *          "ssl_active_from" => "2023-06-02T20:19:15", // UTC timezone
      *          "ssl_active_until" => "2023-08-31T20:19:14", // UTC timezone, auto-renews
      *          "status": "ACTIVE_SSL",
      *          "status_message" => "Active with SSL",
      *          "target_address" => "myapp.com",
      *          "target_ports" => "443"
      *     ]
      * ]
      */
    public function get_vhost($incoming_address) 
    {
        $response =  Http::withHeaders(['api-key' => env('APPROXIMATED_API_KEY')])
                        ->get($this->api_url . "/vhosts/by/incoming/".$incoming_address);


        return $this->handle_response($response);
    }

    /**
      * Deletes a vhost by incoming address.
      *
      * @param mixed $incoming_address The incoming address of the vhost to be deleted.
      * @return array ["success" => true, "data" => null]
      */
    public function delete_vhost($incoming_address) 
    {
        $response =  Http::withHeaders(['api-key' => env('APPROXIMATED_API_KEY')])
                        ->delete($this->api_url . "/vhosts/by/incoming/".$incoming_address);


        return $this->handle_response($response);
    }

    private function handle_response($response) {
        // return success and the vhost data if successful
        if($response->successful()) {
            return array_merge(["success" => true], $response->json() || ["data" => null]);    
        }

        if($response->notFound()){
            return ["success" => false, "data" => "Not Found."];
        }

        // There's an issue with the data we sent (duplicate incoming address, missing fields, etc.)
        // returns success as false and the data will be the errors.
        if($response->unprocessableEntity()){
            return array_merge(["success" => false], $response->json());  
        }

        // otherwise throw if there was an error
        $response->throw();
    }
}

Between these two files, we're updating a domain field in the user entity, and when we do we're using the Approximated service to call the Approximated API. That's all there is to it - you'll have fully automated custom domains from now on!