Building SaaS with Laravel 12 and Filament 4: Complete 2025 Guide
Learn how to build a production ready SaaS with Laravel 12 and Filament 4. Multi-tenancy, Stripe payments, admin panel and everything you need in one guide.
Building a SaaS application used to mean weeks of setup, complex infrastructure, and crossing your fingers that nothing breaks in production but with Laravel 12 and Filament 4, that's changed. Last month I helped a client go from idea to paying customers in three weeks. Not a toy demo, a real SaaS with subscriptions, team management, and a gorgeous admin panel.
In this guide, we're taking the fastest path to a working MVP. We'll use SQLite (Laravel's default) and a single database with smart tenant isolation. No Docker complexity, no microservices, no infrastructure headaches. Just a clean SaaS that you can deploy and start selling.
You'll walk away with production-ready code that handles up to 100+ tenants. Once you're making money and need to scale beyond that, I'll show you exactly where to upgrade.
Why Laravel 12 + Filament 4 for SaaS Development
Let me be honest, I've tried building SaaS with everything from Rails to Django to Node.js. Laravel 12 with Filament 4 is the first stack where I'm not fighting the framework.
What makes Laravel 12 perfect for SaaS:
- SQLite support out of the box (zero database setup)
- Improved query performance (30-40% faster in my tests)
- Better job batching for handling subscriptions and billing
- Built-in rate limiting per user/team
- Streamlined authentication with Breeze
Why Filament 4 is a game-changer:
- Build admin panels in minutes, not days
- Gorgeous UI that clients actually compliment
- Form builder handles 90% of CRUD operations
- Built-in table filters and search
- Easy to customize when you need to
Here's what sold me: I built ReplyGenius's entire admin dashboard in one afternoon. Tables, forms, bulk actions, everything. That would've taken me a week writing custom Blade templates.
The combination means you can focus on what makes your SaaS unique instead of rebuilding authentication and admin panels for the hundredth time.
Setting Up Your Laravel 12 SaaS Foundation
Let's get you up and running in the next 10 minutes. I'm assuming you have PHP 8.3+ and Composer installed.
composer create-project laravel/laravel:^12.0 saas-app
cd saas-app
Laravel 12 comes with SQLite configured by default. Check your .env file:
DB_CONNECTION=sqlite
# DB_DATABASE=database/database.sqlite (this is the default)
The database file is already created for you. No MySQL installation, no credentials to manage, no connection issues. It just works.
Install the essential packages:
# Filament for admin panel
composer require filament/filament:"^4.0"
# Spatie permissions for role management
composer require spatie/laravel-permission
# Laravel Cashier for Stripe subscriptions
composer require laravel/cashier
# Install Filament
php artisan filament:install --panels
# Run migrations
php artisan migrate
This sets up everything you need. The whole installation takes about 2 minutes.
Create your first admin user:
php artisan make:filament-user
Enter your name, email, and password. You can now access /admin and see your Filament dashboard.
Implementing Multi-Tenancy the Simple Way
Here's where most tutorials overcomplicate things. You don't need separate databases for each tenant when you're starting out. You need proper data isolation in a single database.
The approach we're using: every table that contains tenant-specific data gets a tenant_id column. Laravel's global scopes automatically filter queries so users only see their own data.
Create the Tenant model:
php artisan make:model Tenant -m
Set up the migration:
// database/migrations/xxxx_create_tenants_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('plan')->default('trial');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
});
// Add tenant_id to users table
Schema::table('users', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
});
Schema::dropIfExists('tenants');
}
};
Update your Tenant model:
// app/Models/Tenant.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $fillable = [
'name',
'slug',
'plan',
'trial_ends_at',
];
protected $casts = [
'trial_ends_at' => 'datetime',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function isOnTrial(): bool
{
return $this->plan === 'trial' &&
$this->trial_ends_at &&
$this->trial_ends_at->isFuture();
}
public function hasActiveSubscription(): bool
{
return in_array($this->plan, ['basic', 'pro', 'enterprise']);
}
}
Update the User model:
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
'tenant_id',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
// Automatically scope queries to current user's tenant
protected static function booted(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check() && auth()->user()->tenant_id) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
}
}
That booted() method is doing the heavy lifting. Any time you query users, it automatically adds WHERE tenant_id = X. You can't accidentally leak data between tenants because the framework won't let you.
Create a trait for other tenant-scoped models:
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check() && auth()->user()->tenant_id) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
static::creating(function ($model) {
if (auth()->check() && !$model->tenant_id) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
Now any model that needs tenant isolation just uses this trait:
class Post extends Model
{
use BelongsToTenant;
// That's it. Posts are now automatically scoped to the current tenant.
}
I use this pattern in StudyLab for quizzes, flashcards, and everything else. It's simple, it works, and I've never had a data leak.
Building Your Filament 4 Admin Panel
Here's where Filament really shines. We're going to build a complete tenant management interface in about 15 minutes.
Create the Tenant resource:
php artisan make:filament-resource Tenant --generate
This generates a complete CRUD interface. But we need to customize it for SaaS features.
Customize the Tenant resource:
// app/Filament/Resources/TenantResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
use App\Models\Tenant;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationGroup = 'System';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Organization Details')
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('slug', Str::slug($state))
),
Forms\Components\TextInput::make('slug')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->helperText('Used in URLs and identifiers'),
Forms\Components\Select::make('plan')
->options([
'trial' => 'Trial (14 days)',
'basic' => 'Basic - $29/month',
'pro' => 'Pro - $79/month',
'enterprise' => 'Enterprise - $199/month',
])
->required()
->default('trial'),
Forms\Components\DateTimePicker::make('trial_ends_at')
->label('Trial Ends')
->default(now()->addDays(14))
->visible(fn ($get) => $get('plan') === 'trial'),
])
->columns(2),
Forms\Components\Section::make('Statistics')
->schema([
Forms\Components\Placeholder::make('users_count')
->label('Total Users')
->content(fn ($record) => $record ? $record->users()->count() : 'N/A'),
Forms\Components\Placeholder::make('created_at')
->label('Created')
->content(fn ($record) => $record ? $record->created_at->diffForHumans() : 'N/A'),
])
->columns(2)
->visible(fn ($record) => $record !== null),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('slug')
->searchable()
->copyable()
->icon('heroicon-m-clipboard'),
Tables\Columns\BadgeColumn::make('plan')
->colors([
'warning' => 'trial',
'success' => 'basic',
'primary' => 'pro',
'danger' => 'enterprise',
])
->sortable(),
Tables\Columns\TextColumn::make('users_count')
->counts('users')
->label('Users')
->sortable(),
Tables\Columns\TextColumn::make('trial_ends_at')
->dateTime()
->sortable()
->color(fn ($record) =>
$record->trial_ends_at?->isPast() ? 'danger' : 'success'
)
->visible(fn ($record) => $record->plan === 'trial'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('plan')
->options([
'trial' => 'Trial',
'basic' => 'Basic',
'pro' => 'Pro',
'enterprise' => 'Enterprise',
])
->multiple(),
Tables\Filters\Filter::make('trial_expired')
->query(fn ($query) => $query->where('trial_ends_at', '<', now()))
->toggle()
->label('Trial Expired'),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->requiresConfirmation(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()
->requiresConfirmation(),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenants::route('/'),
'create' => Pages\CreateTenant::route('/create'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
];
}
}
Look at what you just got: searchable tables, filterable columns, bulk actions, color-coded badges, and a clean form. All with declarative code that's easy to read and maintain.
Add dashboard widgets to track SaaS metrics:
php artisan make:filament-widget StatsOverview --stats-overview
// app/Filament/Widgets/StatsOverview.php
namespace App\Filament\Widgets;
use App\Models\Tenant;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class StatsOverview extends BaseWidget
{
protected function getStats(): array
{
$totalTenants = Tenant::count();
$trialTenants = Tenant::where('plan', 'trial')->count();
$paidTenants = Tenant::whereIn('plan', ['basic', 'pro', 'enterprise'])->count();
// Calculate MRR
$mrr = Tenant::whereNotIn('plan', ['trial', null])->get()->sum(function ($tenant) {
return match($tenant->plan) {
'basic' => 29,
'pro' => 79,
'enterprise' => 199,
default => 0,
};
});
return [
Stat::make('Total Organizations', $totalTenants)
->description($paidTenants . ' paying customers')
->descriptionIcon('heroicon-m-arrow-trending-up')
->color('success')
->chart([7, 12, 18, 22, 25, 28, $totalTenants]),
Stat::make('Trial Users', $trialTenants)
->description('Convert to paid plans')
->descriptionIcon('heroicon-m-clock')
->color('warning'),
Stat::make('Monthly Revenue', '$' . number_format($mrr))
->description('MRR from ' . $paidTenants . ' customers')
->descriptionIcon('heroicon-m-currency-dollar')
->color('primary'),
];
}
}
Now when you open /admin, you see your key SaaS metrics at a glance. This is exactly what I check every morning, total tenants, trial conversions, and monthly revenue.
Integrating Stripe Subscriptions
Payment integration is where it gets real. You're not building a SaaS until money changes hands. Fortunately, Laravel Cashier makes Stripe integration straightforward.
Configure Cashier:
Add your Stripe keys to .env:
STRIPE_KEY=pk_test_your_key_here
STRIPE_SECRET=sk_test_your_secret_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Make Tenant billable:
// app/Models/Tenant.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;
class Tenant extends Model
{
use Billable;
protected $fillable = [
'name',
'slug',
'plan',
'trial_ends_at',
];
// ... rest of your model
public function subscribeToPlan(string $plan, string $paymentMethod)
{
$prices = [
'basic' => 'price_basic_monthly_id',
'pro' => 'price_pro_monthly_id',
'enterprise' => 'price_enterprise_monthly_id',
];
if (!isset($prices[$plan])) {
throw new \InvalidArgumentException("Invalid plan: {$plan}");
}
return $this->newSubscription('default', $prices[$plan])
->create($paymentMethod);
}
}
Run Cashier migrations:
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
This adds the necessary tables for subscriptions, invoices, and payment methods.
Create the subscription controller:
// app/Http/Controllers/SubscriptionController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Laravel\Cashier\Exceptions\IncompletePayment;
class SubscriptionController extends Controller
{
public function showPlans()
{
$tenant = auth()->user()->tenant;
return view('billing.plans', [
'tenant' => $tenant,
'intent' => $tenant->createSetupIntent(),
]);
}
public function subscribe(Request $request)
{
$request->validate([
'plan' => 'required|in:basic,pro,enterprise',
'payment_method' => 'required|string',
]);
$tenant = auth()->user()->tenant;
// Check if already subscribed
if ($tenant->subscribed('default')) {
return back()->with('error', 'Already subscribed to a plan');
}
try {
$tenant->createOrGetStripeCustomer();
$tenant->updateDefaultPaymentMethod($request->payment_method);
$tenant->subscribeToPlan($request->plan, $request->payment_method);
// Update tenant plan
$tenant->update([
'plan' => $request->plan,
'trial_ends_at' => null,
]);
return redirect()->route('dashboard')
->with('success', 'Successfully subscribed to ' . ucfirst($request->plan) . ' plan!');
} catch (IncompletePayment $e) {
return redirect()->route('billing.confirm-payment', [
'payment' => $e->payment->id,
])->with('message', 'Please confirm your payment');
}
}
public function confirmPayment($paymentId)
{
return view('billing.confirm-payment', [
'payment' => $paymentId,
]);
}
public function cancel(Request $request)
{
$tenant = auth()->user()->tenant;
if (!$tenant->subscribed('default')) {
return back()->with('error', 'No active subscription');
}
$tenant->subscription('default')->cancel();
return back()->with('success', 'Subscription cancelled. Access until period ends.');
}
}
Set up routes:
// routes/web.php
use App\Http\Controllers\SubscriptionController;
Route::middleware(['auth'])->group(function () {
Route::get('/billing/plans', [SubscriptionController::class, 'showPlans'])
->name('billing.plans');
Route::post('/billing/subscribe', [SubscriptionController::class, 'subscribe'])
->name('billing.subscribe');
Route::get('/billing/confirm/{payment}', [SubscriptionController::class, 'confirmPayment'])
->name('billing.confirm-payment');
Route::post('/billing/cancel', [SubscriptionController::class, 'cancel'])
->name('billing.cancel');
});
Create a simple pricing view:
{{-- resources/views/billing/plans.blade.php --}}
<x-app-layout>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center mb-8">Choose Your Plan</h2>
<div class="grid md:grid-cols-3 gap-6">
{{-- Basic Plan --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-xl font-bold mb-2">Basic</h3>
<p class="text-3xl font-bold mb-4">$29<span class="text-sm">/month</span></p>
<ul class="mb-6 space-y-2">
<li>✓ Up to 5 team members</li>
<li>✓ 10GB storage</li>
<li>✓ Email support</li>
</ul>
<button onclick="subscribeToPlan('basic')"
class="w-full bg-blue-600 text-white py-2 rounded">
Select Plan
</button>
</div>
{{-- Pro Plan --}}
<div class="bg-white rounded-lg shadow p-6 border-2 border-blue-600">
<h3 class="text-xl font-bold mb-2">Pro</h3>
<p class="text-3xl font-bold mb-4">$79<span class="text-sm">/month</span></p>
<ul class="mb-6 space-y-2">
<li>✓ Up to 25 team members</li>
<li>✓ 100GB storage</li>
<li>✓ Priority support</li>
<li>✓ Advanced analytics</li>
</ul>
<button onclick="subscribeToPlan('pro')"
class="w-full bg-blue-600 text-white py-2 rounded">
Select Plan
</button>
</div>
{{-- Enterprise Plan --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-xl font-bold mb-2">Enterprise</h3>
<p class="text-3xl font-bold mb-4">$199<span class="text-sm">/month</span></p>
<ul class="mb-6 space-y-2">
<li>✓ Unlimited team members</li>
<li>✓ 1TB storage</li>
<li>✓ 24/7 support</li>
<li>✓ Custom integrations</li>
</ul>
<button onclick="subscribeToPlan('enterprise')"
class="w-full bg-blue-600 text-white py-2 rounded">
Select Plan
</button>
</div>
</div>
</div>
</div>
@push('scripts')
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('{{ config('cashier.key') }}');
const clientSecret = '{{ $intent->client_secret }}';
async function subscribeToPlan(plan) {
const {error, paymentMethod} = await stripe.createPaymentMethod({
type: 'card',
card: {/* Stripe Elements card here */},
});
if (error) {
alert(error.message);
return;
}
// Submit form with payment method
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ route('billing.subscribe') }}';
form.innerHTML = `
@csrf
<input type="hidden" name="plan" value="${plan}">
<input type="hidden" name="payment_method" value="${paymentMethod.id}">
`;
document.body.appendChild(form);
form.submit();
}
</script>
@endpush
</x-app-layout>
Handle webhooks for subscription changes:
php artisan make:controller WebhookController
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use App\Models\Tenant;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class WebhookController extends CashierController
{
public function handleCustomerSubscriptionDeleted(array $payload)
{
$tenant = $this->getTenant($payload);
if ($tenant) {
$tenant->update(['plan' => 'trial']);
}
return $this->successMethod();
}
public function handleCustomerSubscriptionUpdated(array $payload)
{
$tenant = $this->getTenant($payload);
if ($tenant) {
$plan = $this->determinePlan($payload);
$tenant->update(['plan' => $plan]);
}
return $this->successMethod();
}
protected function getTenant(array $payload): ?Tenant
{
$stripeId = $payload['data']['object']['customer'] ?? null;
return $stripeId ? Tenant::where('stripe_id', $stripeId)->first() : null;
}
protected function determinePlan(array $payload): string
{
$priceId = $payload['data']['object']['items']['data'][0]['price']['id'] ?? null;
return match($priceId) {
'price_basic_monthly_id' => 'basic',
'price_pro_monthly_id' => 'pro',
'price_enterprise_monthly_id' => 'enterprise',
default => 'trial',
};
}
}
Register webhook route:
// routes/web.php
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook']);
Stripe webhooks keep your database in sync when customers upgrade, downgrade, or cancel. I learned the hard way to handle these, had a customer cancel through Stripe but our app thought they were still subscribed for a week.
Authentication and Team Management
Every SaaS needs user authentication and the ability for team members to collaborate. Laravel makes this simple with Breeze.
Install Laravel Breeze:
composer require laravel/breeze --dev
php artisan breeze:install blade
php artisan migrate
npm install && npm run dev
This gives you login, registration, password reset, and email verification out of the box.
Add role-based permissions with Spatie:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Create roles and permissions seeder:
// database/seeders/RolePermissionSeeder.php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
// Create permissions
$permissions = [
'manage team',
'manage billing',
'manage settings',
'create content',
'edit content',
'delete content',
'view analytics',
];
foreach ($permissions as $permission) {
Permission::create(['name' => $permission]);
}
// Create roles
$owner = Role::create(['name' => 'owner']);
$owner->givePermissionTo(Permission::all());
$admin = Role::create(['name' => 'admin']);
$admin->givePermissionTo([
'manage team',
'manage settings',
'create content',
'edit content',
'delete content',
'view analytics',
]);
$member = Role::create(['name' => 'member']);
$member->givePermissionTo([
'create content',
'edit content',
'view analytics',
]);
}
}
Run the seeder:
php artisan db:seed --class=RolePermissionSeeder
Update User model:
// app/Models/User.php
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
// ... rest of your model
}
Protect routes with permissions:
// routes/web.php
Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::middleware(['permission:manage team'])->group(function () {
Route::get('/team', [TeamController::class, 'index'])->name('team.index');
Route::post('/team/invite', [TeamController::class, 'invite'])->name('team.invite');
Route::delete('/team/{user}', [TeamController::class, 'remove'])->name('team.remove');
});
Route::middleware(['permission:manage billing'])->group(function () {
Route::get('/billing', [BillingController::class, 'index'])->name('billing.index');
});
});
Create a team management controller:
// app/Http/Controllers/TeamController.php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class TeamController extends Controller
{
public function index()
{
$tenant = auth()->user()->tenant;
$users = $tenant->users()->with('roles')->get();
return view('team.index', compact('users'));
}
public function invite(Request $request)
{
$request->validate([
'email' => 'required|email|unique:users,email',
'name' => 'required|string|max:255',
'role' => 'required|in:admin,member',
]);
$tenant = auth()->user()->tenant;
// Create user with temporary password
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make(Str::random(16)),
'tenant_id' => $tenant->id,
]);
$user->assignRole($request->role);
// TODO: Send invitation email with password reset link
return back()->with('success', 'Team member invited successfully');
}
public function remove(User $user)
{
// Prevent removing the last owner
if ($user->hasRole('owner') && $user->tenant->users()->role('owner')->count() === 1) {
return back()->with('error', 'Cannot remove the last owner');
}
$user->delete();
return back()->with('success', 'Team member removed');
}
}
This gives you basic team management, invite users, assign roles, and remove team members. The permission system ensures only authorized users can access sensitive features like billing.
Building Tenant-Scoped Features
Now let's create a feature that's automatically scoped to the current tenant. I'll use a simple "Posts" example, you can adapt this to whatever your SaaS does.
Create the Post model:
php artisan make:model Post -m
Migration:
// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('content');
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'status']);
});
Post model with tenant scoping:
// app/Models/Post.php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'user_id',
'title',
'content',
'status',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isPublished(): bool
{
return $this->status === 'published' &&
$this->published_at &&
$this->published_at->isPast();
}
}
Create Filament resource for Posts:
php artisan make:filament-resource Post --generate
// app/Filament/Resources/PostResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\PostResource\Pages;
use App\Models\Post;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(255),
Forms\Components\RichEditor::make('content')
->required()
->columnSpanFull(),
Forms\Components\Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
])
->required()
->default('draft'),
Forms\Components\DateTimePicker::make('published_at')
->label('Publish Date')
->visible(fn ($get) => $get('status') === 'published'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'secondary' => 'draft',
'success' => 'published',
]),
Tables\Columns\TextColumn::make('user.name')
->label('Author')
->sortable(),
Tables\Columns\TextColumn::make('published_at')
->dateTime()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
]),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
}
Because we used the BelongsToTenant trait, posts are automatically scoped. When Tenant A creates a post, Tenant B can never see it. The framework handles this for you.
Simple Deployment Strategy
You don't need Kubernetes or a complex CI/CD pipeline to launch your SaaS. Here's how I deploy MVPs that handle thousands of users.
Option 1: Traditional shared hosting (simplest)
Most shared hosts support SQLite and PHP 8.3. Just:
- Upload your code via FTP/Git
- Run
composer install --no-dev - Set up your
.envfile - Run
php artisan migrate --force - Point your domain to
/public
That's it. This works great for the first 50-100 tenants.
Option 2: DigitalOcean/Linode VPS (recommended)
I run StudyLab on a $12/month DigitalOcean droplet. Here's the setup:
# On your server
git clone your-repo
cd your-repo
composer install --no-dev --optimize-autoloader
cp .env.example .env
php artisan key:generate
php artisan migrate --force
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Set permissions
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
Configure Nginx:
server {
listen 80;
server_name yoursaas.com;
root /var/www/html/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Set up queue worker with Supervisor:
[program:saas-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
Enable SSL with Certbot:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yoursaas.com
Done. Your SaaS is live with HTTPS and background job processing.
Quick deployment script:
# deploy.sh
#!/bin/bash
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan cache:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
echo "Deployment complete!"
Run this script whenever you push updates. For zero-downtime deployments, you can use Laravel Envoy or just pay $10/month for Laravel Forge to handle it.
Performance Optimization Tips
SQLite is surprisingly fast, but you still need to optimize for production.
1. Enable query caching for expensive queries:
// Instead of this:
$stats = Tenant::where('plan', '!=', 'trial')->count();
// Do this:
$stats = Cache::remember('paid-tenants-count', now()->addHours(6), function () {
return Tenant::where('plan', '!=', 'trial')->count();
});
2. Add database indexes:
// In your migrations, index columns you query frequently
$table->index('tenant_id');
$table->index(['tenant_id', 'status']);
$table->index('created_at');
I added an index on tenant_id + status in StudyLab and queries went from 200ms to 15ms.
3. Use eager loading to avoid N+1 queries:
// Bad (N+1 problem)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name; // Each iteration hits database
}
// Good (single query)
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->user->name; // User already loaded
}
4. Enable OPcache in production:
# php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
This caches compiled PHP code and makes your app 2-3x faster.
5. Use Redis for sessions when you grow:
composer require predis/predis
SESSION_DRIVER=redis
CACHE_DRIVER=redis
REDIS_CLIENT=predis
But honestly, file-based sessions work fine until you're doing 1000+ requests per minute.
Common Mistakes to Avoid
I've made every mistake building SaaS platforms. Learn from my pain:
Not testing tenant isolation thoroughly. Write automated tests that create multiple tenants and verify they can't see each other's data. This is critical, one data leak destroys trust.
// tests/Feature/TenantIsolationTest.php
public function test_users_cannot_see_other_tenant_posts()
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$user1 = User::factory()->for($tenant1)->create();
$user2 = User::factory()->for($tenant2)->create();
$post1 = Post::factory()->for($tenant1)->for($user1)->create();
$this->actingAs($user2);
$this->assertDatabaseMissing('posts', [
'id' => $post1->id,
'tenant_id' => $tenant2->id,
]);
}
Forgetting to handle subscription failures. Stripe webhooks can fail. Cards get declined. Have a plan for handling this gracefully instead of locking customers out immediately.
Not backing up your SQLite database. Set up a daily cron job:
# In crontab
0 2 * * * cp /var/www/html/database/database.sqlite /backups/database-$(date +\%Y\%m\%d).sqlite
Hardcoding plan prices everywhere. Create a config file:
// config/plans.php
return [
'basic' => [
'name' => 'Basic',
'price' => 29,
'stripe_price_id' => 'price_xxx',
'features' => ['5 users', '10GB storage'],
],
'pro' => [
'name' => 'Pro',
'price' => 79,
'stripe_price_id' => 'price_yyy',
'features' => ['25 users', '100GB storage', 'Analytics'],
],
];
Not planning for trial expirations. Set up a scheduled task to handle expired trials:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
$schedule->call(function () {
Tenant::where('plan', 'trial')
->where('trial_ends_at', '<', now())
->each(function ($tenant) {
// Send reminder email
// Optionally downgrade/disable account
});
})->daily();
}
Skipping monitoring. Install Laravel Telescope for debugging:
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
Access it at /telescope to see queries, jobs, requests, and exceptions in real-time.
When to Scale Beyond This Setup
The single-database SQLite approach works great for launching and growing to your first 50-100 paying customers. But you'll eventually need to upgrade. Here's when:
Move to PostgreSQL/MySQL when:
- You hit 50,000+ records and queries slow down
- You need full-text search across large datasets
- You're getting 10+ concurrent write operations
- Multiple servers need to access the same database
Separate databases per tenant when:
- Enterprise customers require it for compliance
- You have 100+ tenants with heavy data usage
- You need to offer data export/backup per tenant
- One tenant's data is becoming massive (100GB+)
Add Docker when:
- You're managing multiple staging/production environments
- Your dev team is larger than 2-3 people
- You need consistent deployments across different servers
- You're adding complex dependencies (Redis, Elasticsearch, etc.)
Use Redis for caching/sessions when:
- You're serving 1000+ requests per minute
- Page load times creep above 500ms
- You need real-time features (notifications, live updates)
- You're running multiple web server instances
The migration path looks like this:
- Start: SQLite + single database (you are here)
- 20 tenants: Add Redis for caching
- 50 tenants: Move to PostgreSQL
- 100 tenants: Add separate databases per tenant
- 500+ tenants: Docker + load balancing + separate DB server
I've helped clients make these exact transitions. The beautiful part about starting simple is that Laravel handles all of this with minimal code changes. Your application logic stays the same, you just swap configuration.
What's Next?
You now have a working SaaS foundation that can get you to your first paying customers. Here's what I recommend building next:
Add email notifications so customers know when their trial is ending, payment succeeds/fails, or team members are added. Laravel makes this dead simple with Markdown emails.
Build a customer-facing API if your SaaS benefits from integrations. Laravel Sanctum handles API authentication in about 10 minutes.
Implement usage-based billing if your SaaS has variable costs (API calls, storage, processing time). Stripe supports metered billing and Cashier handles the integration.
Add analytics so tenants see how they're using your product. Even basic stats (total posts, team activity) increase engagement.
Set up proper error monitoring with something like Sentry or Flare. You need to know when things break before your customers complain.
The architecture I've shown you is exactly what I use for StudyLab and ReplyGenius. It's not sexy or over-engineered, but it works. I spend time building features customers want instead of debugging infrastructure.
Start simple. Ship fast. Get paying customers. Then scale based on real usage, not imagined future requirements.
Need help implementing this for your specific use case? I've built this stack multiple times and can help you avoid the painful mistakes. Let's work together: Contact me
Need Help With Your Laravel Project?
I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.
⚡ Currently available for 2-3 new projects
About Hafiz Riaz
Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.
View Portfolio →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Setting Up Laravel 10 with Vue3 and Vuetify3
A complete guide to seamlessly integrating Vue3 and Vuetify3 into Laravel 10 usi...
Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Unleash the Full Potential of Laravel 10 with Vue 3 and Vuetify 3 in a Dockerize...
Mastering Design Patterns in Laravel
Unraveling the Secrets Behind Laravel’s Architectural Mastery