Laravel Wildcard Subdomain Routing: Building Multi-Tenant Applications
Wildcard subdomain routing in Laravel opens up powerful possibilities for building multi-tenant applications, user workspaces, and dynamic subdomain-based features. This technique allows you to capture subdomain values as route parameters, enabling sophisticated application architectures.
Understanding Wildcard Subdomain Routing
Laravel's wildcard subdomain routing captures dynamic subdomains and passes them as parameters to your routes:
Route::domain('{username}.workspace.com')->group(function () {
Route::get('/', function ($username) {
return "Welcome to {$username}'s workspace!";
});
Route::get('projects/{id}', function ($username, $id) {
return "Project {$id} in {$username}'s workspace";
});
});
This creates routes that respond to any subdomain pattern, making it perfect for user-specific spaces, tenant isolation, and dynamic content delivery.
Multi-Tenant SaaS Application
Tenant-Based Application Structure
// Tenant routing with middleware
Route::domain('{tenant}.myapp.com')
->middleware(['tenant.resolve', 'tenant.active'])
->group(function () {
// Public tenant pages
Route::get('/', [TenantHomeController::class, 'index'])->name('tenant.home');
Route::get('pricing', [TenantHomeController::class, 'pricing'])->name('tenant.pricing');
Route::get('about', [TenantHomeController::class, 'about'])->name('tenant.about');
// Tenant authentication
Route::prefix('auth')->name('tenant.auth.')->group(function() {
Route::get('login', [TenantAuthController::class, 'showLogin'])->name('login');
Route::post('login', [TenantAuthController::class, 'login'])->name('login.post');
Route::post('logout', [TenantAuthController::class, 'logout'])->name('logout');
Route::get('register', [TenantAuthController::class, 'showRegister'])->name('register');
Route::post('register', [TenantAuthController::class, 'register'])->name('register.post');
// Password reset with tenant context
Route::get('password/reset', [TenantPasswordController::class, 'showReset'])->name('password.request');
Route::post('password/email', [TenantPasswordController::class, 'sendReset'])->name('password.email');
Route::get('password/reset/{token}', [TenantPasswordController::class, 'showResetForm'])->name('password.reset');
Route::post('password/reset', [TenantPasswordController::class, 'reset'])->name('password.update');
});
// Tenant application routes (requires authentication)
Route::middleware(['auth:tenant'])->group(function() {
Route::get('dashboard', [TenantDashboardController::class, 'index'])->name('tenant.dashboard');
// User management within tenant
Route::resource('users', TenantUserController::class, [
'as' => 'tenant'
])->middleware('can:manage-users');
// Projects within tenant context
Route::prefix('projects')->name('tenant.projects.')->group(function() {
Route::get('/', [TenantProjectController::class, 'index'])->name('index');
Route::get('create', [TenantProjectController::class, 'create'])->name('create');
Route::post('/', [TenantProjectController::class, 'store'])->name('store');
Route::prefix('{project}')->group(function() {
Route::get('/', [TenantProjectController::class, 'show'])->name('show');
Route::get('edit', [TenantProjectController::class, 'edit'])->name('edit');
Route::put('/', [TenantProjectController::class, 'update'])->name('update');
Route::delete('/', [TenantProjectController::class, 'destroy'])->name('destroy');
// Team management for specific project
Route::prefix('team')->name('team.')->group(function() {
Route::get('/', [ProjectTeamController::class, 'index'])->name('index');
Route::post('invite', [ProjectTeamController::class, 'invite'])->name('invite');
Route::delete('remove/{user}', [ProjectTeamController::class, 'remove'])->name('remove');
});
// Project-specific features
Route::prefix('tasks')->name('tasks.')->group(function() {
Route::resource('/', TaskController::class)->except(['create', 'show']);
Route::post('{task}/complete', [TaskController::class, 'complete'])->name('complete');
Route::post('{task}/assign', [TaskController::class, 'assign'])->name('assign');
});
});
});
// Tenant admin routes (requires admin role within tenant)
Route::middleware(['role:tenant-admin'])->prefix('admin')->name('tenant.admin.')->group(function() {
Route::get('settings', [TenantAdminController::class, 'settings'])->name('settings');
Route::put('settings', [TenantAdminController::class, 'updateSettings'])->name('settings.update');
Route::get('billing', [TenantBillingController::class, 'index'])->name('billing');
Route::post('billing/upgrade', [TenantBillingController::class, 'upgrade'])->name('billing.upgrade');
Route::post('billing/cancel', [TenantBillingController::class, 'cancel'])->name('billing.cancel');
Route::get('analytics', [TenantAnalyticsController::class, 'index'])->name('analytics');
Route::get('audit-log', [TenantAuditController::class, 'index'])->name('audit');
});
});
});
Tenant Resolution Middleware
// app/Http/Middleware/ResolveTenant.php
<?php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
class ResolveTenant
{
public function handle(Request $request, Closure $next)
{
$subdomain = $this->extractSubdomain($request);
if (!$subdomain) {
return redirect()->to(config('app.main_domain'));
}
$tenant = $this->resolveTenant($subdomain);
if (!$tenant) {
abort(404, 'Tenant not found');
}
if (!$tenant->is_active) {
return response()->view('tenant.suspended', compact('tenant'), 503);
}
// Set tenant context globally
app()->instance('tenant', $tenant);
Config::set('tenant', $tenant->toArray());
// Switch database connection if using tenant-specific databases
if ($tenant->database) {
$this->configureTenantDatabase($tenant);
}
// Set tenant-specific cache prefix
Cache::setPrefix("tenant_{$tenant->id}_");
// Share tenant with all views
view()->share('tenant', $tenant);
return $next($request);
}
private function extractSubdomain(Request $request)
{
$host = $request->getHost();
$mainDomain = config('app.main_domain', 'myapp.com');
if (str_ends_with($host, ".{$mainDomain}")) {
return substr($host, 0, -strlen(".{$mainDomain}"));
}
return null;
}
private function resolveTenant($subdomain)
{
return Cache::remember("tenant_{$subdomain}", 300, function() use ($subdomain) {
return Tenant::where('subdomain', $subdomain)
->with(['settings', 'branding'])
->first();
});
}
private function configureTenantDatabase($tenant)
{
Config::set('database.connections.tenant', [
'driver' => 'mysql',
'host' => config('database.connections.mysql.host'),
'database' => $tenant->database,
'username' => config('database.connections.mysql.username'),
'password' => config('database.connections.mysql.password'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
]);
// Switch default connection for this request
Config::set('database.default', 'tenant');
}
}
User Workspace Platform (GitHub/Notion Style)
Personal Workspace Routing
// User workspace routing
Route::domain('{username}.dev-space.com')
->middleware(['workspace.resolve', 'workspace.public'])
->group(function () {
// Public user profile
Route::get('/', [WorkspaceController::class, 'profile'])->name('workspace.profile');
Route::get('projects', [WorkspaceController::class, 'publicProjects'])->name('workspace.projects');
Route::get('blog', [WorkspaceBlogController::class, 'index'])->name('workspace.blog');
Route::get('blog/{slug}', [WorkspaceBlogController::class, 'show'])->name('workspace.blog.show');
// Project showcase
Route::get('projects/{project}', [WorkspaceProjectController::class, 'show'])
->middleware('project.public')
->name('workspace.project.show');
Route::get('projects/{project}/demo', [WorkspaceProjectController::class, 'demo'])
->middleware('project.demo.enabled')
->name('workspace.project.demo');
// Workspace owner authentication and management
Route::middleware(['auth', 'workspace.owner'])->group(function() {
Route::prefix('admin')->name('workspace.admin.')->group(function() {
Route::get('dashboard', [WorkspaceAdminController::class, 'dashboard'])->name('dashboard');
// Profile management
Route::prefix('profile')->name('profile.')->group(function() {
Route::get('edit', [WorkspaceProfileController::class, 'edit'])->name('edit');
Route::put('update', [WorkspaceProfileController::class, 'update'])->name('update');
Route::post('avatar', [WorkspaceProfileController::class, 'updateAvatar'])->name('avatar.update');
});
// Project management
Route::prefix('projects')->name('projects.')->group(function() {
Route::get('/', [WorkspaceProjectAdminController::class, 'index'])->name('index');
Route::get('create', [WorkspaceProjectAdminController::class, 'create'])->name('create');
Route::post('/', [WorkspaceProjectAdminController::class, 'store'])->name('store');
Route::prefix('{project}')->group(function() {
Route::get('edit', [WorkspaceProjectAdminController::class, 'edit'])->name('edit');
Route::put('/', [WorkspaceProjectAdminController::class, 'update'])->name('update');
Route::delete('/', [WorkspaceProjectAdminController::class, 'destroy'])->name('destroy');
// Project settings
Route::prefix('settings')->name('settings.')->group(function() {
Route::get('/', [ProjectSettingsController::class, 'index'])->name('index');
Route::put('visibility', [ProjectSettingsController::class, 'updateVisibility'])->name('visibility');
Route::put('demo', [ProjectSettingsController::class, 'updateDemo'])->name('demo');
Route::post('deploy', [ProjectSettingsController::class, 'deploy'])->name('deploy');
});
// Collaborators
Route::prefix('collaborators')->name('collaborators.')->group(function() {
Route::get('/', [ProjectCollaboratorController::class, 'index'])->name('index');
Route::post('invite', [ProjectCollaboratorController::class, 'invite'])->name('invite');
Route::delete('{collaborator}', [ProjectCollaboratorController::class, 'remove'])->name('remove');
Route::put('{collaborator}/role', [ProjectCollaboratorController::class, 'updateRole'])->name('update-role');
});
});
});
// Blog management
Route::prefix('blog')->name('blog.')->group(function() {
Route::get('/', [WorkspaceBlogAdminController::class, 'index'])->name('index');
Route::get('create', [WorkspaceBlogAdminController::class, 'create'])->name('create');
Route::post('/', [WorkspaceBlogAdminController::class, 'store'])->name('store');
Route::get('{post}/edit', [WorkspaceBlogAdminController::class, 'edit'])->name('edit');
Route::put('{post}', [WorkspaceBlogAdminController::class, 'update'])->name('update');
Route::delete('{post}', [WorkspaceBlogAdminController::class, 'destroy'])->name('destroy');
Route::post('{post}/publish', [WorkspaceBlogAdminController::class, 'publish'])->name('publish');
});
// Analytics and insights
Route::prefix('analytics')->name('analytics.')->group(function() {
Route::get('/', [WorkspaceAnalyticsController::class, 'overview'])->name('overview');
Route::get('projects', [WorkspaceAnalyticsController::class, 'projects'])->name('projects');
Route::get('visitors', [WorkspaceAnalyticsController::class, 'visitors'])->name('visitors');
Route::get('referrers', [WorkspaceAnalyticsController::class, 'referrers'])->name('referrers');
});
// Custom domain management
Route::prefix('domains')->name('domains.')->group(function() {
Route::get('/', [WorkspaceDomainController::class, 'index'])->name('index');
Route::post('/', [WorkspaceDomainController::class, 'store'])->name('store');
Route::put('{domain}', [WorkspaceDomainController::class, 'update'])->name('update');
Route::delete('{domain}', [WorkspaceDomainController::class, 'destroy'])->name('destroy');
Route::post('{domain}/verify', [WorkspaceDomainController::class, 'verify'])->name('verify');
});
});
});
// Collaboration routes (for invited collaborators)
Route::middleware(['auth', 'collaborator.access'])->group(function() {
Route::prefix('collab')->name('workspace.collab.')->group(function() {
Route::get('dashboard', [CollaboratorController::class, 'dashboard'])->name('dashboard');
Route::get('projects', [CollaboratorController::class, 'projects'])->name('projects');
Route::prefix('projects/{project}')->name('projects.')->group(function() {
Route::get('/', [CollaboratorProjectController::class, 'show'])->name('show');
Route::get('tasks', [CollaboratorProjectController::class, 'tasks'])->name('tasks');
Route::post('tasks', [CollaboratorProjectController::class, 'createTask'])->name('tasks.store');
Route::put('tasks/{task}', [CollaboratorProjectController::class, 'updateTask'])->name('tasks.update');
});
});
});
});
Team Workspace Platform (Slack/Discord Style)
Team-Based Subdomain Architecture
// Team workspace routing
Route::domain('{team_slug}.workspace.app')
->middleware(['team.resolve', 'team.member'])
->group(function () {
// Team dashboard and overview
Route::get('/', [TeamDashboardController::class, 'index'])->name('team.dashboard');
// Channels system
Route::prefix('channels')->name('team.channels.')->group(function() {
Route::get('/', [TeamChannelController::class, 'index'])->name('index');
Route::prefix('{channel}')->group(function() {
Route::get('/', [TeamChannelController::class, 'show'])->name('show');
Route::post('messages', [TeamMessageController::class, 'store'])->name('messages.store');
Route::put('messages/{message}', [TeamMessageController::class, 'update'])->name('messages.update');
Route::delete('messages/{message}', [TeamMessageController::class, 'destroy'])->name('messages.destroy');
// Channel settings (admin only)
Route::middleware('team.admin')->group(function() {
Route::get('settings', [TeamChannelController::class, 'settings'])->name('settings');
Route::put('settings', [TeamChannelController::class, 'updateSettings'])->name('settings.update');
Route::post('archive', [TeamChannelController::class, 'archive'])->name('archive');
Route::post('unarchive', [TeamChannelController::class, 'unarchive'])->name('unarchive');
});
});
});
// Direct messages
Route::prefix('messages')->name('team.messages.')->group(function() {
Route::get('/', [TeamDirectMessageController::class, 'index'])->name('index');
Route::get('{user}', [TeamDirectMessageController::class, 'show'])->name('show');
Route::post('{user}', [TeamDirectMessageController::class, 'send'])->name('send');
});
// Team file sharing
Route::prefix('files')->name('team.files.')->group(function() {
Route::get('/', [TeamFileController::class, 'index'])->name('index');
Route::post('upload', [TeamFileController::class, 'upload'])->name('upload');
Route::get('{file}/download', [TeamFileController::class, 'download'])->name('download');
Route::delete('{file}', [TeamFileController::class, 'destroy'])->name('destroy');
});
// Team calendar and events
Route::prefix('calendar')->name('team.calendar.')->group(function() {
Route::get('/', [TeamCalendarController::class, 'index'])->name('index');
Route::post('events', [TeamEventController::class, 'store'])->name('events.store');
Route::put('events/{event}', [TeamEventController::class, 'update'])->name('events.update');
Route::delete('events/{event}', [TeamEventController::class, 'destroy'])->name('events.destroy');
Route::post('events/{event}/rsvp', [TeamEventController::class, 'rsvp'])->name('events.rsvp');
});
// Team administration (admin/owner only)
Route::middleware('team.admin')->prefix('admin')->name('team.admin.')->group(function() {
// Member management
Route::prefix('members')->name('members.')->group(function() {
Route::get('/', [TeamMemberAdminController::class, 'index'])->name('index');
Route::post('invite', [TeamMemberAdminController::class, 'invite'])->name('invite');
Route::put('{member}/role', [TeamMemberAdminController::class, 'updateRole'])->name('update-role');
Route::delete('{member}', [TeamMemberAdminController::class, 'remove'])->name('remove');
Route::post('{member}/deactivate', [TeamMemberAdminController::class, 'deactivate'])->name('deactivate');
});
// Channel management
Route::prefix('channels')->name('channels.')->group(function() {
Route::get('/', [TeamChannelAdminController::class, 'index'])->name('index');
Route::post('/', [TeamChannelAdminController::class, 'store'])->name('store');
Route::put('{channel}', [TeamChannelAdminController::class, 'update'])->name('update');
Route::delete('{channel}', [TeamChannelAdminController::class, 'destroy'])->name('destroy');
});
// Team settings
Route::prefix('settings')->name('settings.')->group(function() {
Route::get('general', [TeamSettingsController::class, 'general'])->name('general');
Route::put('general', [TeamSettingsController::class, 'updateGeneral'])->name('general.update');
Route::get('permissions', [TeamSettingsController::class, 'permissions'])->name('permissions');
Route::put('permissions', [TeamSettingsController::class, 'updatePermissions'])->name('permissions.update');
Route::get('integrations', [TeamSettingsController::class, 'integrations'])->name('integrations');
Route::post('integrations/{integration}', [TeamSettingsController::class, 'enableIntegration'])->name('integrations.enable');
Route::delete('integrations/{integration}', [TeamSettingsController::class, 'disableIntegration'])->name('integrations.disable');
});
// Billing (owner only)
Route::middleware('team.owner')->prefix('billing')->name('billing.')->group(function() {
Route::get('/', [TeamBillingController::class, 'index'])->name('index');
Route::post('subscribe', [TeamBillingController::class, 'subscribe'])->name('subscribe');
Route::post('change-plan', [TeamBillingController::class, 'changePlan'])->name('change-plan');
Route::post('cancel', [TeamBillingController::class, 'cancel'])->name('cancel');
Route::get('invoices', [TeamBillingController::class, 'invoices'])->name('invoices');
});
});
});
Custom Domain Support with Wildcard Routing
Multi-Domain Support
// Support both subdomain and custom domains
Route::group([
'domain' => '{domain}',
'middleware' => ['domain.resolve']
], function() {
// This will match both:
// - acme.myapp.com (subdomain)
// - acme.com (custom domain)
Route::get('/', [DomainController::class, 'home'])->name('domain.home');
Route::get('about', [DomainController::class, 'about'])->name('domain.about');
Route::get('contact', [DomainController::class, 'contact'])->name('domain.contact');
});
Domain Resolution Middleware
// app/Http/Middleware/ResolveDomain.php
<?php
namespace App\Http\Middleware;
use Closure;
use App\Models\Domain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ResolveDomain
{
public function handle(Request $request, Closure $next)
{
$host = $request->getHost();
$domain = $this->resolveDomain($host);
if (!$domain) {
return $this->handleMainDomain($request, $next);
}
// Set domain context
app()->instance('domain', $domain);
view()->share('domain', $domain);
// Apply domain-specific configurations
$this->applyDomainConfig($domain);
return $next($request);
}
private function resolveDomain($host)
{
return Cache::remember("domain_{$host}", 300, function() use ($host) {
// Check for custom domain first
$domain = Domain::where('custom_domain', $host)
->where('verified', true)
->first();
if ($domain) {
return $domain;
}
// Check for subdomain
$mainDomain = config('app.main_domain');
if (str_ends_with($host, ".{$mainDomain}")) {
$subdomain = substr($host, 0, -strlen(".{$mainDomain}"));
return Domain::where('subdomain', $subdomain)
->where('active', true)
->first();
}
return null;
});
}
private function applyDomainConfig($domain)
{
// Apply custom CSS/branding
if ($domain->custom_css) {
view()->share('custom_css', $domain->custom_css);
}
// Set custom app name
if ($domain->custom_name) {
config(['app.name' => $domain->custom_name]);
}
// Apply custom meta tags
if ($domain->meta_tags) {
view()->share('meta_tags', $domain->meta_tags);
}
}
}
Testing Wildcard Subdomain Routes
Feature Tests
// tests/Feature/WildcardSubdomainTest.php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Tenant;
use App\Models\User;
class WildcardSubdomainTest extends TestCase
{
public function test_tenant_subdomain_routes_work()
{
$tenant = Tenant::factory()->create(['subdomain' => 'acme']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->get('http://acme.myapp.com/');
$response->assertOk();
$response->assertViewHas('tenant', $tenant);
}
public function test_invalid_subdomain_returns_404()
{
$response = $this->get('http://invalid.myapp.com/');
$response->assertNotFound();
}
public function test_tenant_authentication_works()
{
$tenant = Tenant::factory()->create(['subdomain' => 'test']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
// Test login
$response = $this->post('http://test.myapp.com/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect('http://test.myapp.com/dashboard');
}
public function test_route_parameters_passed_correctly()
{
$tenant = Tenant::factory()->create(['subdomain' => 'example']);
$response = $this->get('http://example.myapp.com/projects/123');
$response->assertOk();
// Verify both subdomain and route parameters are available
$response->assertViewHas(function ($view) {
return $view->getData()['tenant']->subdomain === 'example' &&
request()->route('project') === '123';
});
}
}
Development Environment Setup
// For local development, add to /etc/hosts:
// 127.0.0.1 test.myapp.local
// 127.0.0.1 demo.myapp.local
// 127.0.0.1 acme.myapp.local
// Or use Laravel Valet:
valet link myapp
valet domain local
// This creates:
// *.myapp.local pointing to your project
Production Considerations
DNS Configuration
# For wildcard subdomains, set up DNS:
# *.yourdomain.com CNAME yourdomain.com
# *.yourdomain.com A your.server.ip
# For custom domains, users add:
# their-domain.com CNAME yourdomain.com
# their-domain.com A your.server.ip
SSL Certificate Management
// Use services like Let's Encrypt with automatic certificate generation
// Or implement certificate provisioning:
class CertificateManager
{
public function provisionCertificate($domain)
{
// Use ACME protocol to get SSL certificate
$certificate = $this->requestCertificate($domain);
// Store certificate
$this->storeCertificate($domain, $certificate);
// Update web server configuration
$this->updateWebServerConfig($domain);
return $certificate;
}
}
Best Practices
1. Validate Subdomain Format
Route::domain('{tenant}')->where('tenant', '[a-z0-9\-]+')->group(function() {
// Routes here
});
2. Cache Domain Resolution
// Always cache domain lookups to prevent database hits on every request
$domain = Cache::remember("domain_{$host}", 300, function() use ($host) {
return Domain::where('name', $host)->first();
});
3. Handle Subdomain Conflicts
// Reserve system subdomains
$reserved = ['www', 'api', 'admin', 'mail', 'ftp', 'blog'];
if (in_array($subdomain, $reserved)) {
abort(404, 'Reserved subdomain');
}
Conclusion
Wildcard subdomain routing enables powerful multi-tenant architectures and user-specific spaces in Laravel applications. By mastering these techniques, you can build sophisticated SaaS platforms, workspace applications, and multi-tenant systems that scale efficiently while maintaining clean separation between tenants.
The key is proper middleware implementation, caching strategies, and careful consideration of security and performance implications. With wildcard subdomain routing, you can create applications that feel personalized and isolated while sharing the same codebase infrastructure.