Database managers
Database managers are classes used to create, delete, and check the existence of tenant databases.
This is best explained with actual code, so here’s what the main interface looks like:
interface TenantDatabaseManager{ /** Create a database. */ public function createDatabase(TenantWithDatabase $tenant): bool;
/** Delete a database. */ public function deleteDatabase(TenantWithDatabase $tenant): bool;
/** Does a database exist? */ public function databaseExists(string $name): bool;
/** Construct a DB connection config array. */ public function makeConnectionConfig(array $baseConfig, string $databaseName): array;}All managers except SQLiteDatabaseManager also implement this interface (no connection is needed
with SQLite since it does filesystem operations):
interface StatefulTenantDatabaseManager extends TenantDatabaseManager{ /** Get the DB connection used by the tenant database manager. */ public function connection(): Connection;
/** * Set the DB connection that should be used by the tenant database manager. * * @throws NoConnectionSetException */ public function setConnection(string $connection): void;}With this default implementation:
abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager{ /** The database connection to the server. */ protected string $connection;
public function connection(): Connection { if (! isset($this->connection)) { throw new NoConnectionSetException(static::class); }
return DB::connection($this->connection); }
public function setConnection(string $connection): void { $this->connection = $connection; }
public function makeConnectionConfig(array $baseConfig, string $databaseName): array { $baseConfig['database'] = $databaseName;
return $baseConfig; }}Individual database managers then implement these methods, like this:
class MySQLDatabaseManager extends TenantDatabaseManager{ public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); $charset = $this->connection()->getConfig('charset'); $collation = $this->connection()->getConfig('collation');
return $this->connection()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); }
public function deleteDatabase(TenantWithDatabase $tenant): bool { return $this->connection()->statement("DROP DATABASE `{$tenant->database()->getName()}`"); }
public function databaseExists(string $name): bool { return (bool) $this->connection()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'"); }}These managers are configured in config/tenancy.php, database.managers array:
'managers' => [ 'sqlite' => Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager::class, 'mysql' => Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class, 'mariadb' => Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class, 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager::class, 'sqlsrv' => Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class, // ...],Which manager should be used is determined by the tenant’s template connection’s driver value.
The template connection, as explained on the customizing databases page, defaults to the central connection, which again is why your tenants will be using your MySQL server (as well as the MySQL manager shown above) when your central connection is a MySQL connection. If you change your central connection to PostgreSQL, your tenants will be using your PostgreSQL server and the configured PostgreSQL database manager. And so on.
The only thing remaining to understand is how these managers are actually executed. If you take a look at your
TenancyServiceProvider, you’ll see this job pipeline:
Events\TenantCreated::class => [ JobPipeline::make([ Jobs\CreateDatabase::class, Jobs\MigrateDatabase::class, // Jobs\SeedDatabase::class, // Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant. // Provision API keys, create S3 buckets, anything you want! ])->send(function (Events\TenantCreated $event) { return $event->tenant; })->shouldBeQueued(false),],A job pipeline is our own abstraction for turning an ordered list of jobs into a listener, that may be optionally queued. What happens here is that, upon tenant creation, we first try to create a database. If that succeeds, we try to also migrate the database. If you uncomment any of the other jobs, they will be executed too — in order. Migrations depend on the database being created, and seeders depend on the database being migrated (having tables).
In this case, CreateDatabase calls $this->tenant->database()->manager()->createDatabase($this->tenant).
$tenant->database() returns an instance of DatabaseConfig which is where the tenancy_db_* properties are merged with
the configured template connections and so on. The manager() method returns the TenantDatabaseManager instance which
then executes the actual CREATE DATABASE ... statements.
Since these managers are used in event listeners (the job pipelines), you should also note that simply dropping all tenants
will not delete their databases since bulk deletes do not trigger events in Laravel. Instead, the proper approach is using
something like Tenant::cursor()->each->delete().
This is a common pain point in local dev environments where developers may execute php artisan migrate:fresh often. To solve
that, you can set the tenancy.database.drop_tenant_databases_on_migrate_fresh config key to true, which will override the
default MigrateFresh command with our override that makes sure to delete each tenant one by one, triggering event listeners.
Finally, you should also know that these job pipelines can be queued. If your tenant creation pipeline is slow (for instance
if it involves a bunch of API calls that might be provisioning some resources) or error-prone (again due to a reliance on
third-party services, making some retry logic desirable) you may want to enable queuing — simply set shouldBeQueued(true).
Note that this affects tenant onboarding. If you use a standard approach where tenants “registering” triggers impersonation that redirects the tenant to their domain, the tenant may not be fully created yet. You can add some error handling logic for specific types of query exceptions to render a “We’re building your app” message and automatically refresh the page for the user. See the related page in our sponsor docs for a copy-pasteable implementation of this.
As an alternative to queuing tenant creation, you can use the pending tenants feature to maintain a pool of pre-created tenants that are ready to use during tenant signup.
Permission controlled database managers
Section titled “Permission controlled database managers”Many of the database managers our package ships with also have a “permission controlled” version. That is, a version which in addition to managing tenant databases also manages database users. In other words, each tenant gets a separate database user they connect to, not just a database.
The main benefit of using a permission controlled database manager is extra security: if the database connection is hijacked through something like SQL injection, the attacker will not be able to reach other databases (including the central database) with the hijacked connection, as it only has access to the tenant’s database and nothing else.
Assuming your central connection user has permissions to create users as well as databases, using a permission controlled manager is as simple as swapping the database manager in your Tenancy config file:
'managers' => [ 'mysql' => Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class, 'mysql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class // ...],As demonstrated on the Customizing databases page, simply running Tenant::create() will set
tenancy_db_name to the default prefix, a generated ID, and a suffix (empty by default):
> $tenant = App\Models\Tenant::create();= App\Models\Tenant {#5941 data: null, id: "b37609c7-035c-4a68-aff5-ac60ff334699", updated_at: "2025-09-26 12:29:39", created_at: "2025-09-26 12:29:39", tenancy_db_name: "tenantb37609c7-035c-4a68-aff5-ac60ff334699", }The tenancy_db_name attribute needs to be set before the database is created. The package achieves this by calling $tenant->database()->makeCredentials()
in CreateDatabase before running the actual createDatabase() call.
The same thing applies to the username and password of the tenant’s database user. If a permission controlled database manager is used, makeCredentials() also
generates a username (using DatabaseConfig::$usernameGenerator) and a password (using DatabaseConfig::$passwordGenerator). Those get stored on the tenant as
internal keys and then used when the permission controlled database manager creates/drops the respective database:
public function createUser(DatabaseConfig $databaseConfig): bool{ $database = $databaseConfig->getName(); $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword();
$this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'");
$grants = implode(', ', static::$grants);
if ($this->isVersion8()) { // MySQL 8+ $grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`%`"; } else { // MySQL 5.7 $grantQuery = "GRANT $grants ON `$database`.* TO `$username`@`%` IDENTIFIED BY '$password'"; }
return $this->connection()->statement($grantQuery);}Permission controlled SQLite
Section titled “Permission controlled SQLite”The closest SQLite equivalent of accessing a “different tenant’s database” is using the ATTACH statement
to access data in another database file.
Therefore, for feature parity, we ship with a feature for disabling ATTACH in SQLite: DisallowSqliteAttach.