Laravel SaaS Starter: Teams, Stripe Billing, Horizon & Octane (Production Guide)
You’re here to ship, not write boilerplate. This starter gives you opinionated building blocks: teams, roles and permissions, Stripe billing and webhooks, queues via Horizon, and an Octane-ready structure so you can focus on product instead of plumbing.
Repo: laravel-saas-starter
Billing Package (Packagist): budventure/billing
What You Get (At a Glance)
- Team model, invites, roles/permissions
- Subscriptions, trials, invoices via Stripe
- Durable webhooks (signature verification + idempotency)
- Horizon queues with sane defaults
- Octane-ready structure for when you need more speed
1) Project Layout That Won’t Fight You Later
Why: keep billing isolated so you can version, reuse, and test it independently
apps/web # main Laravel app
packages/billing # Stripe/webhooks routes + provider (also published on Packagist)
routes/ # web/api entrypoints
resources/ # views, mail, assets
Install the billing package from Packagist (recommended):
composer require budventure/billing:^0.1
2) Teams, Roles & Permissions That Scale
Reality check: “is owner?”.checks don’t scale. Make it explicit.
- Scope everything by
team_id. - Define roles → permissions (e.g.,
billing:manage,members:invite,resources:publish). - Store the role on the pivot between
user ↔ team. - Emit audit events for invites, role updates, and membership changes.
Policy example (short and obvious)
public function manageBilling(User $user, Team $team): bool
{
return $user->inTeam($team) && $user->can('billing:manage');
}
Invite link (signed + short-lived)
URL::temporarySignedRoute(
'invites.accept',
now()->addHours(24),
['token' => $invite->token]
);
3) Stripe Billing: Fast Path to Revenue
Set these in production first:
STRIPE_KEY=pk_live_********
STRIPE_SECRET=sk_live_********
STRIPE_WEBHOOK_SECRET=whsec_********
Package routes (auto-registered):
- POST
/api/billing/webhook - GET
/api/billing/ping(sanity) - POST
/api/billing/portal(optional customer portal) - POST
/api/billing/subscribe(example subscription flow)
Why: the package handles signature verification and idempotency, so webhooks are safe to retry.
Verify your Stripe config:
// config/services.php
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
Local test with Stripe CLI:
stripe listen --forward-to http://localhost:8000/api/billing/webhook
stripe trigger checkout.session.completed
Common Production Traps (Do Not Gloss Over)
- Do not hardcode plan IDs in controllers — source them from config or the database.
- Upgrades: prorate by default (happy path for customers).
- Downgrades mid-cycle: often no-prorate plus schedule the change at period end.
- Always ACK webhooks quickly and push heavy work (emails, invoices, sync) onto a queue.
4) Webhooks That Never Lose Events
Why: network flaps and retries are normal; your job is to be idempotent.
Skeleton handler:
public function __invoke(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$event = WebhookSignature::verify($payload, $signature, config('services.stripe.webhook_secret'));
// idempotency guard
if (WebhookEvent::alreadyHandled($event->id)) {
return response()->noContent();
}
dispatch(new HandleStripeEvent($event->type, $event->data));
WebhookEvent::markHandled($event->id);
return response()->noContent();
}
Checklist:
- Verify the signature on every request.
- Idempotency by
event.id(or Stripe’sidfield). - Return a 2xx quickly and enqueue heavy work.
- Log minimal context (type, id) for audits and debugging.
5) Queues with Horizon: Boring Reliability
Why: webhooks, emails, and billing tasks shouldn’t hold HTTP requests hostage.
Enable Horizon:
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Secure Dashboard (only admins):
// app/Providers/HorizonServiceProvider.php
Horizon::auth(fn($request) => optional($request->user())->is_admin);
Forge (Supervisor) Baseline:
[program:horizon]
process_name=%(program_name)s
command=php /home/forge/yourapp/current/artisan horizon
autostart=true
autorestart=true
user=forge
redirect_stderr=true
stdout_logfile=/home/forge/.horizon.log
Sizing (start small, measure weekly)
- Small: 1–2 workers @ concurrency 5–10 for
default, 1 worker dedicated forwebhooks. - Medium: split
webhooks,billing,emailsinto dedicated queues with 2–4 workers each. - Set visibility timeout ≥ your longest job run.
6) Octane: The “Later” Lever
When to flip it on: p95 request time is CPU-bound in profiles, and most work is stateless
composer require laravel/octane --with-all-dependencies
php artisan octane:install
php artisan octane:start --server=swoole
Do a stateless audit first: no per-request mutable singletons, clean up global state in middleware, use cache/DB for shared context.
7) Deploy Notes (Forge & Vapor)
Forge (classic VPS)
- PHP-FPM + Nginx.
- Redis + Horizon (Supervisor) for queues.
- Let’s Encrypt with auto-renew.
- Daily DB backups with offsite copies.
- Fail2ban or Cloudflare WAF on
/api/billing/webhookif possible.
Vapor (serverless)
- Great for spiky workloads, queues via SQS.
- Use RDS for persistent DB and ElastiCache for Redis.
- Put the webhook behind API Gateway with strict routes.
Post-deploy warmups (if you SSR/ISR anywhere):
# hit top pages to build caches before users do
curl -sS https://yourapp.com/ | true
curl -sS https://yourapp.com/pricing | true
8) CI Sanity Checks (Catch Regressions Early)
Routing smoke test:
test('billing ping route exists', function () {
$route = collect(\Route::getRoutes())->firstWhere('action.as', 'billing.ping');
expect($route)->not->toBeNull();
});
Webhook signature unit:
test('rejects invalid stripe signature', function () {
$this->withHeaders(['Stripe-Signature' => 'bad'])
->postJson('/api/billing/webhook', '{}')
->assertStatus(400);
});
9) Production Checklist (Pin This)
STRIPE_*secrets set and rotated quarterly.- Webhook signature verified and idempotent by
event.id. - Horizon running; queues split; visibility timeout set correctly.
2xxreturned on webhook fast; heavy work always queued.- Role/permission policies exist for all billing actions.
- Audit events emitted for invites, role changes, and billing changes.
- Nginx restricts webhook path (method/UA/rate limits where possible).
- Backups not just configured, but tested.
Services & Help
Need senior hands to audit or build your SaaS?
React/Next & Laravel delivery sprints:
https://budventure.technology/reactjs-development
Want scope, timelines, and checkpoints first? Book a 15-minute discovery call: https://budventure.technology/connect
Copy-Paste Commands (Index Card)
Install Billing
composer require budventure/billing:^0.1
Horizon
composer require laravel/horizon
php artisan horizon:install && php artisan migrate
php artisan horizon
Stripe CLI Dev
stripe login
stripe listen --forward-to http://localhost:8000/api/billing/webhook
stripe trigger invoice.payment_succeeded
Octane (Later)
composer require laravel/octane --with-all-dependencies
php artisan octane:start --server=swoole
Where This Starter Fits in Your Growth Path
- MVP month 1–2: teams, invites, roles, basic subscriptions.
- Month 3–4: annual plans, coupons, usage metering.
- Scale: Horizon tuning, Octane, multi-region read replicas, SSO.