Seedling provides multi-tenant database support for Sprout, enabling tenants to have isolated database environments. It supports multiple isolation strategies, configurable migration paths, and explicit provisioning workflows, while integrating with Bud for tenant-specific database configuration.
Many multi-tenant applications require database-level isolation between tenants. The reasons vary:
Sprout Core provides tenant context and scoping for shared-database scenarios (where all tenants share tables with a
tenant_id column), but this doesn’t address cases where tenants need their own database or schema.
Seedling fills this gap by providing:
Seedling supports three isolation strategies:
Separate Database — Each tenant has their own database on a shared or dedicated server:
mysql-server/
├── app_central
├── tenant_acme
├── tenant_globex
└── tenant_initech
Separate Schema — Tenants share a database but have isolated schemas (PostgreSQL):
postgres-database/
├── public (central)
├── tenant_acme
├── tenant_globex
└── tenant_initech
Separate Connection — Each tenant has entirely separate connection details, potentially on different servers or even different database engines:
acme → mysql://acme-db.example.com/acme
globex → pgsql://globex-db.example.com/main
initech → mysql://shared.example.com/initech
The strategy is configuration-based and can potentially vary per tenant when using Bud’s external configuration.
// config/sprout.php
return [
'seedling' => [
// The base connection to use as a template
'connection' => 'tenant',
// Default isolation strategy
'strategy' => 'database', // 'database', 'schema', 'connection'
// How to derive the database/schema name from tenant
'database' => [
'prefix' => 'tenant_',
'suffix' => '',
// Or use a callback/class for full control
'resolver' => null,
],
// Migration configuration
'migrations' => [
'path' => database_path('migrations/tenant'),
'table' => 'migrations',
],
// Seeder configuration
'seeders' => [
TenantDatabaseSeeder::class,
],
],
];
Seedling uses Bud’s service override system but extends it for database-specific needs. The flow:
tenant connectiontenant connection now hits the tenant’s databaseFor simple cases (derive database name from tenant key), no Bud configuration is needed — Seedling handles it directly. For complex cases (per-tenant connection strings, credentials, etc.), Bud provides the configuration source.
// Simple: database name derived from tenant
// No additional config needed, Seedling uses prefix + tenant_key
// Complex: full connection details from Bud
// Tenant config (in database, file, etc.):
[
'database' => [
'host' => 'acme-db.example.com',
'database' => 'acme_production',
'username' => 'acme_user',
'password' => '...',
],
]
Seedling provides a tenant database connection that dynamically resolves to the current tenant’s database:
// In config/database.php
'connections' => [
'tenant' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'database' => null, // Filled by Seedling at runtime
// ... other defaults
],
],
Models specify this connection:
class TenantUser extends Model
{
protected $connection = 'tenant';
}
When no tenant is active, accessing the tenant connection throws an exception (configurable behaviour).
Tenant migrations live in a configurable directory (default: database/migrations/tenant). Seedling provides Artisan
commands that mirror Laravel’s:
# Run migrations for all tenants
php artisan sprout:migrate
# Run for specific tenant
php artisan sprout:migrate --tenant=acme
# Run for tenants matching criteria
php artisan sprout:migrate --where="plan=premium"
# Other migration commands
php artisan sprout:migrate:rollback
php artisan sprout:migrate:fresh
php artisan sprout:migrate:status
Each command:
Migration state is tracked per-tenant in each tenant’s database.
Provisioning is explicit — Seedling does not automatically create databases when tenants are created. This is intentional:
Provisioning is triggered via:
// Programmatically
Sprout::provision($tenant);
// Or via Artisan
php artisan sprout:provision acme
The provisioning process:
TenantProvisioned eventDeprovisioning follows a similar pattern:
Sprout::deprovision($tenant);
Users wire these into their tenant lifecycle as appropriate:
// In a controller, job, or event listener
public function store(Request $request)
{
$tenant = Tenant::create($request->validated());
// Immediate provisioning
Sprout::provision($tenant);
// Or dispatch a job
ProvisionTenant::dispatch($tenant);
}
| Event | When |
|---|---|
TenantProvisioning |
Before provisioning begins |
TenantProvisioned |
After provisioning completes |
TenantDeprovisioning |
Before deprovisioning begins |
TenantDeprovisioned |
After deprovisioning completes |
TenantMigrating |
Before migrations run for a tenant |
TenantMigrated |
After migrations complete for a tenant |
When switching between tenants (in commands that iterate), how do we ensure connections are properly closed and
reopened? Laravel’s DB::purge() exists but there may be edge cases with persistent connections or connection pooling.
For PostgreSQL schema isolation, what’s the cleanest way to switch schemas? Options include:
search_path on the connectionShould tenant migrations be completely isolated from central migrations, or should there be a way to share/inherit? For
example, a users table that exists in both central and tenant databases with the same structure.
For commands that operate on many tenants, should there be built-in support for parallel execution? This significantly speeds up operations but adds complexity around connection management and output handling.
What happens when provisioning fails partway through (database created, but migrations failed)? Options:
How should tenant database testing work? Options:
Rejected because tenant models are user-defined and the provisioning lifecycle varies significantly between applications. Some need synchronous provisioning, others async, others need approval workflows.
Could use Laravel’s existing migration directory with a --tenant flag or trait to mark tenant migrations. Rejected
because it conflates central and tenant migrations, making it harder to reason about what runs where.
Could define each tenant’s connection statically in config/database.php. Rejected because it doesn’t scale and
requires config changes for each new tenant.