Multi-tenancy, Stripe subscriptions, user roles, onboarding flows, feature flags, email verification, audit logs, and the deployment stack — a complete, opinionated walkthrough of the decisions that matter when you’re building a real product, not a tutorial project.
Every “build a SaaS with Laravel” tutorial ends at the same place: a working subscription checkout and a demo video. Nobody’s account is deleted afterward, nobody disputes a charge, no customer asks support why their team member can see data they shouldn’t, and nobody’s trial silently fails to convert because the onboarding email never sent. Six weeks after a real launch, those are exactly the things that happen — and they happen to the parts of the app the tutorial skipped because they’re not interesting to record a video about.
This post covers the parts that are actually load-bearing for a real product: choosing (and correctly scoping) a multi-tenancy model before writing application code, wiring Stripe subscriptions with Cashier the way that survives a failed webhook, roles and permissions enforced consistently, an onboarding flow that doesn’t rely on a single email delivering, feature flags for gating plan tiers without branching code, audit logs that actually answer “who did this” during a support ticket, and the deployment stack underneath all of it. Not a demo. The version that’s still standing at month six.
Multi-Tenancy — The Decision You Make Once, Correctly, or Live With
This is the first architectural decision in the entire project, and it constrains everything built after it. Three real models, not a spectrum of vague options:
Single database, tenant_id column. Every tenant’s data lives in the same tables, scoped by a tenant_id foreign key and a global query scope. Cheapest to run, simplest to migrate, and the one place a single missed where('tenant_id', ...) clause becomes a cross-tenant data leak — visible in production as one customer seeing another customer’s data, which is the single worst bug class a SaaS product can ship.
// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
if ($tenantId = app(TenantContext::class)->id()) {
$builder->where('tenant_id', $tenantId);
}
});
static::creating(function (Model $model) {
$model->tenant_id ??= app(TenantContext::class)->id();
});
}
}
The global scope makes the common case safe by default — every normal query through the model respects tenant boundaries automatically. It does not make raw queries, aggregate reports run outside a request context, or queued jobs that lose tenant context safe. Every job dispatched from a tenant-scoped request needs to explicitly carry and re-establish the tenant context on the queue worker — a job that runs Post::count() without tenant context re-established either throws (safe) or, if the scope silently no-ops instead of failing loud, counts every tenant’s posts together (very much not safe). Design the TenantContext implementation to fail loud, not silently, when it’s asked for an ID it doesn’t have.
Single database, schema-per-tenant. Postgres schemas or MySQL logical databases per tenant, same physical server. Better isolation than a shared tenant_id — a bug in one tenant’s query literally cannot touch another schema’s tables — at the cost of migrations needing to run against every schema individually, and connection-pooling complexity that a shared-table model doesn’t have.
Database-per-tenant. Full physical isolation — packages like stancl/tenancy automate the provisioning, connection-switching, and per-tenant migration story. This is the right call when tenants have compliance requirements that demand physical data separation (certain healthcare, financial, or enterprise contracts explicitly require it), or when a single large tenant’s query load needs to be isolated so it can’t degrade performance for everyone else. It is meaningfully more operational overhead — connection pool exhaustion at scale, migration rollout across every tenant database, backup/restore per tenant — and that overhead is a real cost, not a hypothetical one, from tenant number one.
The decision that matters most: pick based on actual isolation requirements, not anticipated scale. A shared tenant_id model, built with a genuinely enforced global scope and disciplined query review, comfortably serves thousands of tenants. Database-per-tenant solves an isolation problem, not a scale problem — reaching for it because “we’ll need to scale eventually” when the actual requirement is “no compliance mandate for physical separation exists” adds real operational cost for a problem the product doesn’t have yet.
Stripe Subscriptions With Cashier — Built for the Webhook That Fails
Laravel Cashier (currently on Stripe API version 2025-06-30.basil as of Cashier 16) handles the subscription lifecycle’s happy path well. The part tutorials skip is that Stripe communicates state changes to your app via webhooks, and webhooks are not guaranteed to arrive, arrive once, or arrive in order.
// app/Models/Tenant.php
class Tenant extends Model
{
use Billable;
}
// Subscribing a tenant to a plan
$tenant->newSubscription('default', 'price_pro_monthly')
->trialDays(14)
->create($paymentMethodId);
// routes/web.php — Cashier's webhook controller handles signature verification
// and the standard events (subscription updated, cancelled, payment failed) out
// of the box. Extend it for anything app-specific rather than replacing it.
class StripeWebhookController extends CashierController
{
public function handleInvoicePaymentFailed(array $payload): Response
{
$subscription = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($subscription) {
$subscription->notify(new PaymentFailedNotification($payload));
}
return parent::handleInvoicePaymentFailed($payload);
}
}
The failure mode most launches discover the hard way: a webhook that fails mid-processing, or never arrives at all, leaves the local subscription status permanently out of sync with what Stripe actually has. A customer’s card gets declined, Stripe marks the subscription past_due, the webhook that should propagate that fires — and the app’s queue worker is mid-deploy and misses it. From that moment on, the local database says “active” while Stripe says “past due,” and nothing corrects it until someone manually checks.
// app/Console/Commands/ReconcileSubscriptions.php — the safety net webhooks alone don't provide
class ReconcileSubscriptions extends Command
{
protected $signature = 'subscriptions:reconcile';
public function handle(): void
{
Tenant::whereNotNull('stripe_id')->chunk(100, function ($tenants) {
foreach ($tenants as $tenant) {
$stripeSubscription = $tenant->subscription('default')?->asStripeSubscription();
if ($stripeSubscription && $stripeSubscription->status !== $tenant->subscription('default')->stripe_status) {
$tenant->subscription('default')->update([
'stripe_status' => $stripeSubscription->status,
]);
Log::warning('Subscription reconciled — local state was stale', [
'tenant_id' => $tenant->id,
]);
}
}
});
}
}
// Scheduled daily — catches whatever webhooks silently missed
Schedule::command('subscriptions:reconcile')->daily();
Webhooks should be the primary sync mechanism — they’re fast and event-driven, and most of the time they work exactly as expected. A daily reconciliation job is the safety net for the ones that don’t, and it’s cheap insurance against the specific support ticket where a customer insists they upgraded and the app insists they’re still on the free plan.
Idempotency matters as much as reconciliation. Stripe can and does deliver the same webhook event more than once. Cashier’s default event handling is largely idempotent for standard subscription events, but any custom webhook handler that triggers a side effect — sending an email, incrementing a usage counter, provisioning a resource — needs to check whether it already processed that specific event ID before acting on it again.
public function handleCheckoutSessionCompleted(array $payload): Response
{
$eventId = $payload['id'];
if (Cache::has("stripe_event:{$eventId}")) {
return new Response('Already processed', 200);
}
// ... provisioning logic ...
Cache::put("stripe_event:{$eventId}", true, now()->addDays(7));
return new Response('Processed', 200);
}
Roles and Permissions — One Vocabulary, Tenant-Scoped
A SaaS product’s permission model has a dimension a single-tenant app doesn’t: roles are almost always scoped within a tenant, not global to the user. The same person can be an admin on their own team and a regular member on a team they were invited to.
// A user's role is a property of the membership, not the user record
class TeamMembership extends Model
{
protected $fillable = ['user_id', 'tenant_id', 'role'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// app/Policies/PostPolicy.php
class PostPolicy
{
public function update(User $user, Post $post): bool
{
$membership = $user->membershipFor($post->tenant_id);
return $membership && in_array($membership->role, ['admin', 'editor']);
}
}
Spatie’s laravel-permission package works within this model too, but it needs to be team-aware from the start — the package supports “teams” natively, and retrofitting team-scoping onto roles that were built global-to-the-user is a real migration, not a config change, once real tenant data exists. The decision to scope roles per-membership from the first migration, rather than adding it “later, if we need multi-team support,” is one of the cheaper-early, expensive-later calls in this entire build.
The frontend permission pattern from the architecture side of this stack applies directly here — permissions computed once on the backend, shared to the frontend as data via Inertia’s shared props, checked in Vue purely for UX, with the actual enforcement living exclusively in policies. A “hide the delete button if not admin” check in Vue with no corresponding $this->authorize('delete', $post) in the controller is a real vulnerability in a multi-tenant app specifically, because the blast radius of getting it wrong is “a user from tenant A reaches an action meant only for tenant A’s own admins” — not a cosmetic UI bug.
Onboarding — Built to Survive a Failed Email
The tutorial version of onboarding is: user registers, gets a welcome email, done. The real version has to survive the email not arriving, the user closing the tab mid-flow, and the difference between “signed up” and “actually activated” mattering for both product metrics and support.
// app/Models/Tenant.php
protected $casts = [
'onboarding_completed_steps' => 'array',
];
public function onboardingProgress(): float
{
$total = count(OnboardingStep::cases());
$completed = count($this->onboarding_completed_steps ?? []);
return $total > 0 ? $completed / $total : 0;
}
enum OnboardingStep: string
{
case EmailVerified = 'email_verified';
case TeamNamed = 'team_named';
case FirstProjectCreated = 'first_project_created';
case TeamMemberInvited = 'team_member_invited';
}
Tracking onboarding as an explicit, resumable set of steps — not an assumption that a linear wizard completed in one sitting — means a user who verifies email on day one and creates their first project on day three isn’t treated as having “abandoned onboarding.” It also gives product analytics something honest to measure: which specific step has the worst completion rate, rather than a single “% who finished onboarding” number that doesn’t say where people actually get stuck.
Email verification specifically needs a fallback path that doesn’t depend on the email arriving. A percentage of verification emails — sometimes a meaningful percentage, depending on the email provider and the recipient’s spam filtering — never reach the inbox, or land in spam long enough that the user gives up.
// A resend action that's cheap to expose prominently in the UI,
// not buried, because "email didn't arrive" is a routine occurrence, not an edge case
public function resend(Request $request): RedirectResponse
{
RateLimiter::attempt(
"verification-resend:{$request->user()->id}",
3,
function () use ($request) {
$request->user()->sendEmailVerificationNotification();
},
3600
);
return back()->with('status', 'verification-link-sent');
}
Rate-limited, but not hidden — a “resend verification email” action that’s one click away, visibly, is the difference between a user who retries and a user who quietly churns before ever reaching activation, blamed silently on “onboarding conversion” without anyone realizing the actual cause was deliverability.
Feature Flags — Gating Plan Tiers Without Branching Code
Plan-tier feature gating implemented as scattered if ($tenant->plan === 'pro') checks throughout the codebase is the specific pattern that makes launching a new plan tier, or moving a feature between tiers, a multi-file hunt-and-change operation instead of a config update.
// app/Providers/AppServiceProvider.php
use Laravel\Pennant\Feature;
Feature::define('advanced-analytics', function (Tenant $tenant) {
return in_array($tenant->plan, ['pro', 'enterprise']);
});
Feature::define('api-access', function (Tenant $tenant) {
return $tenant->plan === 'enterprise';
});
// Anywhere in the app — backend
if (Feature::for($tenant)->active('advanced-analytics')) {
// ...
}
// Shared to the frontend the same way permissions are — computed once, sent as data
'features' => [
'advanced-analytics' => Feature::for($tenant)->active('advanced-analytics'),
'api-access' => Feature::for($tenant)->active('api-access'),
],
Laravel Pennant’s value here isn’t just plan-tier gating — the same mechanism covers staged rollouts of a genuinely new feature to a percentage of tenants, or an opt-in beta for specific tenant IDs, without any of it requiring a deploy to change who has access. Moving a feature from the Pro tier to the Starter tier becomes a one-line change to the feature definition’s closure, not a search across the codebase for every place that checked $tenant->plan === 'pro' directly.
The trap: defining feature flags per-user instead of per-tenant in a product where the actual billing and access boundary is the tenant, not the individual user. A team member on the Free plan whose teammate is somehow evaluated against a different flag state than they are is a bug, and it happens when the feature definition closure accepts a User instead of a Tenant — check plan-gated features against whatever your billing entity actually is, consistently.
Audit Logs — Built for the Support Ticket, Not the Compliance Checkbox
The honest reason most SaaS products need audit logs isn’t a compliance requirement — it’s the support ticket that reads “someone on my team deleted our data and I don’t know who.” Without an audit trail, that question is unanswerable after the fact, and it’s one of the worst possible answers to give a paying customer.
// app/Models/AuditLog.php
class AuditLog extends Model
{
protected $fillable = ['tenant_id', 'user_id', 'action', 'auditable_type', 'auditable_id', 'changes'];
protected $casts = ['changes' => 'array'];
}
// app/Observers/AuditableObserver.php — attached to any model that needs a trail
class AuditableObserver
{
public function updated(Model $model): void
{
AuditLog::create([
'tenant_id' => $model->tenant_id,
'user_id' => auth()->id(),
'action' => 'updated',
'auditable_type' => get_class($model),
'auditable_id' => $model->id,
'changes' => $model->getChanges(),
]);
}
public function deleted(Model $model): void
{
AuditLog::create([
'tenant_id' => $model->tenant_id,
'user_id' => auth()->id(),
'action' => 'deleted',
'auditable_type' => get_class($model),
'auditable_id' => $model->id,
'changes' => $model->getAttributes(),
]);
}
}
Storing getChanges() rather than the full model state on every update keeps the log readable — “changed status from draft to published” is answerable at a glance, where a full before/after snapshot of every field on every update buries the one field that actually changed in twenty that didn’t. For deletions, storing the full attribute set is deliberate — it’s the only remaining record of what the row contained once it’s actually gone (or soft-deleted and eventually pruned).
Audit logs need their own retention policy and their own performance consideration, not an afterthought. A high-activity tenant can generate an audit log entry on every single field update across every model — this table grows faster than almost anything else in the schema, and it needs the same pruning discipline discussed for soft-deleted records: index it for the query pattern that actually matters (tenant_id + date range, almost always), and prune or archive entries past whatever retention window the product actually commits to, rather than letting it grow unbounded on the assumption that “we might need it someday.”
The Deployment Stack
A SaaS product’s deployment story has requirements a portfolio project doesn’t: zero-downtime deploys (a deploy happening mid-checkout shouldn’t fail a paying customer’s payment), queue workers that survive deploys without dropping in-flight jobs, and a database migration strategy that doesn’t lock tables a live tenant is actively querying.
# The shape of a zero-downtime deploy, regardless of specific host
# 1. Build assets and run `php artisan config:cache`, `route:cache`, `view:cache`
# BEFORE swapping traffic — never cache config on a server already serving requests
# with the old config, and never leave a server serving requests with no cache at all.
# 2. Run migrations against the new codebase, but keep them additive-first —
# add columns/tables before deploying code that uses them, drop columns/tables
# only after deploying code that no longer references them. A migration and a
# deploy that both assume the other happened atomically is how a deploy takes
# the app down for the seconds in between.
# 3. Restart queue workers with `php artisan queue:restart`, not a hard kill —
# this signals workers to finish their current job before exiting, so an
# in-flight webhook handler or email send isn't dropped mid-execution.
# 4. Swap traffic to the new deployment only after health checks pass.
Queue infrastructure specifically deserves more attention than it usually gets in a first launch. Stripe webhooks, onboarding emails, audit log writes for high-frequency updates, and reconciliation jobs are all naturally queue-driven work — running any of them synchronously in the request cycle is the difference between a checkout that completes in 200ms and one that hangs for however long Stripe’s API happens to take that request. Horizon (for Redis-backed queues) gives visibility into failed jobs, retry behavior, and queue depth that’s genuinely necessary once webhook processing and email delivery are load-bearing parts of the product, not optional.
Database backups need a tested restore process, not just a scheduled backup job. A backup that’s never been restored is a hypothesis, not a safety net — the number of teams that discover their backup format was subtly wrong, or their restore process assumed a database size that no longer matches reality, only during an actual incident is not small. Testing a full restore, on a schedule, before it’s needed, is the difference between an incident and a disaster.
The One Rule
Every piece in this post is the answer to the same underlying question: what happens when the happy path doesn’t happen? The webhook that doesn’t arrive, the verification email that lands in spam, the deploy that lands mid-request, the backup nobody’s tested restoring, the audit trail nobody built until the support ticket that needed it already happened. A tutorial project doesn’t need answers to any of these because it never runs long enough, or carries enough real users, to hit them. A real product hits every one of them, usually within the first few months, usually at the worst possible time to be solving it for the first time. The work of building a SaaS isn’t the subscription checkout — that part genuinely is close to a solved problem with Cashier. It’s building every one of these seams before the failure that exposes them happens in front of a paying customer instead of in a design review.
