← back home

building a domain management system in laravel

when building a saas platform, one feature that often comes up is custom domains. users want to use their own domains instead of subdomains on your platform. here's a complete implementation of a domain management system that handles both internal subdomains and external custom domains.

overview

the system supports two types of domains:

  • internal: subdomains of the platform's short domain (e.g., example.pageultime.localhost)
  • external: custom domains owned by the team (e.g., example.com)

it validates domain names, manages domain-page relationships, and ensures domains cannot be deleted when they have associated pages.

database schema

main migration

// database/migrations/2022_03_05_141022_create_domains_table.php
<?php

use App\Models\Domain;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('domains', function (Blueprint $table) {
            $table->id();
            $table->uuid();
            $table->foreignId('team_id')
                ->constrained(Config::get('teamwork.teams_table'))
                ->cascadeOnDelete();
            $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete();
            $table->string('name')->unique();
            $table->string('description')->nullable();
            $table->enum('type', array_values(Domain::TYPES))->index();
            $table->boolean('is_enabled')->default(false);
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('domains');
    }
};

add verified column

// database/migrations/2022_05_31_004320_add_verified_column_on_domains_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::table('domains', function (Blueprint $table) {
            $table->boolean('is_verified')->default(false)->after('type');
        });
    }

    public function down()
    {
        Schema::table('domains', function (Blueprint $table) {
            $table->dropColumn('is_verified');
        });
    }
};

add soft deletes

// database/migrations/2022_12_14_223651_add_soft_delete_on_domains_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::table('domains', function (Blueprint $table) {
            $table->softDeletes();
        });
    }

    public function down()
    {
        Schema::table('domains', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
};

schema summary

column type constraints description
id bigint primary key, auto_increment internal id
uuid char(36) unique public identifier
team_id bigint foreign key, not null owner team
author_id bigint foreign key, nullable creator user
name varchar(255) unique, not null domain name
description varchar(255) nullable optional description
type enum not null, indexed internal or external
is_enabled boolean default false whether domain is active
is_verified boolean default false whether domain is verified
created_at timestamp nullable creation timestamp
updated_at timestamp nullable update timestamp
deleted_at timestamp nullable soft delete timestamp

the domain model

// app/Models/Domain.php
<?php

namespace App\Models;

use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Domain extends Model
{
    use HasFactory, Uuid, SoftDeletes;

    const INTERNAL_TYPE = 'internal';
    const EXTERNAL_TYPE = 'external';

    const TYPES = [
        'INTERNAL' => self::INTERNAL_TYPE,
        'EXTERNAL' => self::EXTERNAL_TYPE,
    ];

    protected $fillable = [
        'name',
        'description',
        'type',
        'is_enabled',
        'is_verified',
        'team_id',
        'author_id',
    ];

    protected $casts = [
        'is_enabled' => 'boolean',
        'is_verified' => 'boolean',
    ];

    protected $hidden = [
        'id',
        'team_id',
        'author_id',
    ];

    /**
     * Get regex pattern for validating domain names
     * Matches: http://example.com, https://example.com, example.com
     */
    public static function domainNameRegex()
    {
        $protocol = "https?:\/\/";
        $domainName = "(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+";
        return "/^(?:{$protocol})?({$domainName})\/?$/i";
    }

    /**
     * Check if domain has associated pages
     */
    public function hasPages()
    {
        return $this->pages()->count() > 0;
    }

    /**
     * Relationship: Domain belongs to a Team
     */
    public function team()
    {
        return $this->belongsTo(Team::class, 'team_id');
    }

    /**
     * Relationship: Domain created by a User
     */
    public function author()
    {
        return $this->belongsTo(User::class, 'author_id');
    }

    /**
     * Relationship: Domain has many Pages
     * Note: Pages reference domain by name, not ID
     */
    public function pages()
    {
        return $this->hasMany(Page::class, 'domain', 'name');
    }
}

key model features:

  1. uuid trait: uses HasUuids trait for public identification
  2. soft deletes: domains are soft-deleted, not permanently removed
  3. type constants: defines internal and external types
  4. domain name regex: static method for validation pattern
  5. relationships: belongs to team, belongs to user (author), has many pages (by name, not id)

validation rules

1. ValidDomainName rule

validates that the domain name format is correct and no segment starts or ends with a dash.

// app/Rules/ValidDomainName.php
<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Models\Domain;
use Illuminate\Support\Str;

class ValidDomainName implements Rule
{
    public function passes($attribute, $value)
    {
        return $this->passesDomainNameRegexValidation($value)
            && collect(explode('.', $value))->every(function($part) {
                return $this->passesDomainNameDoesntStartOrEndWithDash($part);
            });
    }
        
    public function passesDomainNameRegexValidation($value)
    {
        return (bool) preg_match(Domain::domainNameRegex(), $value);
    }
    
    public function passesDomainNameDoesntStartOrEndWithDash($value)
    {
        return !Str::startsWith($value, '-') && !Str::endsWith($value, '-');
    }
    
    public function message()
    {
        return __('The domain name is invalid');
    }
}

2. ReservableDomainName rule

for internal domains: must be a subdomain of allowed internal domains. for external domains: must not conflict with internal or reserved domains.

// app/Rules/ReservableDomainName.php
<?php

namespace App\Rules;

use App\Models\Domain;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Config;

class ReservableDomainName implements Rule
{
    protected $type;

    public function __construct(string $type = Domain::INTERNAL_TYPE)
    {
        $this->type = $type;
    }

    public function passes($attribute, $value)
    {
        if ($this->type === Domain::INTERNAL_TYPE) {
            // For INTERNAL: must be a subdomain of allowed internal domains
            $internalDomains = collect(Config::get('domains.internal'));
            return $internalDomains->some(function ($internalDomain) use ($value) {
                $pattern = preg_quote($internalDomain);
                return preg_match("/^([a-zA-Z0-9-]+\.)+{$pattern}$/i", $value);
            });
        }

        // For EXTERNAL: must NOT match internal or reserved domains
        $domains = array_merge(
            Config::get('domains.internal'),
            Config::get('domains.reserved'),
        );

        return collect($domains)
            ->every(function ($domain) use ($value) {
                $pattern = preg_quote($domain);
                return !preg_match("/^([a-zA-Z0-9-]+\.)*{$pattern}$/i", $value);
            });
    }

    public function message()
    {
        return __('The domain name is reserved');
    }
}

3. DomainType rule

ensures type is either internal or external.

// app/Rules/DomainType.php
<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Models\Domain;

class DomainType implements Rule
{
    public function passes($attribute, $value)
    {
        return in_array($value, Domain::TYPES);
    }

    public function message()
    {
        return __('The domain type is not valid.');
    }
}

api routes

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/domains', [DomainController::class, 'index']);
    Route::post('/domains', [DomainController::class, 'store']);
    Route::get('/domains/{domain:uuid}', [DomainController::class, 'show']);
    Route::put('/domains/{domain:uuid}', [DomainController::class, 'update']);
    Route::delete('/domains/{domain:uuid}', [DomainController::class, 'destroy']);
});

uses {domain:uuid} for route model binding by uuid field.

request classes

StoreDomainRequest

// app/Http/Requests/StoreDomainRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Models\Domain;
use App\Rules\ValidDomainName;
use App\Rules\DomainType;
use App\Rules\ReservableDomainName;

class StoreDomainRequest extends FormRequest
{
    public function authorize()
    {
        return true; // Authorization handled in controller via Gate
    }

    public function rules()
    {
        return [
            'name' => [
                'required',
                'string',
                'unique:domains',
                new ValidDomainName,
                new ReservableDomainName($this->input('type'))
            ],
            'description' => 'nullable|string|max:512',
            'type' => ['required', new DomainType],
        ];
    }
}

UpdateDomainRequest

// app/Http/Requests/UpdateDomainRequest.php
<?php

namespace App\Http\Requests;

use App\Rules\ValidDomainName;
use App\Rules\DomainType;
use App\Rules\ReservableDomainName;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateDomainRequest extends FormRequest
{
    public function authorize()
    {
        return true; // Authorization handled in controller via Gate
    }

    public function rules()
    {
        $domainId = $this->route('domain')?->id;

        return [
            "name" => [
                "sometimes",
                "required",
                "string",
                Rule::unique('domains')->ignore($domainId),
                new ValidDomainName,
                $this->input('type') ? new ReservableDomainName($this->input('type')) : null,
            ],
            "description" => "nullable|string|max:512",
            "type" => [
                'sometimes',
                'required',
                new DomainType,
            ],
            "is_enabled" => ["nullable", "boolean"],
        ];
    }
}

the controller

// app/Http/Controllers/DomainController.php
<?php

namespace App\Http\Controllers;

use App\Models\Domain;
use App\Http\Requests\StoreDomainRequest;
use App\Http\Requests\UpdateDomainRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class DomainController extends Controller
{
    /**
     * List all domains for the current team
     */
    public function index(Request $request): JsonResponse
    {
        /** @var \App\Models\User $user */
        $user = $request->user();

        $domains = Domain::where('team_id', $user->current_team_id)
            ->withCount('pages')
            ->orderBy('name')
            ->get();

        return response()->json($domains);
    }

    /**
     * Create a new domain
     * 
     * Business Rules:
     * - INTERNAL domains are auto-verified and enabled
     * - EXTERNAL domains start as unverified and disabled
     */
    public function store(StoreDomainRequest $request): JsonResponse
    {
        /** @var \App\Models\User $user */
        $user = $request->user();

        $validatedData = $request->validated();

        $data = array_merge($validatedData, [
            'is_verified' => $validatedData['type'] === Domain::INTERNAL_TYPE,
            'is_enabled' => $validatedData['type'] === Domain::INTERNAL_TYPE,
            'team_id' => $user->current_team_id,
            'author_id' => $user->id,
        ]);

        $domain = Domain::create($data);
        $domain->pages_count = 0;
        $domain->load('author');

        return response()->json($domain, 201);
    }

    /**
     * Show a single domain
     */
    public function show(Domain $domain): JsonResponse
    {
        Gate::authorize('owns', $domain);

        $domain->loadCount('pages');
        $domain->load('author');

        return response()->json($domain);
    }

    /**
     * Update a domain
     * 
     * Business Rules:
     * - If EXTERNAL domain name changes, reset verification and enabled status
     */
    public function update(UpdateDomainRequest $request, Domain $domain): JsonResponse
    {
        Gate::authorize('owns', $domain);

        $validatedData = $request->validated();

        $domain->fill($validatedData);

        if ($domain->type === Domain::EXTERNAL_TYPE && $domain->isDirty('name')) {
            $domain->is_enabled = false;
            $domain->is_verified = false;
        }

        $domain->save();
        $domain->loadCount('pages');
        $domain->load('author');

        return response()->json($domain);
    }

    /**
     * Delete a domain
     * 
     * Business Rules:
     * - Cannot delete domain if it has associated pages
     * - Uses soft delete
     */
    public function destroy(Domain $domain): JsonResponse
    {
        Gate::authorize('owns', $domain);

        if ($domain->hasPages()) {
            return response()->json([
                'message' => 'Cannot delete domain with associated pages',
            ], 400);
        }

        $domain->delete();

        return response()->noContent();
    }
}

authorization policy

// app/Policies/DomainPolicy.php
<?php

namespace App\Policies;

use App\Models\Domain;
use App\Models\User;

class DomainPolicy
{
    /**
     * Check if the domain belongs to the user's current team
     */
    public function owns(User $user, Domain $domain): bool
    {
        return (int) $user->current_team_id === (int) $domain->team_id;
    }
}

register it in AuthServiceProvider:

protected $policies = [
    Domain::class => DomainPolicy::class,
];

configuration

// config/domains.php
<?php

$short = env('SHORT_DOMAIN', 'pageultime.localhost');

return [
    'internal' => [
        $short, // e.g., 'pageultime.localhost'
    ],

    'reserved' => [
        // Add reserved domain names that cannot be used as EXTERNAL domains
        // Examples:
        // "admin.{$short}",
        // "www.{$short}",
    ],
];

environment variable (optional, defaults to pageultime.localhost):

SHORT_DOMAIN=pageultime.localhost

factory for testing

// database/factories/DomainFactory.php
<?php

namespace Database\Factories;

use App\Models\Domain;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class DomainFactory extends Factory
{
    public function definition()
    {
        return [
            'name' => $this->faker->domainName(),
            'description' => $this->faker->paragraph(1),
            'type' => Domain::EXTERNAL_TYPE,
            'is_enabled' => true,
            'is_verified' => false,
            'team_id' => function () {
                return Team::factory()->create()->id;
            },
            'author_id' => function () {
                return User::factory()->create()->id;
            },
        ];
    }

    public function unverified()
    {
        return $this->state(function () {
            return ['is_verified' => false];
        });
    }

    public function verified()
    {
        return $this->state(function () {
            return ['is_verified' => true];
        });
    }

    public function enabled()
    {
        return $this->state(function () {
            return ['is_enabled' => true, 'is_verified' => true];
        });
    }

    public function disabled()
    {
        return $this->state(function () {
            return ['is_enabled' => false];
        });
    }

    public function internal()
    {
        return $this->state(function () {
            return [
                'type' => Domain::INTERNAL_TYPE,
                'is_enabled' => true,
                'is_verified' => true,
            ];
        });
    }

    public function external()
    {
        return $this->state(function () {
            return [
                'type' => Domain::EXTERNAL_TYPE,
                'is_enabled' => false,
                'is_verified' => false,
            ];
        });
    }
}

integration with pages

pages reference domains by name, not id:

// in Page model
public function domain()
{
    return $this->belongsTo(Domain::class, 'domain', 'name');
}

this design choice exists because when serving pages, we only have the domain name from the request. looking up by name is faster than joining through an id.

api usage examples

creating an internal domain

POST /api/domains
Content-Type: application/json
Authorization: Bearer {token}

{
  "name": "example.pageultime.localhost",
  "description": "My internal domain",
  "type": "internal"
}

response (201 created):

{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "name": "example.pageultime.localhost",
  "description": "My internal domain",
  "type": "internal",
  "is_enabled": true,
  "is_verified": true,
  "pages_count": 0,
  "author": {
    "id": 1,
    "name": "John Doe"
  },
  "created_at": "2024-01-01T00:00:00.000000Z",
  "updated_at": "2024-01-01T00:00:00.000000Z"
}

creating an external domain

POST /api/domains
Content-Type: application/json
Authorization: Bearer {token}

{
  "name": "example.com",
  "description": "My custom domain",
  "type": "external"
}

response (201 created):

{
  "uuid": "550e8400-e29b-41d4-a716-446655440001",
  "name": "example.com",
  "description": "My custom domain",
  "type": "external",
  "is_enabled": false,
  "is_verified": false,
  "pages_count": 0,
  "author": {
    "id": 1,
    "name": "John Doe"
  },
  "created_at": "2024-01-01T00:00:00.000000Z",
  "updated_at": "2024-01-01T00:00:00.000000Z"
}

listing domains

GET /api/domains
Authorization: Bearer {token}

response (200 ok):

[
  {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "name": "example.pageultime.localhost",
    "type": "internal",
    "is_enabled": true,
    "is_verified": true,
    "pages_count": 5
  },
  {
    "uuid": "550e8400-e29b-41d4-a716-446655440001",
    "name": "example.com",
    "type": "external",
    "is_enabled": false,
    "is_verified": false,
    "pages_count": 0
  }
]

updating a domain

PUT /api/domains/550e8400-e29b-41d4-a716-446655440001
Content-Type: application/json
Authorization: Bearer {token}

{
  "name": "newexample.com",
  "is_enabled": true
}

note: if changing name of external domain, is_enabled and is_verified will be reset to false.

deleting a domain

DELETE /api/domains/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer {token}

response (204 no content) on success.

response (400 bad request) if domain has pages:

{
  "message": "Cannot delete domain with associated pages"
}

feature tests

// tests/Feature/DomainControllerTest.php
<?php

namespace Tests\Feature;

use App\Models\Domain;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class DomainControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_can_create_internal_domain()
    {
        $user = User::factory()->create();
        $team = Team::factory()->create();
        $user->teams()->attach($team, ['role' => 'owner']);
        $user->current_team_id = $team->id;
        $user->save();

        $response = $this->actingAs($user)->postJson('/api/domains', [
            'name' => 'test.pageultime.localhost',
            'type' => Domain::INTERNAL_TYPE,
            'description' => 'Test domain'
        ]);

        $response->assertStatus(201);
        $response->assertJson([
            'name' => 'test.pageultime.localhost',
            'type' => Domain::INTERNAL_TYPE,
            'is_enabled' => true,
            'is_verified' => true,
        ]);

        $this->assertDatabaseHas('domains', [
            'name' => 'test.pageultime.localhost',
            'team_id' => $team->id,
            'is_enabled' => true,
            'is_verified' => true,
        ]);
    }

    public function test_cannot_delete_domain_with_pages()
    {
        $user = User::factory()->create();
        $team = Team::factory()->create();
        $user->teams()->attach($team, ['role' => 'owner']);
        $user->current_team_id = $team->id;
        $user->save();

        $domain = Domain::factory()->create([
            'team_id' => $team->id,
            'name' => 'example.com'
        ]);

        // Create a page using this domain
        $page = Page::factory()->create([
            'domain' => $domain->name
        ]);

        $response = $this->actingAs($user)->deleteJson("/api/domains/{$domain->uuid}");

        $response->assertStatus(400);
        $this->assertDatabaseHas('domains', [
            'id' => $domain->id,
            'deleted_at' => null
        ]);
    }
}

summary

this domain management system provides:

  1. two domain types: internal (subdomains) and external (custom domains)
  2. comprehensive validation: domain name format, reservation rules, type validation
  3. auto-verification: internal domains are auto-verified and enabled
  4. soft deletes: domains are soft-deleted, preserving data
  5. page protection: cannot delete domains with associated pages
  6. re-verification on name change: external domains reset when name changes
  7. team scoped: all operations scoped to user's current team
  8. uuid-based: public identification via uuid, not internal id

the key business rules are:

  • internal domains skip verification because we control them
  • external domains need dns verification before they can be used
  • changing an external domain name requires re-verification
  • domains with pages cannot be deleted
  • all domain operations require team ownership