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:
- uuid trait: uses
HasUuidstrait for public identification - soft deletes: domains are soft-deleted, not permanently removed
- type constants: defines internal and external types
- domain name regex: static method for validation pattern
- 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:
- two domain types: internal (subdomains) and external (custom domains)
- comprehensive validation: domain name format, reservation rules, type validation
- auto-verification: internal domains are auto-verified and enabled
- soft deletes: domains are soft-deleted, preserving data
- page protection: cannot delete domains with associated pages
- re-verification on name change: external domains reset when name changes
- team scoped: all operations scoped to user's current team
- 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