Skip to content
Packstub

Adopting an existing app

Your app already runs single-database tenancy: a Team (or Organization, Workspace, …) model, a team_user pivot, team_id columns on the tables tenants own, and ->tenant(Team::class) on the panel. Filament Tenancy fits on top of that app as it is. After one command and one migration, every existing team is a live tenant: same table, same ids, same pivot, same team_id columns.

What you add is room to grow. When one database stops being enough, the database pool places new tenants on shard databases on any number of servers. A new customer who needs their own database or server gets one with a single attribute (hybrid fleets); an existing one is moved there with its rows (Moving a tenant between modes). Custom domains, onboarding, provisioning and the rest of the plugin come along.

composer require packstub/filament-tenancy
php artisan tenancy:adopt "App\Models\Team"
php artisan migrate

Ran php artisan migrate between the first two commands (a seed step, a deploy hook, habit)? No harm done: the package migrations created tenants, tenant_user, domains, tenant_resources and handoff_tokens for the shipped model, and tenancy:adopt drops those still-empty tables (children first) and removes the package's own migration records of exactly the tables it dropped, so the migrate after it recreates them for your model. tenant_user is dropped too, unless it is your own pivot that the command adopts. On PostgreSQL the reset runs in one transaction, so it happens completely or not at all. It refuses to continue when any of these tables has rows, because that is a plugin already in use, not an adoption. With run_migrations => false the tables came from your published copies, so the command stops and leaves the reset to you.

A table is only treated as the package's when it has the package's columns: a tenants table of your own (stancl's id + data, say) is never dropped, and its migration record stays. A domains, tenant_resources or handoff_tokens table of your own stops the command before anything is written, because the plugin keeps a table of that name: rename yours, then run the command again.

Requirements

  • The key column is id: an auto-increment integer, a UUID or a ULID all work. stancl/tenancy and the package tables (domains, the pivot) reference {table}.id, so a model with its own $primaryKey (team_id) is refused before anything is written; rename the column to id in a migration first.
  • A name column on the tenant table: the tenant switcher, the onboarding wizard and the slug backfill read it.

What the command does

tenancy:adopt reads your model and your schema, then writes the mechanical parts. It prints everything it touches, and a numbered next step for everything it cannot do safely.

  1. Reads the model: its table, its key type (auto-increment integer or string), its columns, and its membership pivot. The pivot is the table you name with --pivot=, else the table of the model's users() / members() / allUsers() BelongsToMany relation, else the {singular}_user convention, else a tenant_user table of your own. A pivot that does not exist yet is created by the package migration, with a role column. Its keys come from such a relation on that table when there is one; otherwise the conventional team_id / user_id are checked against the pivot's columns, and a key the pivot does not have is asked for (or, with --no-interaction, stops the command before anything is written). A role column on the pivot and a user_id column on the tenant table (Jetstream's owner) are detected. The role value that marks an owner is owner when the pivot holds it (or holds no roles yet); when it holds other values only (admin, member), the command asks which one means owner, or takes --owner-role=, and a scripted run keeps owner and prints the values it saw, for you to set membership.owner_role.
  2. Writes config/packstub-tenancy.php (publishing it first when needed): tenant_model; tenant_columns => 'all', so every column of your table stays a real column and personal_team, user_id and the rest are read and written where your code expects them, never inside stancl's data JSON; the membership block; and database_strategy => 'shared', because your rows already live together in one database, scoped by your own tenant key, team_id. A config you published from 1.x gets tenant_columns and the membership block right after tenant_model. Each value is replaced whole, so a tenant_model read through env('TENANT_MODEL', Tenant::class) becomes your model. The edited file must parse before it is written; a file that would not (or that does not parse as it stands) is left as it was, and the settings are printed for you to make by hand. So is a setting the command cannot place, in a file that no longer has the shipped layout.
  3. Writes config/tenancy.php (publishing stancl/tenancy's config with the plugin's corrections when needed): models.tenant is your model, and for an auto-increment key models.id_generator is null so the database keeps numbering tenants.
  4. Generates add_tenancy_columns_to_{table}_table, guarded per column: slug (backfilled from name for every row without one, made unique with a numeric suffix on collisions, at most 63 characters under subdomain identification so every slug is a valid host name, and given a unique index when it has none; a slug column you already have is kept and filled where it is empty, within its declared length), status (default ready, so existing rows are live the moment the migration runs), avatar_url, and data, stancl's JSON column. Existing rows are marked as shared tenants in data, so the runtime never looks for a database they do not have. The migration is generated even when the table already has all four columns, because it also creates the package tables that reference your table on a fresh database (see Fresh databases) and gives existing teams their default domain.
  5. Rewrites the model's class declaration when it has the plain class Team extends Model shape: the parent becomes stancl's Tenant, the Filament contracts and TenantWithDatabase are added, the HasDatabase, HasDomains and IsPackstubTenant traits go in, and protected $table = 'teams' is pinned (stancl's Tenant declares tenants as its table, which your model would otherwise inherit). A $fillable whitelist, or a #[Fillable([...])] attribute, gains name, slug, status, avatar_url and the owner column (a list built another way, [...] + self::EXTRA or array_merge(), is left as it is and named as a step). A file with Windows line endings keeps them. Your relations, casts and methods stay. A contract name your file already uses for a class of its own (an App\Contracts\HasName, say) is imported under an alias such as FilamentHasName, and before the rewritten file is saved it is checked twice: it must parse (php -l), and PHP must be able to load the class, in a fresh PHP process with your app's autoloader. The second check catches a method or constant of your own that clashes with stancl's Tenant, the traits or the contracts (an untyped current() or getFilamentName(), a newCollection() returning another collection class, a database() method, a ROLE_OWNER constant). When a check fails or cannot run, the file is left as it was and the declaration is printed, with PHP's error and the member names to look for. Any other shape (a trait name your file already uses, a block comment inside $fillable), or --no-model-rewrite, prints the declaration to paste instead. A model with its own $dispatchesEvents gets a warning: that property replaces the events stancl's Tenant dispatches, which the plugin needs (see Jetstream below).

Then it prints what stays yours:

  • The User model gets HasPackstubTenants and Filament's HasTenants + HasDefaultTenant contracts. Remove your own getTenants() / canAccessTenant(): the trait implements them through the pivot you kept and hides tenants that are still provisioning.
  • The panel swaps ->tenant(Team::class) for ->plugin(TenancyPlugin::make()), and drops ->tenantRegistration(), ->tenantProfile() and ->tenantRoutePrefix() if it had them: the plugin registers its own onboarding and profile pages, and the route prefix is route_prefix in the config. The plugin registers the tenant model, routing (subdomain or path), the switcher and the onboarding wizard. Add no stancl identification middleware.
  • Columns the wizard does not fill. The onboarding wizard inserts name, slug, status, the owner column and data. A NOT NULL column of your own without a default (Jetstream's personal_team) makes that insert fail, so the command names each such column: give it a default on the model (protected $attributes = ['personal_team' => false];) or in the database.
  • Foreign keys that cross the tenant boundary. An app that grew up single-database points at its tenant table and its users from everywhere (foreignId('team_id')->constrained(), posts.user_id → users), and sometimes from central at a team's rows (users.current_project_id). Nothing has to change on the day you adopt, since every row still lives in one database. The command lists every such key, in both directions, so you know before the first tenants:move: a foreign key cannot reach another database, so the move to a shard or a private database is refused on them until the ones that point at central are dropped (the column and its index stay; Eloquent resolves a belongsTo on the related model's own connection, and a TenantDeleted listener replaces a cascade from teams) and the ones from central are moved into the team's own tables. The steps, in order, are under Foreign keys that cross the tenant boundary.
  • The wizard's words. The wizard and the switcher say "Organization". To say "Team", publish the translations (php artisan vendor:publish --tag=packstub-tenancy-translations) and edit lang/vendor/packstub-tenancy/en/onboarding.php.
  • php artisan migrate: your table gains the four columns, the package adds domains, tenant_resources, handoff_tokens, session_grants and tenant_moves, and every existing team gets its {slug}.central-domain row in domains, the row subdomain routing resolves a host through (nothing is added under path identification). The package migrations follow your model: the tenant key columns are typed like your key column, foreign keys reference your table, and the shipped tenants and tenant_user migrations never create your tables. run_migrations stays on, so future schema changes still arrive on their own. A migrate:rollback drops only what the package created: your teams and team_user are never dropped.

Run with --no-interaction for scripted installs. Every detection is then taken as is, except pivot keys the command cannot verify, which stop it before anything is written (name them with a members() relation, or run it interactively). It does not run the migrations either: run php artisan migrate yourself.

Already have a slug column? It becomes the route key as it is. The command stops before writing anything when two rows share a slug (compared as your column's collation compares them, so Acme and acme are one slug under MySQL's and MariaDB's usual collations, and without case under subdomain identification, since host names are), or, under subdomain identification, when a slug is not a valid host name (lowercase a-z, 0-9 and -, 1 to 63 characters, no - at either end), and lists the first few. Empty slugs are fine: the migration derives them from the name.

Already have a status or data column? The plugin gives both a meaning: status is the provisioning state (provisioning, ready, failed, and only ready tenants appear in the switcher), and data is stancl's JSON column, whose keys the model reads as attributes. When your table has a status with values of its own (active, suspended, …) or of a type that is not text (an integer status code, even with no rows yet), or a data column your code uses, the command stops before writing anything and names the column to rename, so your teams never vanish behind a status or lose their data. Rename it in a migration (state, settings), update the code that reads it, and run the command again. A status with a CHECK constraint that lists the allowed values but not all three (Laravel's enum() is a text column with such a CHECK on PostgreSQL and SQLite) stops it too; a CHECK of another shape (status <> '', a NOT IN, one over several columns) is printed as a warning for you to check. A text status that only holds the plugin's values (a MySQL or PostgreSQL enum, or a CHECK, counts when it lists provisioning, ready and failed), and a data your model already keeps through stancl's Tenant, are used as they are, and so are the columns the generated migration added when you run the command again.

Fresh databases (CI, migrate:fresh, tests)

On a fresh database every migration runs in one go, sorted by date: the package's (dated April 2026) and your app's. When your own create_teams_table is newer, the package migrations run before your table exists, so they must neither create teams or team_user nor reference them yet. Until the migration tenancy:adopt generated has run, the package migrations that touch your tenant table or the pivot therefore create nothing. That migration is dated when you ran the command, after your own migrations, and creates domains, tenant_resources and handoff_tokens at its end, for your table and with your key type, together with the pivot when the plugin owns it (tenant_user). So migrate:fresh, RefreshDatabase, CI and a new machine all end with the same schema as the database you adopted, whatever the date of your own migrations.

The package migrations recognise this case by the generated migration in database/migrations that has not run yet, so keep it in your repository. Once it has run, they behave as on any install. With run_migrations => false you own the published copies and their order instead.

What stays the same

  • Your tables and ids. teams keeps its bigint ids. projects.team_id stays a bigint foreign key. Nothing is renamed.
  • Your pivot. team_user with team_id, user_id and its role column is the membership table the plugin reads and writes. A hand-rolled pivot without created_at / updated_at is detected and recorded as timestamps => false, so neither relation asks for them.
  • Your code that creates teams. A creating hook derives a unique slug from the name when none is given, so a team created by your own action, a factory or a seeder gets its route key. The host row is yours to add, with $team->ensureDefaultDomain() right after the create (not a model event on purpose: code that creates the default domain itself, stancl's documented pattern, would collide with it). In Jetstream that is two places: the CreateTeam action behind the create-team form, and CreateNewUser::createTeam() in app/Actions/Fortify, which creates the personal team on registration. The tenant then goes through the same queue pipeline as one from the onboarding wizard and is marked ready by it, so keep a queue worker running.
  • Your team_id scoping. Under the shared strategy Filament's automatic resource scoping is on, keyed by the ownership relationship (team() by default, configurable). Raw queries, jobs and reports scope by team_id exactly as before.
  • Roles and permissions. The plugin keeps one bit per membership, "owner or not", used to decide who may retry a failed setup and manage the workspace's domains. It is not a permission system. spatie/laravel-permission and Filament Shield keep handling roles inside each tenant, and Filament Teams layers Shield-aware tenant roles on top. See Ownership for how the owner bit is decided and how to source it from a Shield role instead.
  • Owners outside the pivot. With an owner column (teams.user_id), the owner is reached through the column, as in Jetstream: getTenants() merges the teams a user owns with the ones they are a member of, and the onboarding wizard writes the column for the creator instead of a pivot row.

What changes

  • Four columns on your tenant table: slug, status, avatar_url, data. Slugs are the route key (acme.example.com, /admin/teams/acme), so existing teams get one from their name.
  • The model's parent class is stancl's Tenant. It is an Eloquent model like any other, and your queries, relations and factories keep working.
  • The model's route key is slug. IsPackstubTenant returns slug from getRouteKeyName(), so routes of your own that bind the model (/teams/{team}) resolve by slug from now on, URLs built from a team carry its slug, and an id gives a 404. To keep your routes on ids, override it in your model; the plugin's own routes name the slug explicitly, so the panel keeps routing by slug either way:
public function getRouteKeyName(): string
{
    return 'id';
}
  • Tenants move to subdomains, unless you choose paths. The plugin's default identification is 'subdomain': every team is served at {slug}.your-domain, which needs wildcard DNS (*.your-domain) pointing at the app. A Filament app that served teams under a path (/admin/teams/acme) keeps that shape with 'identification' => 'path' in config/packstub-tenancy.php (or ->identification('path') on the plugin). Set it before you run php artisan migrate: under subdomain identification the migration adds a {slug}.your-domain row to domains for every team, under path identification none.
  • Two config files name your model. packstub-tenancy.tenant_model is the source of truth; the plugin keeps stancl's tenancy.models.tenant in step with it at boot.

Jetstream

Jetstream's Team is the archetype of this shape (teams.user_id owner, team_user with a role, personal teams) and adopts cleanly, with three edits the command cannot make for you because of how the model is built:

  • The parent class. Team extends Laravel\Jetstream\Team, and PHP has one parent, which is now stancl's Tenant. Paste the printed declaration and copy the methods you use from Laravel\Jetstream\Team into your model: owner(), users(), allUsers(), hasUser(), hasUserWithEmail(), userHasPermission(), teamInvitations(), removeUser(), purge(). They are short, and your copy of users() keeps Jetstream's Membership pivot model and ->as('membership').
  • The events. Jetstream's stub declares $dispatchesEvents (TeamCreated, TeamUpdated, TeamDeleted). On the adopted model that property replaces the map stancl's Tenant ships, and the job pipeline that marks a new team ready listens to stancl's events, so new teams would never leave provisioning. Remove the property and fire Jetstream's events from hooks instead:
protected static function booted(): void
{
    static::created(fn (self $team) => event(new TeamCreated($team)));
    static::updated(fn (self $team) => event(new TeamUpdated($team)));
    static::deleted(fn (self $team) => event(new TeamDeleted($team)));
}
  • personal_team. Jetstream's teams table declares it NOT NULL without a default, and the onboarding wizard does not set it, so its insert would fail (the command says so). Give the model a default:
protected $attributes = [
    'personal_team' => false,
];

Jetstream's own team creation keeps working: the new team gets a slug from its name, and $team->ensureDefaultDomain() right after the create gives it its host. Add that line in both places Jetstream creates a team: the CreateTeam action (the create-team form) and CreateNewUser::createTeam() in app/Actions/Fortify (the personal team on registration; without it, a new user's only team has no host under subdomain identification and the panel answers 404 right after sign-up). Jetstream keeps the owner out of team_user, and so does the plugin: the team settings page lists members as before, and the owner sees the team through teams.user_id.

Where to next

  • Add shard databases when the central database fills up: create and migrate a shard, add its connection, list it under database_pool, and new tenants are load-balanced across shards. Sharded shared. Existing teams stay on the central database until you move one: deal with the foreign keys the command listed (the steps), check with php artisan tenants:move acme --to=shard_2 --dry-run, then php artisan tenants:move acme --to=shard_2 copies its team_id rows to the shard. Moving a tenant between modes.
  • Give a customer a private database. A new tenant gets one when it is created with isolation_mode => 'dedicated' and a pinned server connection. An existing team's rows are in the shared database, so it is moved: create a database on a server connection, run your migrations against it, and, with the crossing foreign keys dealt with as above, run php artisan tenants:move acme --to=<server connection> --database=<database> --mode=dedicated. The team_id columns are harmless in a database that holds one team. Moving a tenant between modes.
  • Custom domains, onboarding, billing hooks: all available, nothing more to adopt. Configuration.

Checklist for the first deploy

  • tenancy:adopt ran, the printed next steps are done (User model, panel provider).
  • php artisan migrate ran on every environment; teams.slug is unique and non-empty.
  • A queue worker runs (QUEUE_CONNECTION is not sync in production): new signups are marked ready through the queue.
  • Wildcard DNS (*.your-domain) points at the app, or identification is path.
  • Existing users can open their teams from the tenant switcher. The provisioning page is never shown for adopted teams because their status is ready.
  • A user with no team yet lands on the onboarding page, not on the login page.
  • The foreign keys the command listed as crossing the tenant boundary are noted for the first tenants:move (nothing to change until then).