Queues and Tenant Context
A queued job runs outside the request that dispatched it, and the tenant binding does not travel with it. PandaPanel\Tenancy\Tenancy stores the tenant in PandaPanel\Support\PanelContext, which is a scoped() container binding — and Laravel's queue worker calls forgetScopedInstances() between jobs, so every job starts with nothing bound. This page is what to do about that.
Entering a tenant in a job
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Workspace;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use PandaPanel\Tenancy\Tenancy;
final class RebuildWorkspaceIndex implements ShouldQueue
{
use Queueable;
public function __construct(private readonly int $workspaceKey) {}
public function handle(): void
{
$workspace = Workspace::query()->findOrFail($this->workspaceKey);
Tenancy::for($workspace, function (): void {
// Everything in here reads through the bound tenant.
DocumentResource::query()->each(/* ... */);
});
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
RebuildWorkspaceIndex::dispatch($workspace->getKey());Carry the key, not the model. A serialized model reloads on the far side through whatever connection is current, which in a database-per-tenant arrangement is not the one it was serialized from.
Tenancy::for()
/**
* @template TReturn
*
* @param callable(): TReturn $callback
* @return TReturn
*/
public static function for(Model $tenant, callable $callback): mixed2
3
4
5
6
7
Binds, runs, and restores the previous binding in a finally:
public static function for(Model $tenant, callable $callback): mixed
{
$previous = self::current();
self::bind($tenant);
try {
return $callback();
} finally {
$context = app(PanelContext::class);
if ($previous === null) {
$context->set(self::KEY, null);
} else {
$context->set(self::KEY, $previous);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Restoring in a finally is the whole point. A callback that throws must not leave the rest of the process scoped to somebody else's tenant — which in a long-running worker is not one request but every job after it.
Tenancy::bind($acme);
try {
Tenancy::for($beta, fn () => throw new RuntimeException('nope'));
} catch (RuntimeException) {
}
Tenancy::current()?->getKey(); // still Acme2
3
4
5
6
7
8
Nested calls are safe, and starting from nothing leaves nothing bound.
The panel matters too
A job that reads a resource needs the panel as well as the tenant. panel() is how a resource finds its per-panel configuration, its URLs and — through hasTenancy() — whether to scope at all:
use PandaPanel\Core\PanelManager;
use PandaPanel\Tenancy\Tenancy;
public function handle(PanelManager $manager): void
{
$manager->setCurrentPanel($manager->get('app'));
Tenancy::for($workspace, static fn () => DocumentResource::query()->count());
}2
3
4
5
6
7
8
9
Without the panel, applyTenantScope() returns early and the query runs unscoped — the failure this whole mechanism exists to prevent, and one that looks like a working job. Set both, always, in that order.
The framework's own jobs
Three jobs ship with the package:
| Job | Carries | Sets the panel | Binds a tenant |
|---|---|---|---|
PandaPanel\Jobs\RunPanelExport | exporter, resource, columns, format, owner, table state, keys, panel id | yes | no |
PandaPanel\Jobs\RunPanelImport | importer, path, mapping, owner, panel id | yes | no |
PandaPanel\Jobs\SendPanelIntegration | integration id, payload, timeout, delivery id | no | no |
public function handle(PanelManager $manager): void
{
$panel = $manager->get($this->panelId);
// A resource's scope, its table, and its URLs are all read through the
// current panel. Without this the job would be running outside any panel
// and `Resource::query()` would answer for none.
$manager->setCurrentPanel($panel);
// ...
}2
3
4
5
6
7
8
9
10
11
They were written before tenancy and carry no tenant id. What that means depends on your arrangement:
Database per tenant. stancl/tenancy's QueueTenancyBootstrapper restores the connection around a job dispatched inside a tenant context, and nothing in these jobs is scoped by the panel, so a queued export produces the right file. Without that bootstrapper the export would run against the central database and quietly produce the wrong one.
Single database, with a scoped resource. The job sets the panel, the panel has tenancy, the resource names a relationship, nothing is bound — so Tenancy::require() throws PanelRegistrationException and the job fails:
This panel is tenant-scoped, but no tenant is bound to this request…
A loud failure rather than a file containing every tenant's rows, which is the correct trade. Three ways out, in order of preference:
Do not queue that export or import. A negative
queueAfter()never queues, whatever the row count:phpfinal class DocumentExporter extends Exporter { public static function queueAfter(): int { return -1; // always run in the request } }1
2
3
4
5
6
7Exporter::queueAfter()defaults to2000andImporter::queueAfter()to500; both queue when the count exceeds the number, and0always queues.Dispatch your own job that carries the tenant key and wraps the work in
Tenancy::for(), usingExportRun/ImportRundirectly.Restore the binding from a queue hook you own, if your infrastructure already carries a tenant id on every job.
Console commands and schedulers
Same rule, no request involved:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Workspace;
use Illuminate\Console\Command;
use PandaPanel\Core\PanelManager;
use PandaPanel\Tenancy\Tenancy;
final class ReportPerWorkspace extends Command
{
protected $signature = 'workspaces:report';
public function handle(PanelManager $manager): int
{
$manager->setCurrentPanel($manager->get('app'));
Workspace::query()->each(function (Workspace $workspace): void {
Tenancy::for($workspace, function () use ($workspace): void {
$this->line($workspace->name.': '.DocumentResource::query()->count());
});
});
return self::SUCCESS;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
each() outside the loop's Tenancy::for(), deliberately: the list of tenants is a central question, and asking it from inside a tenant would cross the boundary the design exists to keep.
Notifications
Panel notifications are ordinary Laravel notifications, so they follow Laravel's rules. Two things are worth knowing:
PandaPanel\Notifications\TwoFactorCodeimplementsShouldQueue. In a database-per-tenant arrangement the emailed code is written to the cache, which is whyCacheTenancyBootstrapperis not optional — see Database Per Tenant.- A persistent notification is a row in
notifications. That table lives in the tenant database in a database-per-tenant arrangement, so the job that writes it must run on the tenant's connection.
Octane
The same reasoning covers Octane, and it is already handled. PanelContext is bound with scoped(), so the container flushes it between requests, and PandaPanel\Http\Middleware\ResetPanelContext runs at the start of every web request as a second line. Nothing tenant-related is held in a static — which is exactly why Tenancy keeps the tenant in the context rather than in one.
Notes
- A job that does not read a resource does not need any of this. Binding a tenant matters for
Resource::query()and for your own code that callsTenancy::current(); a job that writes a file from data it was given needs neither. Tenancy::bind()without a matching restore is forResolveTenantand tests. In a worker it leaks into the next job on that process. Usefor().- The queue connection is not the tenant boundary. Whether jobs live in a central
jobstable or per tenant is a separate decision — central is simpler and means one worker; per tenant means one tenant cannot fill another's queue. Tenancy::for()returns the callback's value, so it composes:$count = Tenancy::for($workspace, fn (): int => DocumentResource::query()->count());- Failing loudly is the design. Every alternative — skipping the scope, falling back to "no tenant" — produces a job that succeeds with the wrong rows.