تفضيلات ملفات تعريف الارتباط
نستخدم ملفات تعريف الارتباط للحفاظ على تشغيل الموقع وتحسين تجربتك.
اعرف المزيد في سياسة الخصوصية .
N .
English العربية
الرئيسية عنّي أعمالي المدونة تواصل معي
العودة إلى المدونة
laravel architecture مارس 23, 2026

قراءة في 9 دقائق

Why I Chose PostgreSQL RLS for Multi-Tenancy in Laravel After Trying Every Alternative

A technical comparison of Laravel multi-tenancy
TL;DR
  • Compared multiple Laravel multi-tenancy approaches before choosing PostgreSQL Row-Level Security (RLS)

  • Found that common strategies (single DB, multiple DBs, scopes) introduce complexity, risk, or scaling limits

  • RLS provided true data isolation at the database level without extra application logic

  • Simplified multi-tenant architecture while improving security and maintainability

  • Shared real-world lessons on when RLS is the right choice—and when it’s not

If you're building a multi-tenant SaaS with Laravel, one of the first architectural decisions you'll face is how to isolate tenant data. Get it right, and your system scales cleanly. Get it wrong, and you'll either leak data between tenants or drown in operational complexity.

I've shipped production multi-tenant systems using all three approaches that stancl/tenancy supports: single-database with Eloquent scoping, database-per-tenant, and single-database with PostgreSQL Row-Level Security.


The Three Approaches stancl/tenancy Offers

Quick overview before I get into my experience:

  1. Single-database with Eloquent scoping — All tenants share one database. Isolation is handled at the application level using model traits (BelongsToTenant). Every Eloquent model gets a global scope that filters by tenant_id.

  2. Multi-database (database-per-tenant) — Each tenant gets its own separate database. The package switches database connections on each request based on which tenant is identified.

  3. Single-database with PostgreSQL RLS — All tenants share one database, but isolation is enforced by PostgreSQL itself at the database engine level. RLS policies automatically filter every query, and the application connects through a restricted database role that cannot bypass these policies.


Approach 1: Single-Database with Eloquent Scoping

This was my first multi-tenant SaaS. I added BelongsToTenant to my models, and the global scope filtered queries by tenant_id. It worked — at first.

Setup is straightforward. Add a tenant_id column, apply the trait, and your Eloquent queries are scoped. For a small project with a handful of models, this gets you running fast.

Then it stopped being small.

The issue that really got me was DB::table(). Global scopes only apply to Eloquent models. The moment you use DB::table(), DB::select(), or write a raw query — which happens constantly in event listeners, custom reports, bulk operations, and many package internals — the tenant filter disappears. Silently. No error, no warning. Your query just returned every tenant's data without you knowing.

That's the real danger of Eloquent scoping: it's not a security boundary, it's a convenience feature. It works until it doesn't, and when it doesn't, it fails silently.

Looking back, the deciding factor isn't project size — it's how many tenant-scoped models you have and how often your code drops below Eloquent. A platform with a handful of tenant-scoped models, all accessed through normal Eloquent queries, can live with global scopes indefinitely. At that scale, you probably don't need stancl/tenancy at all — a tenant_id column with a hand-written global scope does the same job with less overhead


Approach 2: Multi-Database (Database-Per-Tenant)

After the Eloquent scoping experience, I went the opposite direction. Full database separation. Each tenant gets their own database, their own tables, complete isolation.

Development was a dream. No thinking about scoping, no worrying about data leaks, no defensive code. Each tenant is its own world. If you're connected to a tenant's database, you can only see their data. Period.

Production was a different story.

As a solo developer, managing dozens of databases turned into a real operational burden. Every migration had to run across all of them. When I needed to add a column, it wasn't one migration — it was one migration times every tenant database. If migration #47 fails on tenant #83, you've got an inconsistent fleet. Figuring out which databases are on which migration version at 2am is not a good time.

Provisioning was noticeably slow too. Creating a new database, running all migrations, seeding initial data — this takes real time. For a platform where the promise is "launch your store in minutes," waiting 30+ seconds for database provisioning is a noticeable UX hit.

With the right team and proper DevOps automation, database-per-tenant works fine even at scale. But I didn't have a team. It was just me, and the operational weight wasn't sustainable.


Approach 3: Single-Database with PostgreSQL RLS

This is what I use now in Vlinqr, a production SaaS platform for online stores.

How It Works

All tenants share a single PostgreSQL database. When a request comes in, the tenancy middleware identifies which tenant owns the domain. A PostgreSQL session variable is set for that connection, and from that point, every query on tenant-scoped tables is automatically filtered at the database engine level.

The key difference from Eloquent scoping: this isn't application code adding a WHERE clause it's PostgreSQL itself refusing to return rows that don't belong to the current tenant. The application connects through a restricted database role that physically cannot bypass these policies. Even if there's a bug in your Laravel code, data cannot leak.

Why I Chose It

Security that doesn't depend on my code being perfect. With Eloquent scoping, one missed scope or one raw query and you leak data. With RLS, PostgreSQL enforces isolation regardless of how the query is constructed. The database is the guard, not the application.

Operational simplicity. One database to migrate, one database to back up, one database to optimize. When I add a column, I run one migration. When I need to analyze data across tenants, I can query the central connection.

Instant provisioning. Creating a new tenant is just inserting rows, no database creation, no schema setup. A new store on Vlinqr goes from signup to live in seconds.

The Real Tradeoffs

RLS is not without friction. Here are the problems I ran into:

Forgetting to run php artisan tenants:rls after schema changes. Every time you add a migration that creates or modifies tenant-scoped tables, you need to regenerate the RLS policies. It's one command, but if you forget it, the new table has no RLS policy — meaning it's wide open. I've caught this in development a few times. The fix is simple (run the command), but the failure is silent, which makes it dangerous if it ever slipped to production.

I now treat it as a hard rule in deployment:

php artisan down
php artisan migrate
php artisan tenants:rls
php artisan up

Those two commands always run together, wrapped in maintenance mode. Non-negotiable.

Pest PHP testing is painful. This was my biggest frustration. Here's what doesn't work with RLS:

refreshDatabase and lazyRefreshDatabase — the standard Laravel testing traits — don't work with RLS. The database transactions they use don't play well with RLS policy setup and teardown.

You can't use SQLite :memory: databases for testing either, since RLS is a PostgreSQL-specific feature. Every test must run against a real PostgreSQL instance.

My working solution is to use DatabaseMigrations and run tenants:rls before every test:

pest()->extend(Tests\\TestCase::class)
    ->beforeEach(function () {
        artisan('tenants:rls');
    })
    ->use(DatabaseMigrations::class)
    ->in('Feature');

It works, but it makes the test suite significantly slower. Running tenants:rls is a real PostgreSQL operation creating database users and policies and that overhead adds up across hundreds of tests. Test suite speed is probably the biggest day-to-day cost of using RLS.

You need to think in PostgreSQL, not just Laravel. RLS is a database feature, not an ORM feature. When something goes wrong, you're debugging PostgreSQL policies, not Eloquent scopes. If you're comfortable with PostgreSQL, this is fine. If you've only ever used MySQL through Laravel's query builder, there's a learning curve.


Which Approach Should You Use?

After building with all three, here's how I'd decide:

Single-database with Eloquent scoping if your project is genuinely small, you're prototyping, or the stakes of a data leak are low. But consider whether you even need stancl/tenancy for this a manual tenant_id column with a global scope might be simpler.

Database-per-tenant if you have a DevOps team or strong infrastructure automation that can handle the operational overhead of managing many databases, running cross-database migrations, and maintaining backups at scale.

Single-database with PostgreSQL RLS if you're a solo developer or small team building a platform for many tenants, you want database-level security without database-per-tenant operational overhead, and you're comfortable working with PostgreSQL.


What I'd Do Differently

If I were starting a new multi-tenant SaaS today, I'd go straight to RLS. The early investment in learning PostgreSQL RLS policies and setting up a proper test infrastructure pays off immediately once you're in production. The operational simplicity alone one database, one migration, instant provisioning is worth the testing friction.

The one thing I'd set up from day one: a CI pipeline that always runs tenants:rls after migrations, and a test helper that makes tenant creation and RLS policy setup as painless as possible. The testing workflow is the weakest part of the RLS experience, and any friction you can remove there pays dividends fast.


Multi-tenancy is not just a technical decision it's an architectural commitment that affects how you develop, test, deploy, and scale your application. If you're building a Laravel SaaS and evaluating your multi-tenancy strategy, I hope walking through my experience saves you some of the trial and error.

To see how all of this comes together in a real product, check out Vlinqr.


Further reading:

Laravel PostgreSQL Multi-Tenancy RLS SaaS stancl/tenancy Architecture Pest PHP
مقالات أخرى
تواصل على واتساب