Bekzod Erkinov
Listen to Article
Loading...Table of contents · 10 sections
Building a Modern SaaS Application with Laravel - Part 2: Auth (Tenant-Scoped Login), Billing & Tenant Isolation
One forgotten where tenant_id = ? leaks a customer's data, and billing the user instead of the workspace gives one company three plans. This post builds a small Laravel 12 app with tenant-scoped login, a fail-closed global scope, a test I watched fail when the scope was removed, and a Cashier subscription on the tenant, run offline against stripe-mock.
Scope of "Auth": tenant-scoped login and sessions only. Registration, password reset and per-tenant roles are not covered because I did not build or test them. Series: Part 1 covers architecture and database, part 3 covers queues and scaling.
Minimal setup (fresh Laravel 12, Docker)
I used single-database tenancy: one tenants table, a tenant_id column on tenant-owned tables, SQLite for the tests. Cashier's docs call for the intl extension; I also installed bcmath and zip. Everything below ran in Docker on one machine; "What I didn't test" says what that can't tell you.
cat > Dockerfile <<'EOF2'
FROM php:8.4-cli
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libicu-dev libzip-dev \
&& docker-php-ext-install bcmath intl zip && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
EOF2
docker build -t saas-p2 .
docker network create saasnet
docker run -d --rm --name smock --network saasnet stripe/stripe-mock:v0.206.0
docker volume create saasapp
docker run -it --rm --network saasnet -v saasapp:/app saas-p2 bash
# inside the container:
composer create-project laravel/laravel:^12.0 . --no-interaction
composer require laravel/cashier
php artisan vendor:publish --tag=cashier-migrations
In my run, php artisan --version printed Laravel Framework 12.69.3; the other installed versions were Cashier 16.8.0, stripe-php 21.3.2 and PHP 8.4.26, and stripe-mock's startup line printed stripe-mock 0.206.0. Those are what I ran; newer releases may behave differently. The Laravel 12 billing docs are the reference for Cashier's API.
Then the edits to the stock files (also inside the container):
# email unique per tenant, not globally
sed -i "s/->string('email')->unique();/->string('email');/" database/migrations/0001_01_01_000000_create_users_table.php
# Cashier columns live on tenants; subscriptions point at tenant_id
sed -i "s/Schema::table('users'/Schema::table('tenants'/" database/migrations/*_create_customer_columns.php
sed -i "s/user_id/tenant_id/g" database/migrations/*_create_subscriptions_table.php
# remove the stock example tests (they expect a "/" route that this app doesn't have)
rm tests/Feature/ExampleTest.php tests/Unit/ExampleTest.php
Add 'stripe_price' => env('STRIPE_PRICE'), as the first entry of the array in config/services.php, and these lines to .env (the key only has to look like sk_test_… for stripe-mock):
STRIPE_KEY=pk_test_dummy
STRIPE_SECRET=sk_test_dummy
STRIPE_PRICE=price_dummy_monthly
STRIPE_WEBHOOK_SECRET=whsec_test
STRIPE_API_BASE=http://smock:12111
phpunit.xml needs the same two values as <env> entries, next to the CACHE_STORE line:
sed -i 's#<env name="CACHE_STORE" value="array"/>#&\n <env name="STRIPE_API_BASE" value="http://smock:12111"/>\n <env name="STRIPE_WEBHOOK_SECRET" value="whsec_test"/>#' phpunit.xml
Then replace bootstrap/app.php with the version below (the priority-list lines are explained in the middleware gotcha further down), and create the other files from the listing at the end of this post. There is no Stripe webhook route to write: Cashier registers /stripe/webhook itself (see webhooks). I haven't published a repository; the listing is every file I added or changed.
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['stripe/*']);
// Tenant must be known before the auth guard loads the user from the session.
$middleware->prependToPriorityList(
before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
prepend: \App\Http\Middleware\ResolveTenant::class,
);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
Check the seds before going on. Once the migration from the listing is in place, a failed sed shows up here instead of three sections later:
touch /tmp/v.sqlite
DB_CONNECTION=sqlite DB_DATABASE=/tmp/v.sqlite php artisan migrate:fresh
DB_CONNECTION=sqlite DB_DATABASE=/tmp/v.sqlite php artisan db:table tenants | grep -E "stripe_id|trial"
In my run migrate:fresh listed the Cashier migrations as DONE (the last line was 2026_10_07_225458_add_meter_event_name_to_subscription_items_table 7.51ms DONE), and the second command printed:
stripe_id varchar, nullable ........................................ varchar
trial_ends_at datetime, nullable .................................. datetime
tenants_stripe_id_index stripe_id ..........................................
If migrate:fresh errors, or tenants has no stripe_id, one of the three migration sed lines didn't apply.
Laravel SaaS multi tenancy billing: the tenant is the customer
Cashier's published migrations put stripe_id, pm_type, pm_last_four and trial_ends_at on users and a user_id on subscriptions. For a SaaS the tenant should own those, so one plan covers everyone in the workspace; the sed lines above do that.
The user_id → tenant_id rename isn't optional. In the installed Cashier 16.8.0, grep -n getForeignKey vendor/laravel/cashier/src/Subscription.php printed 96: return $this->belongsTo($model, (new $model)->getForeignKey());, so a Tenant billable means a tenant_id column. (That is vendor source, not something I saw in the docs.) Register the model in AppServiceProvider::boot() with Cashier::useCustomerModel(Tenant::class);; the billing docs show the same call with a different model.
My own migration is database/migrations/0001_01_01_000003_create_tenants_and_projects.php. Ordering matters twice: it alters users, so it must run after the stock 0001_01_01_000000_create_users_table.php, and Cashier's customer-columns migration alters tenants, so it must run after mine. In my run, ls database/migrations after vendor:publish showed the stock migrations at 0001_01_01_00000x, mine at …000003, and the published Cashier ones stamped 2026_10_07_…, and Laravel runs migrations in filename order. If you create yours later with make:migration, it will get a newer timestamp and sort after Cashier's, so rename it to sort before them (I didn't test that failure). The migration also adds the per-tenant email rule:
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Schema::table('users', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
$table->unique(['tenant_id', 'email']);
});
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
$table->index('tenant_id');
});
subscriptions is deliberately not tenant-scoped: it's central billing data, and Cashier's webhook handler has to find it by Stripe ID with no tenant context.
Laravel multi-tenancy: a fail-closed global scope
The rule: no controller, job or query is allowed to remember where tenant_id = ?. A trait does it for every model that uses it. The part most tutorials skip is what happens when no tenant has been resolved (a queue job, a console command, a bug). The global scope docs show how to add a scope; the choice that matters is what it does when it has nothing to filter on. Mine matches nothing:
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
// Fail closed: with no tenant resolved, the query matches nothing.
static::addGlobalScope('tenant', function (Builder $query) {
$id = app(CurrentTenant::class)->id();
$id === null
? $query->whereRaw('1 = 0')
: $query->where($query->getModel()->getTable().'.tenant_id', $id);
});
static::creating(function (Model $model) {
$current = app(CurrentTenant::class)->id();
if ($model->tenant_id === null) {
$model->tenant_id = $current
?? throw new \LogicException('Creating '.$model::class.' with no tenant resolved.');
} elseif ($current !== null && (int) $model->tenant_id !== $current) {
throw new \LogicException('Refusing to create a row for another tenant.');
}
});
}
}
(The file in the listing also has a small tenant() relation.) CurrentTenant is a singleton holding the resolved Tenant. tenant_id is not in $fillable, so a forged tenant_id in a request body is ignored, and the creating hook refuses to write a row for a tenant other than the current one.
Use the trait on Project and on User. Putting it on User is what makes authentication tenant-aware.
Gotcha: the auth middleware runs before your tenant middleware
My ResolveTenant middleware loads the tenant from the {tenant} URL segment and sets CurrentTenant. Routes had it first and auth second, yet requests to authenticated routes failed. I removed the prependToPriorityList call from bootstrap/app.php and re-ran the suite:
⨯ free plan caps at two projects then 402
⨯ subscribe against stripe mock stores subscription on tenant
✓ signed webhook updates the right tenant
✓ unsigned webhook is rejected
⨯ user only sees own tenant rows
✓ session from tenant a is useless on tenant b
✓ primary key lookup across tenants returns nothing
✓ login is scoped to tenant and same email can exist twice
⨯ forged tenant id in request body is ignored
✓ no tenant resolved fails closed
✓ unscoped escape hatch does see everything
Tests: 4 failed, 7 passed (18 assertions)
The four failures are the tests that go through auth and expect a tenant. Laravel sorts middleware using a priority list. In the installed 12.69.3, grep -n AuthenticatesRequests vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php printed 109: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,, and Illuminate\Auth\Middleware\Authenticate is declared class Authenticate implements AuthenticatesRequests. My middleware isn't in that list, so it isn't ordered relative to the interface; the observed effect was the session guard loading the user before any tenant was set, so the fail-closed scope matched nothing.
The fix is the prependToPriorityList call already shown. I found that method by reading the framework source (grep -n "function prependToPriorityList" printed 425: public function prependToPriorityList($before, $prepend) in Foundation/Configuration/Middleware.php); I did not look for it in the docs. With the call in place the suite passes again: Tests: 11 passed (24 assertions).
Use the interface, not the class. In an earlier run, before: \Illuminate\Auth\Middleware\Authenticate::class gave the same 4 failed, 7 passed as having no fix at all.
ResolveTenant also clears the tenant in a finally block so a long-lived worker (Octane, a queue worker in the same process) can't leak one request's tenant into the next. I used path-based tenants (/t/{tenant}/…) to keep the tests simple; for subdomains, derive the slug from $request->getHost() instead (not tested).
Tenant-scoped login
Because User carries the scope, Auth::attempt() is already tenant-scoped: the user provider's email = ? query gets AND tenant_id = ? appended, so credentials from tenant A fail on tenant B's login URL (the login action is in the listing; it's Auth::attempt() plus session()->regenerate()).
The same mechanism covers session cookies. A session issued for Alice at Acme stores her user ID; replayed against /t/bolt/…, the guard looks her up under Bolt's scope and finds nothing. There's no extra "does this user belong here" check to forget.
The test that proves cross-tenant access fails
Two tenants, acme and bolt. Both have a user with the email [email protected] and different passwords. Acme has two projects, Bolt has one named bolt-secret. A helper replays a session cookie for a given user against any URL (cookieFor()), and as($tenant, fn) sets CurrentTenant for the setup code; both are in the listing. The tests that matter:
public function test_user_only_sees_own_tenant_rows(): void
{
$this->cookieFor($this->alice)->getJson('/t/acme/projects')
->assertOk()->assertJsonCount(2)
->assertJsonMissing(['name' => 'bolt-secret']);
}
public function test_session_from_tenant_a_is_useless_on_tenant_b(): void
{
$this->cookieFor($this->alice)->getJson('/t/bolt/projects')->assertUnauthorized();
}
public function test_primary_key_lookup_across_tenants_returns_nothing(): void
{
$boltId = Project::withoutGlobalScopes()->where('name', 'bolt-secret')->value('id');
$this->as($this->a, function () use ($boltId) {
$this->assertNull(Project::find($boltId));
$this->assertSame(0, Project::whereKey($boltId)->count());
});
}
Plus: Alice's credentials on Bolt return 422 while the same email with Bob's password works, a forged tenant_id in the POST body lands in the caller's tenant anyway, and creating a Project with no tenant throws.
php artisan test on the finished app, 2026-10-08 (output trimmed to the result lines and summary):
✓ free plan caps at two projects then 402 0.41s
✓ subscribe against stripe mock stores subscription on tenant 0.07s
✓ signed webhook updates the right tenant 0.02s
✓ unsigned webhook is rejected 0.01s
✓ user only sees own tenant rows 0.02s
✓ session from tenant a is useless on tenant b 0.02s
✓ primary key lookup across tenants returns nothing 0.01s
✓ login is scoped to tenant and same email can exist twice 0.02s
✓ forged tenant id in request body is ignored 0.02s
✓ no tenant resolved fails closed 0.01s
✓ unscoped escape hatch does see everything 0.01s
Tests: 11 passed (24 assertions)
A green isolation test proves nothing until you've seen it fail
With the middleware-order bug unfixed, test_session_from_tenant_a_is_useless_on_tenant_b still passed (see the run above), because everything returned 401, not because isolation worked. That's why the suite has the positive twin (user_only_sees_own_tenant_rows): a negative test only means something next to a positive one.
So I did a mutation check. The exact one-line edit, run inside the container from /app: make the scope closure return immediately.
sed -i "s|static::addGlobalScope('tenant', function (Builder \$query) {|&\n return;|" app/Models/Concerns/BelongsToTenant.php
php artisan test tests/Feature/TenantIsolationTest.php
This is the real phpunit output, abridged: I kept the result list, the first two failure blocks and the summary. The other three failures print the same way; the primary-key one is followed by a ~45-line dump of the Project object, omitted here.
FAIL Tests\Feature\TenantIsolationTest
⨯ user only sees own tenant rows 0.25s
⨯ session from tenant a is useless on tenant b 0.02s
⨯ primary key lookup across tenants returns nothing 0.01s
⨯ login is scoped to tenant and same email can exist twice 0.02s
✓ forged tenant id in request body is ignored 0.03s
⨯ no tenant resolved fails closed 0.01s
✓ unscoped escape hatch does see everything 0.01s
FAILED Tests\Feature\TenantIsolationTest > user only sees own tenant row…
Failed to assert that the response count matched the expected 2
Failed asserting that actual size 3 matches expected size 2.
at tests/Feature/TenantIsolationTest.php:57
FAILED Tests\Feature\TenantIsolationTest > session from tenant a is usel…
Expected response status code [401] but received 200.
Failed asserting that 200 is identical to 401.
at tests/Feature/TenantIsolationTest.php:63
[... three more failure blocks omitted ...]
Tests: 5 failed, 2 passed (10 assertions)
Five of seven fail with the scope gone. The two that pass are expected to: the forged-tenant_id test is protected by $fillable and the creating hook, not by the scope, and the escape-hatch test calls withoutGlobalScopes() on purpose. After restoring the file, php artisan test printed Tests: 11 passed (24 assertions) again.
That escape hatch is the real weakness of this design. Any withoutGlobalScopes() call in application code is a cross-tenant read. The last test exists to document it, and the CI check in "What to do next" is how I'd police it.
Laravel Cashier billing per tenant (tested on stripe-mock)
The subscribe action is three lines, straight from the Cashier docs:
$sub = $current->get()
->newSubscription('default', config('services.stripe_price'))
->create($request->input('payment_method'));
return ['stripe_status' => $sub->stripe_status, 'tenant_id' => $sub->tenant_id];
The plan gate is in storeProject (in the listing): if ! $tenant->subscribed('default') && Project::count() >= 2, it returns 402. The first billing test checks that the third project is refused.
What stripe-mock is, and what it can't tell you
stripe-mock is Stripe's own mock server. Its README (fetched 2026-10-08) says it's "locked to the latest version of Stripe's API and doesn't support old versions", that "stripe-mock is stateless", that its "responses are completely hardcoded", and that testing for specific errors "is currently not supported" (it "will return a success response instead of the desired error response"). Treat this as a check that your wiring and request shapes are accepted by the mock, not as a test of billing behaviour. None of this ran against the live Stripe API, and I never charged a card.
On API versions: the billing docs, under "Upgrading Cashier", say "Cashier 16 utilizes Stripe API version 2025-06-30.basil" (fetched 2026-10-08). In my run, a throwaway PHP server standing in for the API logged Stripe-Version=2026-08-26.dahlia on every request Cashier made, so the docs sentence and the installed 16.8.0 disagree. I didn't investigate why.
The subscribe test printed this:
SUBSCRIBE RESPONSE: {"stripe_status":"active","tenant_id":1}
SUBSCRIPTION ROW: {"tenant_id":1,"type":"default","stripe_id":"sub_RUklHzw5O9j12AO","stripe_status":"active","stripe_price":"price_1PgafmB7WZ01zgkW6dKueIc5","quantity":1}
TENANT stripe_id: cus_RUklHQDOimtw420
The row lands on the tenant, which is the point. But note stripe_price: I asked for price_dummy_monthly and the stored value is a price ID I never wrote. To see where it comes from, I posted a subscription with items[0][price]=price_dummy_monthly straight to the mock with curl; the response contained "id": "price_1PgafmB7WZ01zgkW6dKueIc5", the same ID. So the mock returns a canned price whatever you send, and your tests shouldn't assert on Stripe-returned IDs.
Gotcha: Cashier's base URL isn't the only one
My first run failed with a 401. With the line setting Stripe::$apiBase removed (everything else unchanged) the subscribe test prints:
Error sending request to Stripe: (Status 401) Invalid API Key provided: sk_test_*ummy
The key itself was fine for stripe-mock: curl -u sk_test_dummy: http://smock:12111/v1/customers returned status 200. In vendor source, grep -n apiBaseUrl vendor/laravel/cashier/src/Cashier.php printed 37: public static $apiBaseUrl = BaseStripeClient::DEFAULT_API_BASE; and 125: 'api_base' => static::$apiBaseUrl,, so it redirects calls made through Cashier's own client, while grep -n apiBase vendor/stripe/stripe-php/lib/Stripe.php printed 17: public static $apiBase = 'https://api.stripe.com';, the SDK-global setting. The 401 shows at least one request still went to the real Stripe host, since the mock doesn't reject keys. The stack trace from that failing test names the call:
#7 /app/vendor/laravel/cashier/src/Concerns/ManagesPaymentMethods.php(102): Stripe\PaymentMethod->attach(Array)
#8 /app/vendor/laravel/cashier/src/Concerns/ManagesPaymentMethods.php(186): App\Models\Tenant->addPaymentMethod(Object(Stripe\PaymentMethod))
So it is addPaymentMethod() calling attach() on a Stripe\PaymentMethod object. Set both, only when the env var exists so production is untouched:
if ($base = env('STRIPE_API_BASE')) {
Cashier::$apiBaseUrl = $base; // calls made through Cashier's client
\Stripe\Stripe::$apiBase = $base; // calls made on Stripe objects, e.g. PaymentMethod::attach()
}
If you wire this up yourself, check that your tests really never reach the real API; a dummy key makes the mistake harmless, but a real test key in .env would not.
Webhooks: sign the payload yourself
Cashier's webhook route is /stripe/webhook, and the billing docs say to exclude stripe/* from CSRF and list the events to enable (customer.subscription.created/updated/deleted, invoice.payment_succeeded and others). Stripe signs deliveries with an HMAC-SHA256 of timestamp + "." + body, sent as Stripe-Signature: t=…,v1=…, per the Stripe webhook docs. That's simple enough to reproduce in a test without any Stripe tooling:
$ts = time();
$sig = hash_hmac('sha256', "$ts.$payload", 'whsec_test');
$this->call('POST', '/stripe/webhook', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_STRIPE_SIGNATURE' => "t=$ts,v1=$sig",
], $payload)->assertOk();
$this->assertSame('canceled', Subscription::first()->stripe_status);
$this->assertFalse($tenant->fresh()->subscribed('default'));
The payload is a hand-written customer.subscription.deleted event (I wrote the fields Cashier reads: id, customer, status, ended_at, an empty items list), not a captured real one. Cashier found the tenant by its stripe_id (cus_test_acme) and canceled the right subscription. A second test posts an unsigned request and gets 403. Two things from the Stripe docs I did not test: duplicate deliveries (log event IDs) and out-of-order events (Stripe doesn't guarantee order). Both matter once real traffic arrives.
What it costs, and when to pay for more
Cashier is free; Stripe's cut isn't. From Stripe's pricing page (re-read 2026-10-08): "2.9% + 30¢ per successful transaction for domestic cards", plus Billing at "0.7% of Billing volume" on pay-as-you-go. For a hypothetical $29/month plan that's $1.14 processing plus $0.20 Billing, about $1.34 or 4.6% per charge. That's my arithmetic on list prices; it ignores taxes, international cards and other Stripe products.
Decision rules, from what I ran and what the docs say:
- Shared database + global scope (this post) fits when a
tenant_idindex is enough for your data. Its failure mode is human: onewithoutGlobalScopes()or one raw query leaks data, which is why the mutation test and a CI check matter. For a comparison of the other patterns, see the multi-tenant architecture patterns post. - stancl/tenancy is worth a look when you need a database per tenant. Its docs (fetched 2026-10-08) describe single-database and multi-database tenancy and say it "focuses more on multi-database tenancy"; for single-database it provides "a class that keeps track of the current tenant and model traits" and "the rest is up to you", which is roughly what this post builds by hand.
- Bill the tenant unless you sell per-person licences with no shared workspace. Billing users means three people in one company hold three separate plans.
What I didn't test
- Everything ran on SQLite in-memory, one process, one request at a time. No concurrency, no Postgres, no row-level security, no performance numbers.
- No real Stripe account: no 3-D Secure, failed renewals, proration, tax, or the customer portal.
- Registration, password reset (including whether reset tokens are scoped through the
Userscope) and per-tenant roles. - Queue jobs: the scope fails closed with no tenant, so a job must set the tenant explicitly before touching tenant models. Part 3 deals with that.
Full listing of the files I added or changed
All application and test files (click to expand)
app/Models/Concerns/BelongsToTenant.php
<?php
namespace App\Models\Concerns;
use App\Models\Tenant;
use App\Tenancy\CurrentTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
// Fail closed: with no tenant resolved, the query matches nothing.
static::addGlobalScope('tenant', function (Builder $query) {
$id = app(CurrentTenant::class)->id();
$id === null
? $query->whereRaw('1 = 0')
: $query->where($query->getModel()->getTable().'.tenant_id', $id);
});
static::creating(function (Model $model) {
$current = app(CurrentTenant::class)->id();
if ($model->tenant_id === null) {
$model->tenant_id = $current
?? throw new \LogicException('Creating '.$model::class.' with no tenant resolved.');
} elseif ($current !== null && (int) $model->tenant_id !== $current) {
throw new \LogicException('Refusing to create a row for another tenant.');
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class)->withoutGlobalScopes();
}
}
app/Models/Tenant.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;
class Tenant extends Model
{
use Billable;
protected $fillable = ['name', 'slug'];
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Tenancy/CurrentTenant.php
<?php
namespace App\Tenancy;
use App\Models\Tenant;
class CurrentTenant
{
private ?Tenant $tenant = null;
public function set(?Tenant $tenant): void
{
$this->tenant = $tenant;
}
public function get(): ?Tenant
{
return $this->tenant;
}
public function id(): ?int
{
return $this->tenant?->getKey();
}
}
app/Models/Project.php
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
use BelongsToTenant;
protected $fillable = ['name'];
}
app/Models/User.php
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, BelongsToTenant;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
app/Http/Middleware/ResolveTenant.php
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Tenancy\CurrentTenant;
use Closure;
use Illuminate\Http\Request;
class ResolveTenant
{
public function __construct(private CurrentTenant $current) {}
public function handle(Request $request, Closure $next)
{
$tenant = Tenant::where('slug', $request->route('tenant'))->firstOrFail();
$this->current->set($tenant);
$request->route()->forgetParameter('tenant');
try {
return $next($request);
} finally {
$this->current->set(null); // octane / queue-worker hygiene
}
}
}
routes/web.php
<?php
use App\Http\Controllers\TenantController;
use App\Http\Middleware\ResolveTenant;
use Illuminate\Support\Facades\Route;
Route::prefix('t/{tenant}')->middleware(ResolveTenant::class)->group(function () {
Route::post('login', [TenantController::class, 'login']);
Route::middleware('auth')->group(function () {
Route::get('projects', [TenantController::class, 'projects']);
Route::post('projects', [TenantController::class, 'storeProject']);
Route::post('billing/subscribe', [TenantController::class, 'subscribe']);
});
});
app/Http/Controllers/TenantController.php
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Tenancy\CurrentTenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TenantController
{
public function login(Request $request)
{
// User's global scope already pins this lookup to the resolved tenant.
if (! Auth::attempt($request->only('email', 'password'))) {
return response()->json(['message' => 'bad credentials'], 422);
}
$request->session()->regenerate();
return ['ok' => true];
}
public function projects()
{
return Project::orderBy('id')->get(['id', 'name']);
}
public function storeProject(Request $request, CurrentTenant $current)
{
// Free plan: 2 projects. Subscribed: unlimited.
if (! $current->get()->subscribed('default') && Project::count() >= 2) {
return response()->json(['message' => 'upgrade required'], 402);
}
return response(Project::create($request->validate(['name' => 'required'])), 201);
}
public function subscribe(Request $request, CurrentTenant $current)
{
$sub = $current->get()
->newSubscription('default', config('services.stripe_price'))
->create($request->input('payment_method'));
return ['stripe_status' => $sub->stripe_status, 'tenant_id' => $sub->tenant_id];
}
}
app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Models\Tenant;
use App\Tenancy\CurrentTenant;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CurrentTenant::class);
}
public function boot(): void
{
// The tenant, not the user, is the paying customer.
Cashier::useCustomerModel(Tenant::class);
// Point the Stripe SDK at stripe-mock in dev/CI; unset in production.
if ($base = env('STRIPE_API_BASE')) {
Cashier::$apiBaseUrl = $base; // calls made through Cashier's client
\Stripe\Stripe::$apiBase = $base; // calls made on Stripe objects, e.g. PaymentMethod::attach()
}
}
}
tests/Feature/TenantIsolationTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Tenancy\CurrentTenant;
use Illuminate\Auth\SessionGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantIsolationTest extends TestCase
{
use RefreshDatabase;
private Tenant $a;
private Tenant $b;
private User $alice;
private User $bob;
protected function setUp(): void
{
parent::setUp();
$this->a = Tenant::create(['name' => 'Acme', 'slug' => 'acme']);
$this->b = Tenant::create(['name' => 'Bolt', 'slug' => 'bolt']);
$this->as($this->a, function () {
$this->alice = User::create(['name' => 'Alice', 'email' => '[email protected]', 'password' => 'secret-a']);
Project::create(['name' => 'acme-roadmap']);
Project::create(['name' => 'acme-billing']);
});
$this->as($this->b, function () {
$this->bob = User::create(['name' => 'Bob', 'email' => '[email protected]', 'password' => 'secret-b']);
Project::create(['name' => 'bolt-secret']);
});
}
private function as(Tenant $t, \Closure $fn): void
{
app(CurrentTenant::class)->set($t);
$fn();
app(CurrentTenant::class)->set(null);
}
/** A session cookie that was issued for $user, replayed against any URL. */
private function cookieFor(User $user): static
{
return $this->withSession(['login_web_'.sha1(SessionGuard::class) => $user->id])
->withHeader('Accept', 'application/json');
}
public function test_user_only_sees_own_tenant_rows(): void
{
$this->cookieFor($this->alice)->getJson('/t/acme/projects')
->assertOk()
->assertJsonCount(2)
->assertJsonMissing(['name' => 'bolt-secret']);
}
public function test_session_from_tenant_a_is_useless_on_tenant_b(): void
{
$this->cookieFor($this->alice)->getJson('/t/bolt/projects')->assertUnauthorized();
}
public function test_primary_key_lookup_across_tenants_returns_nothing(): void
{
$boltId = Project::withoutGlobalScopes()->where('name', 'bolt-secret')->value('id');
$this->as($this->a, function () use ($boltId) {
$this->assertNull(Project::find($boltId));
$this->assertSame(0, Project::whereKey($boltId)->count());
});
}
public function test_login_is_scoped_to_tenant_and_same_email_can_exist_twice(): void
{
$this->postJson('/t/acme/login', ['email' => '[email protected]', 'password' => 'secret-a'])->assertOk();
$this->postJson('/t/bolt/login', ['email' => '[email protected]', 'password' => 'secret-a'])->assertStatus(422);
$this->postJson('/t/bolt/login', ['email' => '[email protected]', 'password' => 'secret-b'])->assertOk();
}
public function test_forged_tenant_id_in_request_body_is_ignored(): void
{
$this->as($this->a, fn () => Project::query()->delete());
$this->cookieFor($this->alice)
->postJson('/t/acme/projects', ['name' => 'x', 'tenant_id' => $this->b->id])
->assertCreated();
$this->assertSame($this->a->id, Project::withoutGlobalScopes()->where('name', 'x')->value('tenant_id'));
}
public function test_no_tenant_resolved_fails_closed(): void
{
$this->assertSame(0, Project::count());
$this->expectException(\LogicException::class);
Project::create(['name' => 'orphan']);
}
public function test_unscoped_escape_hatch_does_see_everything(): void
{
// Documented footgun: this is why grepping for withoutGlobalScopes belongs in CI.
$this->assertSame(3, Project::withoutGlobalScopes()->count());
}
}
tests/Feature/BillingTest.php
<?php
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use App\Tenancy\CurrentTenant;
use Illuminate\Auth\SessionGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Cashier\Subscription;
use Tests\TestCase;
class BillingTest extends TestCase
{
use RefreshDatabase;
public function test_free_plan_caps_at_two_projects_then_402(): void
{
[, $u] = $this->tenantWithUser('acme');
$c = $this->cookieFor($u);
$c->postJson('/t/acme/projects', ['name' => '1'])->assertCreated();
$c->postJson('/t/acme/projects', ['name' => '2'])->assertCreated();
$c->postJson('/t/acme/projects', ['name' => '3'])->assertStatus(402);
}
public function test_subscribe_against_stripe_mock_stores_subscription_on_tenant(): void
{
[$t, $u] = $this->tenantWithUser('acme');
$res = $this->cookieFor($u)
->postJson('/t/acme/billing/subscribe', ['payment_method' => 'pm_card_visa'])
->assertOk();
fwrite(STDERR, "\nSUBSCRIBE RESPONSE: ".$res->getContent()."\n");
$row = Subscription::first();
fwrite(STDERR, 'SUBSCRIPTION ROW: '.json_encode($row->only(['tenant_id', 'type', 'stripe_id', 'stripe_status', 'stripe_price', 'quantity']))."\n");
fwrite(STDERR, 'TENANT stripe_id: '.$t->fresh()->stripe_id."\n");
$this->assertSame($t->id, $row->tenant_id);
}
public function test_signed_webhook_updates_the_right_tenant(): void
{
[$t] = $this->tenantWithUser('acme');
$t->forceFill(['stripe_id' => 'cus_test_acme'])->save();
$t->subscriptions()->create([
'type' => 'default', 'stripe_id' => 'sub_test_1', 'stripe_status' => 'active',
'stripe_price' => 'price_test', 'quantity' => 1,
]);
$this->assertTrue($t->fresh()->subscribed('default'));
$payload = json_encode([
'id' => 'evt_1', 'object' => 'event', 'type' => 'customer.subscription.deleted',
'data' => ['object' => [
'id' => 'sub_test_1', 'object' => 'subscription', 'customer' => 'cus_test_acme',
'status' => 'canceled', 'cancel_at_period_end' => false, 'ended_at' => time(),
'items' => ['object' => 'list', 'data' => []],
]],
]);
$ts = time();
$sig = hash_hmac('sha256', "$ts.$payload", 'whsec_test');
$this->call('POST', '/stripe/webhook', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_STRIPE_SIGNATURE' => "t=$ts,v1=$sig",
], $payload)->assertOk();
$this->assertSame('canceled', Subscription::first()->stripe_status);
$this->assertFalse($t->fresh()->subscribed('default'));
}
public function test_unsigned_webhook_is_rejected(): void
{
$this->postJson('/stripe/webhook', ['type' => 'customer.subscription.deleted'])->assertForbidden();
}
private function tenantWithUser(string $slug): array
{
$t = Tenant::create(['name' => $slug, 'slug' => $slug]);
app(CurrentTenant::class)->set($t);
$u = User::create(['name' => 'U', 'email' => "u@$slug.test", 'password' => 'x']);
app(CurrentTenant::class)->set(null);
return [$t, $u];
}
private function cookieFor(User $u): static
{
return $this->withSession(['login_web_'.sha1(SessionGuard::class) => $u->id])
->withHeader('Accept', 'application/json');
}
}
What to do next
-
Add a CI step that fails on any new
withoutGlobalScope*call outside an allowlist of file paths (.scope-allowlist, one path per line). I ran the shell logic below in the app container, not inside a CI service. With the allowlist containing onlyapp/Models/Concerns/BelongsToTenant.php(the onewithoutGlobalScopes()in thetenant()relation), it exited 0; after I added a file with a newwithoutGlobalScopes()call it exited 1:- name: No unscoped queries outside the allowlist run: '! grep -rn "withoutGlobalScope" app/ | grep -v -F -f .scope-allowlist' -
Add a positive twin for every "cross-tenant access fails" test, then disable your scope once and confirm the negatives go red.
-
Point a Stripe test-mode key at a real sandbox, forward events with
stripe listen(or replay one withstripe events resend), and compare a realcustomer.subscription.updatedpayload to the one in your webhook test. Cashier's own notes are in its webhook section. -
Move on to part 3 for queues and scaling, where the "no tenant resolved" case actually bites.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 964 views
Web DevelopmentMastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 859 views
Cloud ComputingComplete Stripe Integration Template for Subscription-Based Services
29 min · 822 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 7: Test Pyramid, Security Hardening & PCI Compliance
May 12, 2026
Building a REST API with Laravel - Part 1: API Design, Versioning & Sanctum Authentication
May 10, 2026
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026