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.