laravel-saas maintained by coollabsio
Laravel SaaS
Teams, Stripe billing, and self-hosted mode for Laravel applications. Built on top of Laravel Cashier and Inertia.js.
Used for upcoming apps at coolLabs.
Andras: It is opinionated, so maybe it is not useful for you.
Features
- Teams — create, manage, switch teams; invite members via email
- Billing — tiered plans (Free/Pro/Enterprise) or dynamic usage-based billing via Stripe
- Self-hosted mode — disable billing entirely, unlock all features
- Plan gating — middleware and model methods to restrict features by plan
- Inertia integration — shared props middleware + publishable Vue components
Requirements
- PHP 8.2+
- Laravel 12
- Laravel Cashier 16+
- Inertia.js v2 (with Vue 3)
Installation
composer require coollabsio/laravel-saas
php artisan saas:install
php artisan migrate
The install command will:
- Publish
config/saas.php - Publish all Vue components and route files
- Register the package test suite in
phpunit.xmlandtests/Pest.php
Updating after a package upgrade
composer update coollabsio/laravel-saas
php artisan saas:install --update
php artisan migrate
The --update flag will:
- Force-update all managed Vue stubs (see Published files)
- Force-update
config/saas.phpwith new keys - Publish any new route files that don't exist yet
Publish the Plan enum (optional)
php artisan vendor:publish --tag=saas-plan
Copies a customizable Plan enum to app/Enums/Plan.php. Update saas.plan_enum in your config to point to it.
Published files
The install command publishes files into your app. These are split into two categories:
Managed stubs — overwritten on every saas:install --update. Do not customize these directly; extend or wrap them instead.
| File | Location |
|---|---|
| Team settings page | resources/js/pages/settings/Team.vue |
| Billing settings page | resources/js/pages/settings/Billing.vue |
| Instance settings page | resources/js/pages/settings/Instance.vue |
| Team invitation page | resources/js/pages/TeamInvitation.vue |
| Team switcher component | resources/js/components/TeamSwitcher.vue |
| Checkbox component | resources/js/components/NativeCheckbox.vue |
User-owned files — published once, never overwritten. Safe to customize.
| File | Location |
|---|---|
| Configuration | config/saas.php (keys are merged on --update) |
| Team routes | routes/saas-teams.php |
| Billing routes | routes/saas-billing.php |
| Instance routes | routes/saas-instance.php |
| Plan enum | app/Enums/Plan.php (via --tag=saas-plan) |
Other publishable groups
| Tag | Description |
|---|---|
saas-config |
Configuration file |
saas-vue |
Vue components (stubs) |
saas-plan |
Plan enum (stub) |
saas-routes |
Route files for full override |
saas-migrations |
Migration files |
saas-views |
Mail views |
Setup
1. User model
Add the HasTeams trait to your User model:
use Coollabsio\LaravelSaas\Concerns\HasTeams;
class User extends Authenticatable
{
use HasTeams;
}
Your users table does not need a current_team_id column — the package migration adds it automatically.
2. Registration
Add the CreatesPersonalTeam trait to your Fortify CreateNewUser action:
use Coollabsio\LaravelSaas\Concerns\CreatesPersonalTeam;
class CreateNewUser implements CreatesNewUsers
{
use CreatesPersonalTeam;
public function create(array $input): User
{
// ... validate and create user ...
return DB::transaction(function () use ($input) {
$user = User::create([...]);
$this->createPersonalTeam($user);
return $user;
});
}
}
3. Middleware
Add ShareSaasProps to your web middleware stack in bootstrap/app.php:
use Coollabsio\LaravelSaas\Http\Middleware\ShareSaasProps;
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
ShareSaasProps::class,
]);
})
This shares currentTeam, teams, and billing props with every Inertia page.
4. Environment variables
# Deployment mode
SELF_HOSTED=false
# Stripe (not needed when SELF_HOSTED=true)
STRIPE_KEY=pk_...
STRIPE_SECRET=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Tiered billing prices
STRIPE_PRO_MONTHLY_PRICE_ID=price_...
STRIPE_PRO_YEARLY_PRICE_ID=price_...
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_...
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_...
# OR dynamic billing (set this instead of tiered prices)
STRIPE_DYNAMIC_PRICE_ID=price_...
# Require active subscription to access the app
REQUIRE_SUBSCRIPTION=false
Configuration
All configuration lives in config/saas.php:
return [
'self_hosted' => env('SELF_HOSTED', false),
'require_subscription' => env('REQUIRE_SUBSCRIPTION', false),
'models' => [
'team' => \App\Models\Team::class,
'team_invitation' => \App\Models\TeamInvitation::class,
'user' => \App\Models\User::class,
'instance_settings' => \Coollabsio\LaravelSaas\Models\InstanceSettings::class,
],
'plan_enum' => \Coollabsio\LaravelSaas\Enums\Plan::class,
'stripe' => [
'prices' => [
'pro' => [
'monthly' => env('STRIPE_PRO_MONTHLY_PRICE_ID'),
'yearly' => env('STRIPE_PRO_YEARLY_PRICE_ID'),
],
'enterprise' => [
'monthly' => env('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'),
'yearly' => env('STRIPE_ENTERPRISE_YEARLY_PRICE_ID'),
],
],
'dynamic_price_id' => env('STRIPE_DYNAMIC_PRICE_ID'),
],
'routes' => [
'teams' => true,
'billing' => true,
'instance' => true,
],
];
Customization
The package is designed to be extended at every layer. Here's how to customize each part.
Models
Extend the package models and update config/saas.php to point to your subclass:
// app/Models/Team.php
use Coollabsio\LaravelSaas\Models\Team as SaasTeam;
class Team extends SaasTeam
{
// add relationships, scopes, accessors, etc.
}
// config/saas.php
'models' => [
'team' => \App\Models\Team::class,
// ...
],
The same pattern works for InstanceSettings if you need to add custom settings columns:
// app/Models/InstanceSettings.php
use Coollabsio\LaravelSaas\Models\InstanceSettings as SaasInstanceSettings;
class InstanceSettings extends SaasInstanceSettings
{
protected $fillable = ['registration_enabled', 'your_custom_field'];
}
Plan enum
Publish the stub and customize tiers, Stripe price mappings, and hierarchy:
php artisan vendor:publish --tag=saas-plan
Your enum must implement Coollabsio\LaravelSaas\Contracts\PlanContract. Update the config:
'plan_enum' => \App\Enums\Plan::class,
Routes
The package auto-registers routes from vendor/. To override them, disable the package routes and publish your own:
// config/saas.php
'routes' => [
'teams' => false, // disable package team routes
'billing' => false, // disable package billing routes
'instance' => false, // disable package instance routes
],
php artisan vendor:publish --tag=saas-routes
Then load the published routes in your app's routes/web.php or bootstrap/app.php.
Controllers
The package controllers are not directly customizable. To override behavior:
- Disable the relevant package routes (see above)
- Create your own controller extending the package controller
- Register your own routes pointing to your controller
// app/Http/Controllers/TeamController.php
use Coollabsio\LaravelSaas\Http\Controllers\TeamController as SaasTeamController;
class TeamController extends SaasTeamController
{
public function store(StoreTeamRequest $request): RedirectResponse
{
// custom logic before
$response = parent::store($request);
// custom logic after
return $response;
}
}
Middleware
The package registers these middleware automatically:
| Alias | Class | Purpose |
|---|---|---|
plan |
EnsurePlanAccess |
Gate routes by minimum plan tier |
subscribed |
EnsureSubscribed |
Require active subscription |
root |
EnsureRootUser |
Require root user (self-hosted) |
| — | CheckRegistrationEnabled |
Global; blocks /register when disabled |
| — | ShareSaasProps |
Must be added manually to web middleware |
To override a middleware, register your own alias with the same name in bootstrap/app.php (after the package boots):
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'plan' => \App\Http\Middleware\CustomPlanAccess::class,
]);
})
Vue components (managed stubs)
The published Vue stubs are managed — they get overwritten on saas:install --update. To customize the UI without losing changes on update:
- Wrap the component — create your own component that imports and wraps the managed stub
- Use slots/props — if the stub supports them
- Copy and detach — copy the stub to a new filename and use that instead. The managed stub will still be updated but your copy won't be affected.
<!-- resources/js/components/MyTeamSwitcher.vue -->
<script setup>
import TeamSwitcher from '@/components/TeamSwitcher.vue';
</script>
<template>
<div>
<TeamSwitcher />
<!-- your additions here -->
</div>
</template>
Traits
The package provides traits you add to your models. To customize their behavior, override the trait methods in your model:
class User extends Authenticatable
{
use HasTeams;
// Override to customize root user logic
public function isRootUser(): bool
{
// custom logic
return $this->is_admin && config('saas.self_hosted');
}
}
Listeners
The package registers a LockRegistrationAfterRootUser listener on Illuminate\Auth\Events\Registered (self-hosted mode only). To disable or replace it, create your own listener and disable the package's in a service provider:
use Coollabsio\LaravelSaas\Listeners\LockRegistrationAfterRootUser;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Event;
// In a service provider's boot() method:
Event::forget(Registered::class, LockRegistrationAfterRootUser::class);
Usage
Teams
$user->teams; // all teams
$user->currentTeam; // active team
$user->ownedTeams; // teams the user owns
$user->switchTeam($team); // switch active team
$user->teamRole($team); // TeamRole enum (Owner/Member)
$user->isOwnerOf($team); // bool
$team->owner; // team owner
$team->users; // all members
$team->invitations; // pending invitations
$team->isOwner($user); // bool
$team->hasUser($user); // bool
$team->isPersonalTeam(); // bool
Billing
$team->plan(); // PlanContract enum (Free/Pro/Enterprise)
$team->onPlan('pro'); // exact match
$team->canAccess('pro'); // hierarchical: Pro or higher
$team->subscribed(); // has active subscription (Cashier)
Dynamic billing
$team->hasActiveDynamicSubscription(); // bool
$team->dynamicQuantity(); // current quantity
$team->updateDynamicQuantity(5); // update on Stripe
$team->reportUsage('event-name', 10); // report metered usage
Route middleware
// Require minimum plan tier
Route::get('/pro-feature', Controller::class)->middleware('plan:pro');
// Require active subscription
Route::get('/app', Controller::class)->middleware('subscribed');
Both are bypassed automatically in self-hosted mode and for root users.
Frontend (Inertia)
The ShareSaasProps middleware shares these props on every page:
const page = usePage();
page.props.currentTeam; // current team object
page.props.teams; // all user teams
page.props.billing.enabled; // boolean
page.props.billing.mode; // 'tiered' | 'dynamic' | null
page.props.billing.currentPlan; // 'free' | 'pro' | 'enterprise' | null
page.props.billing.requiresSubscription; // boolean
page.props.instance.selfHosted; // boolean
page.props.instance.isRootUser; // boolean
page.props.instance.registrationEnabled; // boolean
Self-hosted mode
Set SELF_HOSTED=true to disable all billing. No Stripe keys needed. All features are unlocked — Team::plan() returns Enterprise.
| Concern | SELF_HOSTED=true |
SELF_HOSTED=false |
|---|---|---|
| Billing | Disabled | Enabled via Stripe |
| Features | All unlocked | Plan-based |
plan:pro middleware |
Always passes | Checks team plan (root bypasses) |
| Root user | First registered user | N/A |
| Registration | Locked after first user | Always open |
| Instance settings | Available at /settings/instance |
N/A |
Root user
The first user to register in self-hosted mode becomes the root user. Their personal team is marked with is_root = true. Any owner of the root team has root privileges.
$user->isRootUser(); // true if owner of root team + self-hosted
$team->isRootTeam(); // true if is_root column is true
Instance settings
Root users can access /settings/instance to manage instance-wide settings. Currently available settings:
- Registration enabled — toggle whether new users can register. Automatically disabled after the first user registers.
The CheckRegistrationEnabled middleware (auto-registered globally) redirects /register to /login when registration is disabled.
Shared Inertia props (self-hosted)
The ShareSaasProps middleware includes an instance prop:
page.props.instance.selfHosted; // boolean
page.props.instance.isRootUser; // boolean
page.props.instance.registrationEnabled; // boolean
Routes
The package registers these routes automatically:
Teams
| Method | URI | Name |
|---|---|---|
| POST | /teams |
teams.store |
| GET | /settings/team |
teams.edit |
| PATCH | /teams/{team} |
teams.update |
| DELETE | /teams/{team} |
teams.destroy |
| PUT | /teams/{team}/switch |
teams.switch |
| POST | /teams/{team}/invitations |
team-invitations.store |
| DELETE | /teams/{team}/invitations/{invitation} |
team-invitations.destroy |
| GET | /invitations/{token} |
team-invitations.accept |
| POST | /invitations/{token} |
team-invitations.process |
Billing
| Method | URI | Name |
|---|---|---|
| GET | /settings/billing |
billing.index |
| POST | /billing/checkout |
billing.checkout |
| GET | /billing/portal |
billing.portal |
| POST | /stripe/webhook |
cashier.webhook |
Instance (self-hosted only)
| Method | URI | Name |
|---|---|---|
| GET | /settings/instance |
instance-settings.edit |
| PATCH | /settings/instance |
instance-settings.update |
Artisan commands
# Install the package
php artisan saas:install
# Update after a package upgrade
php artisan saas:install --update
# Clear cached Stripe prices
php artisan billing:clear-price-cache
Testing
The package ships its own feature tests. The saas:install command registers them in your app's phpunit.xml and tests/Pest.php so they run alongside your app tests:
php artisan test
License
MIT