Architecting Laravel SaaS
For a new SaaS feature request, follow this pattern immediately:
Bashphp artisan make:model Tenant -mfs php artisan make:controller Api/TenantController --api --model=Tenant php artisan make:request StoreTenantRequest php artisan make:policy TenantPolicy --model=Tenant php artisan make:resource TenantResource
Structure logic across layers — never put business logic in controllers:
PHP// app/Http/Controllers/Api/TenantController.php public function store(StoreTenantRequest $request, TenantService $service) { $tenant = $service->createTenant($request->validated()); return new TenantResource($tenant); } // app/Services/TenantService.php public function createTenant(array $data): Tenant { return DB::transaction(function () use ($data) { $tenant = Tenant::create($data); $tenant->databases()->create(['name' => 'tenant_' . $tenant->id]); event(new TenantCreated($tenant)); return $tenant; }); }
Progress checklist for building a Laravel SaaS module:
- Define domain model and relationships (migrations + models)
- Decide multi-tenancy strategy (single DB with
tenant_id, or DB-per-tenant) - Create Form Requests for validation (never validate in controller)
- Create Service/Action classes for business logic
- Create Policies for authorization (never inline
ifchecks on roles) - Create API Resources for consistent JSON output
- Add Jobs/Events for async work (billing, emails, provisioning)
- Write Feature tests covering the happy path + edge cases
- Add rate limiting / throttling on public endpoints
- Document endpoints (OpenAPI or Postman collection)
1. Multi-Tenancy Strategy
Default recommendation: single database, tenant_id column + global scope, unless client requires strict data isolation (then use stancl/tenancy package with DB-per-tenant).
PHPclass TenantScope implements Scope { public function apply(Builder $builder, Model $model) { if ($tenantId = app('currentTenant')?->id) { $builder->where('tenant_id', $tenantId); } } }
2. Subscription Billing
Use laravel/cashier (Stripe/Paddle) as default. Model plans as a plans table, not hardcoded enums, so pricing can change without deploys.
3. Authentication & Authorization
- Sanctum for SPA/API tokens.
- Spatie
laravel-permissionfor roles/permissions. - Always gate at Policy level, test policies independently.
4. Layered Architecture (MVC+)
Controller -> thin, only orchestrates request/response
Request -> validation
Service -> business logic, transactional boundaries
Model -> Eloquent, relationships, scopes only
Resource -> API shape/transformation
Event/Job -> async side effects
Example 1: Input: "Add a feature where creating a company also creates a default admin user and sends a welcome email." Output:
PHP// TenantService.php public function createTenant(array $data): Tenant { return DB::transaction(function () use ($data) { $tenant = Tenant::create($data); $admin = $tenant->users()->create([ 'name' => $data['admin_name'], 'email' => $data['admin_email'], 'password' => Hash::make(Str::random(16)), ]); $admin->assignRole('admin'); SendWelcomeEmail::dispatch($admin); return $tenant; }); }
Example 2:
Input: "Client wants usage-based billing per API call."
Output: Recommend Cashier's metered billing with Stripe usage records; log usage via a UsageRecorded event listener that increments a usage_counters table, then a scheduled job reports aggregated usage to Stripe nightly via reportUsageFor().
- Keep controllers under ~15 lines; push logic to Services/Actions.
- Use Form Requests +
authorize()method for every write endpoint. - Always wrap multi-step writes in
DB::transaction(). - Use Events for cross-cutting concerns (emails, webhooks, audit logs) instead of coupling in Services.
- Use API Resources, never return raw Eloquent models as JSON.
- Cache expensive tenant-level config/lookups with tagged cache (
Cache::tags(['tenant:'.$id])). - Version your API (
/api/v1/...) from day one. - Use queues for anything touching third-party APIs (payment, email, webhooks).
- Write feature tests per tenant scenario, not just per endpoint.
- Forgetting a global scope on tenant-scoped models → cross-tenant data leaks.
- Putting billing logic directly in controllers instead of a
BillingService. - Using
env()outside config files — always wrap inconfig(). - Skipping policies and checking
role === 'admin'inline in controllers. - Not queuing webhook handlers (Stripe/Paddle) — causes timeouts under load.
- Hardcoding plan limits in code instead of a
plans/featurestable. - Sharing a single Redis/cache namespace across tenants without prefixing keys.