↓ Skip to main content

Multi-tenant Laravel: a real estate back-office example

·11 mins
Hennadii Alforov
Author
Hennadii Alforov
Senior Backend Engineer with 15+ years in IT. I build and scale backend systems for high-traffic platforms - billing, payments, hosting and domain services.

To build a multi-tenant back-office in Laravel, keep every customer in one PostgreSQL database with an office_id on each row and enforce it twice: an Eloquent global scope in the app and row-level security in the database. Roles live per office, with spatie/laravel-permission teams.

Below is how that looks for a real estate back-office, with the order I'd build it in at the end. The examples use Laravel 13, PHP 8.4 and PostgreSQL 16.

The example: a back-office for real estate offices
#

Picture a platform for real estate agencies. Each agency, I'll call it an office, invites its brokers. Brokers enter deals from a phone, managers approve them on the web. Accounting sees the money and nothing else. Customer service sees listings, but not the property owners' phone numbers. A compliance officer reviews clients that match a sanctions list, and the client signs from a link, without an account. A broker who works alone should fit in too.

Here's where each of those rules ends up:

Rule Where it lives in Laravel
Offices never see each other's data office_id, a global scope and PostgreSQL RLS
Five roles per office spatie/laravel-permission, team = office
Broker sees own deals, manager sees all policies and a query scope
Customer service sees listings without owner phone numbers API resources
Compliance officer reviews flagged deals two columns on the office plus a policy
Client signs without an account a signed URL and a token table
Commission history can't be rewritten an append-only ledger and an audit log

Multi-tenancy in one database: office_id on every row
#

You can give each tenant its own database, its own schema, or a tenant column in shared tables. For hundreds of small offices on one schema, shared tables win: migrations run once, there's one backup, and platform reports are a plain GROUP BY. Shared tables are also what I picked when I built an HR SaaS alone. A database per tenant makes sense when a big customer writes it into the contract.

A solo broker is an office with one seat, so there's no second code path. The tenant comes from the logged-in user: the mobile app's Sanctum token belongs to a user, and the user to an office.

stancl/tenancy 3.x and spatie/laravel-multitenancy 4.x mostly switch databases, caches and disks per tenant. stancl's single-database mode has a scope too, but in 3.x it stops filtering when no tenant is initialized. For commissions and client data I want the opposite, so the trait is my own:

trait BelongsToOffice
{
    public static function bootBelongsToOffice(): void
    {
        static::addGlobalScope('office', function (Builder $query) {
            $officeId = TenantContext::officeId();

            // No office in context means no rows, never all rows.
            $officeId === null
                ? $query->whereRaw('1 = 0')
                : $query->where($query->qualifyColumn('office_id'), $officeId);
        });

        static::creating(function (Model $model) {
            $model->office_id ??= TenantContext::officeId();
        });
    }
}

The 1 = 0 line is what "failing closed" means: a missing context turns a bug into an empty page instead of a data leak. TenantContext is a small static class that holds the current office id.

PostgreSQL row-level security as the second lock
#

A global scope only covers Eloquent. DB::table(), a raw report query or a model without the trait go straight past it. So do exists and unique validation rules, which use the query builder: without RLS, office A can attach office B's client to a deal by its id.

Row-level security moves the filter into the database. You attach a policy to the table, the app tells PostgreSQL which office the current request belongs to, and every query on that table gets an invisible WHERE office_id = ..., raw SQL included:

ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
ALTER TABLE deals FORCE ROW LEVEL SECURITY;

-- NULLIF: once the variable is cleared, current_setting() returns '' instead of NULL
CREATE POLICY office_isolation ON deals
    USING (office_id = NULLIF(current_setting('app.office_id', true), '')::bigint)
    WITH CHECK (office_id = NULLIF(current_setting('app.office_id', true), '')::bigint);

Superusers and roles with BYPASSRLS skip every policy, and so does the table owner unless the table has FORCE. So the app and its tests connect as a plain role, and only migrations run as the owner. Don't test as the postgres user from the official Docker image: it's a superuser, and an RLS test run as it proves nothing. Platform reports and the admin panel get a second connection with a BYPASSRLS role that office-facing code never touches.

The office id travels in a session variable. On PgBouncer in transaction mode every transaction can land on a different server connection, and in autocommit that's every statement. So the middleware below wraps the request in DB::transaction() and makes set_config(..., true) its first statement: the value lives exactly as long as the transaction. With plain PHP-FPM and no pooler, a session-level set_config(..., false) that you clear at the end works too.

One middleware sets the office in four places:

public function handle(Request $request, Closure $next): Response
{
    $officeId = $request->user()?->office_id;

    TenantContext::set($officeId);
    setPermissionsTeamId($officeId); // spatie team = office
    Context::addHidden('office_id', $officeId); // travels with queued jobs

    try {
        return DB::transaction(function () use ($request, $next, $officeId) {
            DB::select("select set_config('app.office_id', ?, true)", [(string) $officeId]);

            return $next($request);
        });
    } finally {
        TenantContext::clear();
    }
}

Put it in the middleware priority list in bootstrap/app.php with prependToPriorityList(), right before SubstituteBindings. Otherwise route model binding runs its query before the office is known and returns 404. And since the whole request is now one transaction, set after_commit on the queue connection, or a job dispatched mid-request can run before its rows are committed.

Request path in a multi-tenant Laravel app: broker app, dashboard and signing link go through middleware, policy and the Eloquent global scope; queue jobs, cron and webhooks restore the office from Laravel Context or the payload; raw SQL skips the scope; PostgreSQL row-level security filters everything before the tables
Every way in has to set the office. Raw SQL skips the global scope, so RLS is the last filter.

Permissions per office: seats, roles and designations
#

A seat is what the office pays for: broker, accounting or customer service. A role is what a person can do. A designation is a duty that exactly one named person holds. Most permission designs I've seen mix these three up.

A manager is a role on top of a broker seat. The person still works their own deals, so one user holds both broker and manager. The compliance officer is a designation. A role can go to five people or to nobody, while the office needs exactly one officer from sign-up, plus an optional deputy. That's two columns on offices, compliance_officer_id NOT NULL and compliance_deputy_id. Sign-up runs in one transaction: create the owner, create the office with the owner as officer, then set the owner's office_id.

Roles go into spatie/laravel-permission 8 with teams turned on, and the team id is the office id. Two things I learned the hard way. Platform users without an office need a placeholder team id such as 0: team_id is part of the pivot's primary key, PostgreSQL refuses NULL there and SQLite accepts it, so a nullable design passes the tests and fails in production. And check permissions, not roles. can('approve deals') survives the day the owner asks for a "senior broker" who can approve. hasRole('manager') doesn't.

The matrix I'd start with:

Owner Manager Broker Accounting Customer service
See listings yes yes yes no yes
Create and edit own deals yes yes yes no no
See all office deals yes yes no money fields no
Approve deals yes, not own yes, not own no no no
See commission amounts all all own all no
See listing owner contacts yes yes own listings no no
Manage seats and settings yes no no no no
View the audit log yes no no no no

Clearing a compliance flag isn't in the table on purpose: only the designated officer or deputy does it. Rules like "own deals" go into a policy:

public function view(User $user, Deal $deal): bool
{
    return $user->can('view all deals') || $deal->broker_id === $user->id;
}

public function approve(User $user, Deal $deal): bool
{
    return $user->can('approve deals')
        && $deal->status === DealStatus::PendingApproval
        && $deal->broker_id !== $user->id;
}

That last line is a decision nobody makes until a manager submits their own deal. Another manager or the owner approves it. A solo office has nobody else, so there submitting skips the approval step, and the audit log says so.

A policy decides on one deal. The broker's deal list still needs ->where('broker_id', $user->id) when they can't view all deals, and the fields are the API resource's job: 'owner_phone' => $this->when($request->user()->can('view owner contacts'), $this->owner_phone). Accounting's money-only view of a deal works the same way.

When a broker leaves, the seat is freed. The user row stays, inactive and with its tokens revoked, so old deals still point to a real person.

Deal approval with a compliance detour
#

Deal states: draft, submitted, compliance review on a sanctions match, manager approval, approved, signing link, signed; a confirmed match stops the deal; rejected deals go back to draft, cancellations and commission corrections go through manager approval again
A sanctions match goes to the compliance officer before any manager sees the deal.

On submit, the client's name is screened against sanctions lists, and a name match above a set threshold goes to the compliance officer. The officer clears it with a required note or stops the deal. Without a match the deal goes straight to a manager.

Each transition is an action class: policy check, status change and audit entry in one transaction. spatie/laravel-model-states helps once transitions carry their own logic, until then an enum will do. Cancellations and corrections after approval are new transitions through the same approval.

Where tenant context goes missing
#

Everything that arrives without a logged-in user starts with an empty context: jobs, cron, webhooks, signing links. A fail-closed scope gives them empty results, and nothing throws.

Queue jobs are the most common case. The office that the middleware put into Laravel's Context travels with every job dispatched in the request, and the worker restores it before it unserializes the job. A Context::hydrated() callback runs for every job and sets or clears the office, the spatie team and the database variable before any model loads. Workers are few and long-lived, so they can skip PgBouncer and use the session-level set_config. In tests, Notification::fake() and a sync queue hide all of this.

Webhooks are the quietest. Every tenant-scoped lookup comes back empty, the handler decides there's nothing to update and answers 200, and your logs stay clean. I've had Stripe subscription updates silently not apply this way. Find the office from the payload first, by the customer id for example, and log every place where the context is missing.

The signing link is a guest request. URL::temporarySignedRoute() with a 24-hour expiry gives a URL nobody can edit. Behind Laravel's signed middleware, a second middleware looks the token up in a signing_links table that isn't tenant-scoped, sets that office and only then loads the deal.

A commission split with another office on the platform is the one place where data crosses tenants on purpose. The invitation row has from_office_id and to_office_id and carries only the amount, a deal reference and an expiry date. Its policy, in Laravel and in RLS, lets either side read it.

Then the boring ones. Unique indexes include office_id, cache keys and file paths start with it, and exports run as jobs with the context restored.

An append-only ledger and an audit trail
#

Commission amounts are never updated. A correction is a reversal row plus a new row, and the balance is a SUM. Each entry points to the split rule version it used (say, 60/40 between broker and office), so a new percentage doesn't rewrite last year. The database enforces it:

GRANT SELECT, INSERT ON commission_entries TO app_user;
-- no UPDATE, no DELETE: the app gets "permission denied"

When a broker asks why their payout is what it is, you show them the rows that add up to it.

The audit trail is spatie/laravel-activitylog 5 (it needs PHP 8.4) with office_id, the user who acted, and the old and new values on every entry. For a flagged deal it answers who created it, which list flagged it, who cleared it with what note and who approved it. I once wrote down what an audit log should record for an HR product, and most of it applies to deals too.

Where to start
#

Every query in a multi-tenant app goes through the tenant checks, so they go in before any feature. The order I'd go in:

  1. Draw the tenant line before the first migration. Write down which tables get office_id and which are platform data, like sanctions lists or subscription plans. Decide now whether a user can belong to two offices: moving from users.office_id to a membership table later touches auth, the middleware and every role.
  2. Create three database roles: the owner for migrations, the app's plain role and a BYPASSRLS role for the admin panel. Then build the Office model, the fail-closed scope, the middleware and RLS on the first tenant table. From day one, test on PostgreSQL as the app's role: a broker from office A opens a deal of office B and gets a 404, and every model whose table has office_id uses the trait, except User, which auth loads before the office is known.
  3. Before the first queued notification or webhook, give each entry point without a user a way to find its office.
  4. Agree on the permission matrix with the people who'll run the platform before writing any role code. Ten minutes over a table like the one above beats a week of "wait, accounting can see that?" after launch.
  5. Build the deal flow, the audit log and the append-only commission ledger together. An audit trail added later can only guess what happened before it.

FAQ
#

Do I need PostgreSQL RLS if I already have global scopes?
I'd add it with the first tenant table. A global scope only covers Eloquent, while RLS also catches DB::table(), raw SQL and exists validation rules, as long as the app connects as a plain role and not as the table owner or a superuser.
Can one broker work for two offices?
Yes, with a membership table between users and offices and the active office kept in the session or on the API token. spatie/laravel-permission teams already store roles per team, so one user can be a broker in one office and a manager in another.