Packstub.

Multi-tenancy

Workflows, runs, secrets and waits carry a tenant — a team, a company, an organisation — or none, which makes them global. In a Filament panel with tenancy each tenant manages its own workflows and secrets and sees its own runs and approvals; when a trigger fires, the tenant of the record decides which workflows run: the tenant's own plus the global ones.

Setting up

Nothing to configure for the panel side: when the panel has ->tenant(Team::class), the Workflows and Secrets resources are scoped to Filament::getTenant() through the polymorphic tenant relationship, new workflows and secrets are attached to the current tenant, and the Runs and Approvals pages show the tenant's rows. A panel without tenancy manages global workflows and sees every run.

The runtime side needs to know which tenant a payload belongs to. Three sources, tried in order:

  1. A resolver: Flow::resolveTenantUsing(fn (array $payload) => ...) (or FlowPlugin::make()->resolveTenantUsing(...)), receiving the payload and returning a tenant model or null — for webhooks, events, or records with an indirect tenant.
  2. The record's relationship: PACKSTUB_FLOW_TENANT_RELATIONSHIP=team (the tenancy.relationship config key) reads $payload['model']->team.
  3. Filament's current tenant, when the trigger fires inside a tenant panel request.
// config/packstub-flow.php
'tenancy' => [
    'relationship' => env('PACKSTUB_FLOW_TENANT_RELATIONSHIP'),   // "team"
],
Flow::resolveTenantUsing(function (array $payload): ?Team {
    return match (true) {
        isset($payload['webhook']['team_id']) => Team::find($payload['webhook']['team_id']),
        isset($payload['model']) && method_exists($payload['model'], 'team') => $payload['model']->team,
        default => null,
    };
});

What runs for whom

Workflow Runs when
Tenant A's A trigger fires for a payload resolved to tenant A (record triggers, events, webhooks, schedules' now payload has no tenant, so a tenant's Schedule trigger never fires — use a global one, or a resolver that reads the workflow); a Date on a record trigger only for A's records
Global (no tenant) Every payload, whatever its tenant

Flow::run() and the Run now / Run workflow buttons start a workflow regardless of tenant checks. The run stores the tenant it ran for (tenant_type / tenant_id on the run; the workflow's own tenant, or the payload's tenant for a global workflow), and the payload carries it as {{ tenant.name }}.

Secrets per tenant

A tenant's secret with the same key as a global one shadows it while that tenant's workflows run — one global slack_webhook, overridden by the tenants that have their own. The Secrets page in a tenant panel manages the tenant's secrets; global ones are managed from a panel without tenancy or in code.

Plan limits

FlowPlugin::make()->maxWorkflows(int|Closure) caps how many workflows a tenant may have; the closure receives the current tenant, so a plan limit — from Filament Features or your own billing — plugs straight in:

FlowPlugin::make()->maxWorkflows(fn (?Team $team): ?int => $team?->plan->limit('workflows'));

Beyond the limit the New workflow button is disabled with a tooltip and creation is refused with a notification. null means no limit.

In code

Workflow::query()->ofTenant($team);       // the team's own
Workflow::query()->forTenant($team);      // the team's own plus the global ones
Workflow::query()->global();

$workflow->tenant;                          // MorphTo
$workflow->isGlobal();

Outside a panel request, Filament's tenant global scope is not active; the dispatcher and the runner use withoutGlobalScopes() and their own tenant filters, and so should any custom query that must see every tenant's workflows.

Next: Configuration.