{"id":100,"date":"2026-07-20T10:00:00","date_gmt":"2026-07-20T04:30:00","guid":{"rendered":"https:\/\/softcrony.com\/blog\/?p=100"},"modified":"2026-07-20T10:00:00","modified_gmt":"2026-07-20T04:30:00","slug":"multi-tenant-saas-laravel-13-guide","status":"publish","type":"post","link":"https:\/\/softcrony.com\/blog\/multi-tenant-saas-laravel-13-guide\/","title":{"rendered":"Building a Multi-Tenant SaaS in Laravel 13: Complete Guide"},"content":{"rendered":"<p>Multi-tenancy is the architecture that makes SaaS possible \u2014 one application serving many customers, with their data completely isolated from each other. Laravel 13 makes this cleaner than ever with PHP Attributes, improved middleware, and better tooling.<\/p>\n<p>This guide builds a complete multi-tenant foundation in Laravel 13 \u2014 the patterns we use at Softcrony for client SaaS projects.<\/p>\n<h2>Multi-Tenancy Strategies<\/h2>\n<p>There are three main approaches to multi-tenancy in Laravel:<\/p>\n<table>\n<thead>\n<tr>\n<th>Strategy<\/th>\n<th>How It Works<\/th>\n<th>Best For<\/th>\n<th>Complexity<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Single DB, shared tables<\/td>\n<td>company_id column on every table<\/td>\n<td>Most SaaS apps<\/td>\n<td>Low<\/td>\n<\/tr>\n<tr>\n<td>Single DB, separate schemas<\/td>\n<td>MySQL schema per tenant<\/td>\n<td>Mid-scale, stronger isolation<\/td>\n<td>Medium<\/td>\n<\/tr>\n<tr>\n<td>Separate databases<\/td>\n<td>One database per tenant<\/td>\n<td>Enterprise, regulated industries<\/td>\n<td>High<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>This guide implements <strong>Strategy 1 \u2014 Single DB with shared tables<\/strong>. It handles 95% of SaaS use cases and is the simplest to build and maintain.<\/p>\n<h2>Step 1 \u2014 Database Architecture<\/h2>\n<pre><code>php artisan make:migration create_tenants_table\r\nphp artisan make:migration create_tenant_users_table<\/code><\/pre>\n<pre><code>&lt;?php\r\n\r\n\/\/ Tenants (companies\/organizations)\r\nSchema::create('tenants', function (Blueprint $table) {\r\n    $table-&gt;id();\r\n    $table-&gt;string('name');\r\n    $table-&gt;string('slug')-&gt;unique(); \/\/ used for subdomain\r\n    $table-&gt;string('domain')-&gt;nullable()-&gt;unique(); \/\/ custom domain\r\n    $table-&gt;string('plan')-&gt;default('free'); \/\/ free\/starter\/pro\/enterprise\r\n    $table-&gt;string('status')-&gt;default('active'); \/\/ active\/suspended\/cancelled\r\n    $table-&gt;json('settings')-&gt;nullable();\r\n    $table-&gt;timestamp('trial_ends_at')-&gt;nullable();\r\n    $table-&gt;timestamps();\r\n    $table-&gt;softDeletes();\r\n});\r\n\r\n\/\/ Users belong to tenants\r\nSchema::create('tenant_users', function (Blueprint $table) {\r\n    $table-&gt;id();\r\n    $table-&gt;foreignId('tenant_id')-&gt;constrained()-&gt;onDelete('cascade');\r\n    $table-&gt;foreignId('user_id')-&gt;constrained()-&gt;onDelete('cascade');\r\n    $table-&gt;string('role')-&gt;default('member'); \/\/ owner\/admin\/member\r\n    $table-&gt;timestamps();\r\n\r\n    $table-&gt;unique(['tenant_id', 'user_id']);\r\n});\r\n\r\n\/\/ All your feature tables need tenant_id\r\nSchema::create('projects', function (Blueprint $table) {\r\n    $table-&gt;id();\r\n    $table-&gt;foreignId('tenant_id')-&gt;constrained()-&gt;onDelete('cascade');\r\n    $table-&gt;foreignId('user_id')-&gt;constrained(); \/\/ creator\r\n    $table-&gt;string('name');\r\n    $table-&gt;text('description')-&gt;nullable();\r\n    $table-&gt;string('status')-&gt;default('active');\r\n    $table-&gt;timestamps();\r\n    $table-&gt;softDeletes();\r\n\r\n    $table-&gt;index('tenant_id'); \/\/ Always index tenant_id\r\n});<\/code><\/pre>\n<h2>Step 2 \u2014 Tenant Model with Laravel 13 Attributes<\/h2>\n<pre><code>php artisan make:model Tenant<\/code><\/pre>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Models;\r\n\r\nuse Illuminate\\Database\\Eloquent\\Attributes\\Table;\r\nuse Illuminate\\Database\\Eloquent\\Attributes\\Fillable;\r\nuse Illuminate\\Database\\Eloquent\\Model;\r\nuse Illuminate\\Database\\Eloquent\\SoftDeletes;\r\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\r\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\r\n\r\n#[Table('tenants')]\r\n#[Fillable(['name', 'slug', 'domain', 'plan', 'status', 'settings', 'trial_ends_at'])]\r\nclass Tenant extends Model\r\n{\r\n    use SoftDeletes;\r\n\r\n    protected $casts = [\r\n        'settings'      =&gt; 'array',\r\n        'trial_ends_at' =&gt; 'datetime',\r\n    ];\r\n\r\n    public function users(): BelongsToMany\r\n    {\r\n        return $this-&gt;belongsToMany(User::class, 'tenant_users')\r\n            -&gt;withPivot('role')\r\n            -&gt;withTimestamps();\r\n    }\r\n\r\n    public function projects(): HasMany\r\n    {\r\n        return $this-&gt;hasMany(Project::class);\r\n    }\r\n\r\n    public function isOnTrial(): bool\r\n    {\r\n        return $this-&gt;trial_ends_at &amp;&amp; $this-&gt;trial_ends_at-&gt;isFuture();\r\n    }\r\n\r\n    public function hasFeature(string $feature): bool\r\n    {\r\n        return in_array($feature, config(\"plans.{$this-&gt;plan}.features\", []));\r\n    }\r\n}<\/code><\/pre>\n<h2>Step 3 \u2014 Tenant Resolution<\/h2>\n<p>The most important part \u2014 identifying which tenant owns the current request:<\/p>\n<pre><code>php artisan make:class Services\/TenantResolver<\/code><\/pre>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Services;\r\n\r\nuse App\\Models\\Tenant;\r\nuse Illuminate\\Http\\Request;\r\n\r\nclass TenantResolver\r\n{\r\n    public function fromRequest(Request $request): ?Tenant\r\n    {\r\n        \/\/ 1. Try subdomain: company.yourapp.com\r\n        $host = $request-&gt;getHost();\r\n        $appDomain = config('app.domain', 'yourapp.com');\r\n\r\n        if (str_ends_with($host, '.' . $appDomain)) {\r\n            $slug = str_replace('.' . $appDomain, '', $host);\r\n            return Tenant::where('slug', $slug)-&gt;where('status', 'active')-&gt;first();\r\n        }\r\n\r\n        \/\/ 2. Try custom domain\r\n        return Tenant::where('domain', $host)-&gt;where('status', 'active')-&gt;first();\r\n    }\r\n\r\n    public function fromUser(): ?Tenant\r\n    {\r\n        \/\/ Get tenant from authenticated user's context\r\n        return auth()-&gt;user()?-&gt;currentTenant();\r\n    }\r\n}<\/code><\/pre>\n<h2>Step 4 \u2014 Tenant Context (Singleton)<\/h2>\n<pre><code>php artisan make:class TenantContext<\/code><\/pre>\n<pre><code>&lt;?php\r\n\r\nnamespace App;\r\n\r\nuse App\\Models\\Tenant;\r\n\r\nclass TenantContext\r\n{\r\n    private static ?Tenant $current = null;\r\n\r\n    public static function set(Tenant $tenant): void\r\n    {\r\n        static::$current = $tenant;\r\n    }\r\n\r\n    public static function get(): ?Tenant\r\n    {\r\n        return static::$current;\r\n    }\r\n\r\n    public static function id(): ?int\r\n    {\r\n        return static::$current?-&gt;id;\r\n    }\r\n\r\n    public static function clear(): void\r\n    {\r\n        static::$current = null;\r\n    }\r\n\r\n    public static function require(): Tenant\r\n    {\r\n        if (!static::$current) {\r\n            throw new \\RuntimeException('No tenant context set');\r\n        }\r\n        return static::$current;\r\n    }\r\n}<\/code><\/pre>\n<h2>Step 5 \u2014 Tenant Middleware<\/h2>\n<pre><code>php artisan make:middleware InitializeTenant<\/code><\/pre>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Http\\Middleware;\r\n\r\nuse App\\TenantContext;\r\nuse App\\Services\\TenantResolver;\r\nuse Closure;\r\nuse Illuminate\\Http\\Request;\r\n\r\nclass InitializeTenant\r\n{\r\n    public function __construct(\r\n        private readonly TenantResolver $resolver\r\n    ) {}\r\n\r\n    public function handle(Request $request, Closure $next)\r\n    {\r\n        $tenant = $this-&gt;resolver-&gt;fromRequest($request);\r\n\r\n        if (!$tenant) {\r\n            abort(404, 'Tenant not found');\r\n        }\r\n\r\n        \/\/ Set tenant context for this request\r\n        TenantContext::set($tenant);\r\n\r\n        \/\/ Make tenant available in views\r\n        view()-&gt;share('tenant', $tenant);\r\n\r\n        return $next($request);\r\n    }\r\n}<\/code><\/pre>\n<p>Register in <code>bootstrap\/app.php<\/code>:<\/p>\n<pre><code>-&gt;withMiddleware(function (Middleware $middleware) {\r\n    $middleware->alias([\r\n        'tenant' =&gt; \\App\\Http\\Middleware\\InitializeTenant::class,\r\n    ]);\r\n})<\/code><\/pre>\n<h2>Step 6 \u2014 Tenant Global Scope<\/h2>\n<p>This is the core security layer \u2014 automatically filtering all queries by the current tenant:<\/p>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Models\\Scopes;\r\n\r\nuse App\\TenantContext;\r\nuse Illuminate\\Database\\Eloquent\\Builder;\r\nuse Illuminate\\Database\\Eloquent\\Model;\r\nuse Illuminate\\Database\\Eloquent\\Scope;\r\n\r\nclass TenantScope implements Scope\r\n{\r\n    public function apply(Builder $builder, Model $model): void\r\n    {\r\n        $tenantId = TenantContext::id();\r\n\r\n        if ($tenantId) {\r\n            $builder-&gt;where($model-&gt;getTable() . '.tenant_id', $tenantId);\r\n        }\r\n    }\r\n}\r\n\r\n\/\/ Trait to add to all tenant-scoped models\r\ntrait BelongsToTenant\r\n{\r\n    protected static function bootBelongsToTenant(): void\r\n    {\r\n        \/\/ Apply scope on all queries\r\n        static::addGlobalScope(new TenantScope());\r\n\r\n        \/\/ Auto-set tenant_id on create\r\n        static::creating(function (Model $model) {\r\n            if (!$model-&gt;tenant_id &amp;&amp; TenantContext::id()) {\r\n                $model-&gt;tenant_id = TenantContext::id();\r\n            }\r\n        });\r\n    }\r\n}<\/code><\/pre>\n<p>Use the trait on all tenant-scoped models:<\/p>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Models;\r\n\r\nuse App\\Models\\Scopes\\BelongsToTenant;\r\nuse Illuminate\\Database\\Eloquent\\Attributes\\Fillable;\r\nuse Illuminate\\Database\\Eloquent\\Model;\r\n\r\n#[Fillable(['name', 'description', 'status', 'tenant_id', 'user_id'])]\r\nclass Project extends Model\r\n{\r\n    use BelongsToTenant;\r\n\r\n    \/\/ All queries automatically scoped to current tenant\r\n    \/\/ tenant_id automatically set on create\r\n}<\/code><\/pre>\n<h2>Step 7 \u2014 Subdomain Routing<\/h2>\n<pre><code>\/\/ routes\/web.php\r\nRoute::domain('{tenant}.' . config('app.domain'))\r\n    -&gt;middleware(['tenant', 'auth'])\r\n    -&gt;group(function () {\r\n        Route::get('\/dashboard', [DashboardController::class, 'index'])-&gt;name('dashboard');\r\n        Route::resource('projects', ProjectController::class);\r\n        Route::resource('tasks', TaskController::class);\r\n    });<\/code><\/pre>\n<h2>Step 8 \u2014 Tenant Registration Flow<\/h2>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Http\\Controllers;\r\n\r\nuse App\\Models\\Tenant;\r\nuse App\\Models\\User;\r\nuse Illuminate\\Http\\Request;\r\nuse Illuminate\\Support\\Facades\\DB;\r\nuse Illuminate\\Support\\Facades\\Hash;\r\nuse Illuminate\\Support\\Str;\r\n\r\nclass TenantRegistrationController extends Controller\r\n{\r\n    public function store(Request $request)\r\n    {\r\n        $validated = $request-&gt;validate([\r\n            'company_name' =&gt; 'required|string|max:255',\r\n            'name'         =&gt; 'required|string|max:255',\r\n            'email'        =&gt; 'required|email|unique:users',\r\n            'password'     =&gt; 'required|min:8|confirmed',\r\n        ]);\r\n\r\n        return DB::transaction(function () use ($validated) {\r\n            \/\/ Create tenant\r\n            $tenant = Tenant::create([\r\n                'name'          =&gt; $validated['company_name'],\r\n                'slug'          =&gt; Str::slug($validated['company_name']),\r\n                'plan'          =&gt; 'free',\r\n                'trial_ends_at' =&gt; now()-&gt;addDays(14),\r\n            ]);\r\n\r\n            \/\/ Create owner user\r\n            $user = User::create([\r\n                'name'     =&gt; $validated['name'],\r\n                'email'    =&gt; $validated['email'],\r\n                'password' =&gt; Hash::make($validated['password']),\r\n            ]);\r\n\r\n            \/\/ Attach user to tenant as owner\r\n            $tenant-&gt;users()-&gt;attach($user-&gt;id, ['role' =&gt; 'owner']);\r\n\r\n            \/\/ Seed default data for the new tenant\r\n            app(TenantSeeder::class)-&gt;run($tenant);\r\n\r\n            return redirect(\"https:\/\/{$tenant-&gt;slug}.\" . config('app.domain') . '\/dashboard');\r\n        });\r\n    }\r\n}<\/code><\/pre>\n<h2>Step 9 \u2014 Plan and Feature Management<\/h2>\n<pre><code>\/\/ config\/plans.php\r\nreturn [\r\n    'free' =&gt; [\r\n        'name'     =&gt; 'Free',\r\n        'price'    =&gt; 0,\r\n        'features' =&gt; ['projects:5', 'users:2', 'storage:1gb'],\r\n    ],\r\n    'starter' =&gt; [\r\n        'name'     =&gt; 'Starter',\r\n        'price'    =&gt; 999, \/\/ \u20b9999\/month\r\n        'features' =&gt; ['projects:25', 'users:10', 'storage:10gb', 'api_access'],\r\n    ],\r\n    'pro' =&gt; [\r\n        'name'     =&gt; 'Pro',\r\n        'price'    =&gt; 2999,\r\n        'features' =&gt; ['projects:unlimited', 'users:unlimited', 'storage:100gb', 'api_access', 'priority_support'],\r\n    ],\r\n];<\/code><\/pre>\n<pre><code>\/\/ Middleware to check plan features\r\nclass RequireFeature\r\n{\r\n    public function handle(Request $request, Closure $next, string $feature)\r\n    {\r\n        $tenant = TenantContext::require();\r\n\r\n        if (!$tenant-&gt;hasFeature($feature)) {\r\n            return response()-&gt;json([\r\n                'error'   =&gt; 'upgrade_required',\r\n                'message' =&gt; 'This feature requires a higher plan.',\r\n                'upgrade' =&gt; route('billing.upgrade'),\r\n            ], 403);\r\n        }\r\n\r\n        return $next($request);\r\n    }\r\n}\r\n\r\n\/\/ Usage in routes\r\nRoute::middleware(['tenant', 'auth', 'feature:api_access'])-&gt;group(function () {\r\n    Route::apiResource('api\/v1\/projects', ApiProjectController::class);\r\n});<\/code><\/pre>\n<h2>Step 10 \u2014 Razorpay Subscription Billing<\/h2>\n<pre><code>composer require razorpay\/razorpay<\/code><\/pre>\n<pre><code>&lt;?php\r\n\r\nnamespace App\\Services;\r\n\r\nuse App\\Models\\Tenant;\r\nuse Razorpay\\Api\\Api;\r\n\r\nclass BillingService\r\n{\r\n    private Api $razorpay;\r\n\r\n    public function __construct()\r\n    {\r\n        $this-&gt;razorpay = new Api(\r\n            config('services.razorpay.key'),\r\n            config('services.razorpay.secret')\r\n        );\r\n    }\r\n\r\n    public function createSubscription(Tenant $tenant, string $plan): array\r\n    {\r\n        $planConfig = config(\"plans.{$plan}\");\r\n\r\n        \/\/ Create Razorpay plan if not exists\r\n        $razorpayPlan = $this-&gt;razorpay-&gt;plan-&gt;create([\r\n            'period'   =&gt; 'monthly',\r\n            'interval' =&gt; 1,\r\n            'item'     =&gt; [\r\n                'name'     =&gt; $planConfig['name'] . ' Plan',\r\n                'amount'   =&gt; $planConfig['price'] * 100, \/\/ paise\r\n                'currency' =&gt; 'INR',\r\n            ],\r\n        ]);\r\n\r\n        \/\/ Create subscription\r\n        $subscription = $this-&gt;razorpay-&gt;subscription-&gt;create([\r\n            'plan_id'         =&gt; $razorpayPlan-&gt;id,\r\n            'total_count'     =&gt; 12, \/\/ 12 months\r\n            'customer_notify' =&gt; 1,\r\n            'notes'           =&gt; ['tenant_id' =&gt; $tenant-&gt;id],\r\n        ]);\r\n\r\n        $tenant-&gt;update([\r\n            'plan'                    =&gt; $plan,\r\n            'razorpay_subscription_id' =&gt; $subscription-&gt;id,\r\n        ]);\r\n\r\n        return ['subscription_id' =&gt; $subscription-&gt;id];\r\n    }\r\n}<\/code><\/pre>\n<h2>Security Checklist for Multi-Tenant Apps<\/h2>\n<table>\n<thead>\n<tr>\n<th>Check<\/th>\n<th>Implementation<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>All tenant tables have tenant_id index<\/td>\n<td>Migration<\/td>\n<\/tr>\n<tr>\n<td>Global scope on all tenant models<\/td>\n<td>BelongsToTenant trait<\/td>\n<\/tr>\n<tr>\n<td>Tenant middleware on all tenant routes<\/td>\n<td>Route middleware<\/td>\n<\/tr>\n<tr>\n<td>File uploads scoped to tenant folder<\/td>\n<td>Storage::disk()->put(&#8220;tenant_{id}\/&#8230;&#8221;)<\/td>\n<\/tr>\n<tr>\n<td>Redis cache keys prefixed with tenant_id<\/td>\n<td>Cache::prefix(&#8220;tenant_{id}&#8221;)<\/td>\n<\/tr>\n<tr>\n<td>Queued jobs include tenant_id context<\/td>\n<td>Job middleware<\/td>\n<\/tr>\n<tr>\n<td>API rate limits per tenant<\/td>\n<td>RateLimiter::for()<\/td>\n<\/tr>\n<tr>\n<td>Cross-tenant query test in test suite<\/td>\n<td>PHPUnit feature test<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>If you&#8217;re planning to build a SaaS product and need a solid multi-tenant foundation, <a href=\"https:\/\/softcrony.com\/contact\/\">our Laravel team at Softcrony can architect and build it for you<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Multi-tenancy is the architecture that makes SaaS possible \u2014 one application serving many customers, with their data completely isolated from each other. Laravel 13 makes this cleaner than ever with PHP Attributes, improved middleware, and better tooling. This guide builds a complete multi-tenant foundation in Laravel 13 \u2014 the patterns we use at Softcrony for [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":102,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[102,19,88,101,30,44],"class_list":["post-100","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-architecture","tag-laravel","tag-laravel-13","tag-multi-tenant","tag-php","tag-saas"],"_links":{"self":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/100","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/comments?post=100"}],"version-history":[{"count":1,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/100\/revisions"}],"predecessor-version":[{"id":101,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/100\/revisions\/101"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media\/102"}],"wp:attachment":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media?parent=100"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/categories?post=100"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/tags?post=100"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}