I spent a day inside stancl/tenancy (the database-per-tenant Laravel package) shipping a real change to a multi-tenant Software-as-a-Service (SaaS) application, and a few things finally clicked for me — none of which were obvious from the documentation alone. 💡 Here are the notes I wish I’d had on day one.
The VirtualColumn trait, and why $tenant->data is null
Open any tenant record in tinker and the first thing you’ll notice is bizarre: $tenant->data returns null. But the database row clearly has a JavaScript Object Notation (JSON) blob full of data. What gives?
It’s the VirtualColumn trait. On retrieval, Stancl decodes the JSON column into individual top-level model attributes, then nulls the data property itself. So a row like this:
1 | { "ready": true, "region": "AMER", "has_pending_enrollment": null } |
…becomes $tenant->ready, $tenant->region, and $tenant->has_pending_enrollment. Reach for $tenant->data[‘ready’] and you’ll get nothing back, then waste an hour wondering why your test is broken (don’t ask me how I know).
The takeaway: treat any data-bag key as if it were a real column. Read it via the attribute, write it via the attribute:
1 2 3 4 5 6 7 | // Read $ready = $tenant->ready; $pending = $tenant->has_pending_enrollment; // Write — Stancl re-encodes into the data JSON on save $tenant->update(['ready' => true]); $tenant->update(['has_pending_enrollment' => '2026-06-22 10:30:00']); |
You only need the raw data->key form when you’re going through the query builder (for whereBetween over a Java Script Object Notation path, or an atomic update that bypasses the model). For anything else, attribute access is cleaner and matches how the rest of the codebase reads.
Finding a tenant: the central side vs the tenant side
This one trips people up because Tenant and the tenant’s own data live on different connections. The central database holds the Tenant and Domain models — the registry of who exists. Each tenant has its own database holding its actual application data.
From anywhere in the app:
1 2 3 4 5 6 7 | use App\Models\Central\Tenant; // Find by ID (central registry) $tenant = Tenant::find('acme'); // Get the current tenant inside a tenant-context request $current = tenant(); // global helper |
The tenant() helper returns the model for whichever tenant initialized the current request. Inside controllers, jobs, or anything routed through a subdomain, it’s already there. Outside of that — central admin code, background scripts, scheduled jobs — you’ll often initialize tenancy yourself:
1 2 3 | tenancy()->initialize($tenant); // swap default connection to the tenant DB // ... do stuff in tenant context ... tenancy()->end(); // back to central |
If your code runs in both contexts, always check tenancy()->initialized before re-initializing, and use try/finally so a thrown exception doesn’t strand a connection.
A pattern: storing “when” instead of “if”
Here’s the change that actually prompted all this. We had a scheduled job that fanned out to every tenant every 15 minutes — and for 95% of them, it was a no-op. The job would boot a tenant connection, query for pending work, find nothing, exit. Multiply by hundreds of tenants and the waste was real.
The obvious fix is a flag: has_pending_enrollment = true, the scheduler filters on it. But that has a subtle problem. A campaign saved today for next week would flip the flag immediately, and the cron would pay the full fan-out cost for a week of no-ops before the campaign was actually due.
The better shape: store when the work is due, not just if. A nullable datetime instead of a boolean. Then the scheduler filter becomes time-aware:
1 2 3 4 5 6 | Tenant::where('data->ready', true) ->whereBetween('data->has_pending_enrollment', [ Carbon::now()->subHours(6), Carbon::now(), ]) ->get(); |
Future-dated tenants are skipped at the Structured Query Language (SQL) level for free. When the campaign’s startdate enters the lookback window, the tenant gets picked up, the job runs, marks the work done, and either points the flag at the next pending campaign or sets it to null. The boolean couldn’t encode “skip me until then” — a datetime can.
Watch the format when you store datetimes in JSON
One small gotcha I burned 30 minutes on: depending on which database driver you’re using, Stancl/Laravel may serialize a Carbon instance into JSON as either ‘2026-06-22 10:30:00’ or ‘2026-06-22T10:30:00.000000Z’. The latter looks fine, but a whereBetween against datetime bounds compares the stored value lexicographically, not as a datetime — and the uppercase T sorts higher than a space, so your bounds never match. Normalize to Y-m-d H:i:s on write to avoid the cross-database surprise. 🐘
The pattern that finally stuck
If I had to compress today into three lines:
- Treat tenant-data keys as model attributes, not JSON properties.
- Use the query builder only when you genuinely need a SQL-level filter or atomic JSON-path update.
- When you’re storing state about future work, store when, not if — let the database filter for you.
None of these are revolutionary. But each one took me staring at a failing test for a while before it clicked. 🎉