So, here's the thing, We've been working on a new product at Jacasa with my colleague Michael, It's a multi-tenant Laravel application using stancl/tenancy, and like any good development team, we write tests. A lot of them. The problem? Our test suite was getting painfully slow. We're talking 5 minutes for a full run in Pest with parallel mode on, and 1-3 seconds per individual test. The funniest part? On GitHub Actions, this ballooned to around 30 minutes. When you're in flow and making rapid changes, that kind of wait time kills your momentum.
Every time we'd make a change, we'd run the tests, and then... wait. And wait. It got to the point where we'd skip running tests altogether just to keep moving. Not great.
Then I figured something out that changed everything. Now our tests run in ~10 seconds. A single test? Usually ~0.04 seconds. Let me show you what we did.
What Was Making Everything So Slow?
In a multi-tenant app, you're juggling multiple databases. We've got:
- A central database for tenant info and administrative stuff
- A tenant database for each tenant's actual data
Every time a test ran, we were doing this dance:
- Create a fresh tenant
- Create the tenant database and migrate
- Connect to the tenant database
- Execute the test
- Repeat
The Fix: Wrap Everything in Database Transactions
Here's what clicked for us: instead of rebuilding the database for every test, what if we just wrapped each test in a transaction and rolled it back when done?
Laravel already does this for single-database apps. We just needed to make it work across multiple database connections at once.
The Code That Changed Everything
We built a custom trait called ManagesMultipleDatabaseTransactions. Here's what it does:
public function beginDatabaseTransaction(): void{ $database = $this->app->make('db'); $connections = $this->connectionsToTransact(); $transactionsManager = new DatabaseTransactionsManager($connections); $this->app->instance('db.transactions', $transactionsManager); foreach ($connections as $name) { $this->beginTransactionForConnection($database, $name, $transactionsManager); } $this->beforeApplicationDestroyed(function () use ($database): void { $this->rollbackDatabaseTransactions($database); });}
The trick is starting a transaction on both the central database and the tenant database before each test runs.
The Tenant Database Twist
Here's where it gets interesting. For the tenant database, we need to initialize tenancy first:
protected function beginTransactionForConnection( DatabaseManager $database, string $name, DatabaseTransactionsManager $transactionsManager): void { if ($name === 'tenant') { $this->initializeTenancy(); } $connection = $database->connection($name); $connection->setTransactionManager($transactionsManager); $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); $connection->beginTransaction(); $connection->setEventDispatcher($dispatcher);}
When we hit the tenant connection, we initialize tenancy. This creates the tenant and runs migrations—but only once. We've extracted this into a separate InitializesTenancy trait. Here's what that looks like:
public function initializeTenancy(array $attributes = []): Tenant{ $tenancy = tenancy(); if ($tenancy->initialized) { $tenant = tenant(); $tenant->update($attributes); return $tenant; } $tenant = Tenant::factory() ->state(['id' => 'testing']) ->createQuietly(); $this->artisan('tenants:migrate', [ '--tenants' => ['testing'], ]); $tenancy->initialize($tenant); return $tenant;}
The if ($tenancy->initialized) check is crucial—it means migrations only run on the first test. Every subsequent test skips the migration and just reuses the existing tenant database.
We also create the tenant model silently using createQuietly(), so it doesn't fire the events that would automatically create the tenant database and migration.
Rolling Everything Back
After each test, we clean up:
protected function rollbackDatabaseTransactions(DatabaseManager $database): void{ foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); $connection->rollBack(); $connection->setEventDispatcher($dispatcher); $connection->disconnect(); }}
Everything gets rolled back. It's like the test never happened (in the database, at least).
Hooking It Into Our Base TestCase
Our TestCase uses PHP's trait conflict resolution to swap in our custom transaction handling:
abstract class TestCase extends BaseTestCase{ use InitializesTenancy; use ManagesMultipleDatabaseTransactions; use RefreshDatabase { ManagesMultipleDatabaseTransactions::beginDatabaseTransaction insteadof RefreshDatabase; } protected function setUp(): void { parent::setUp(); Event::fake([ TenancyInitialized::class, TenancyEnded::class, ]); } protected function connectionsToTransact(): array { return ['mysql', 'tenant']; }}
That insteadof keyword is doing the heavy lifting here. It tells Laravel to use our transaction method instead of the default one.
We also fake the TenancyInitialized and TenancyEnded events in setUp(). This is crucial because stancl/tenancy fires these events when switching between tenants, and we don't want them triggering side effects during our tests. By faking them, we keep our tests focused and prevent unexpected behavior from event listeners.
The connectionsToTransact() method is equally important—it tells the transaction manager which database connections to wrap in transactions. In our case:
mysql- The central databasetenant- The tenant-specific database
This is how we coordinate transactions across multiple databases. When the test starts, both connections begin a transaction. When the test finishes, both get rolled back together, keeping everything in sync.
Why This Works So Well
The magic is pretty simple when you break it down:
- One-time setup: The tenant database gets created and migrated once when the test suite starts, not before every single test
- Transaction isolation: Each test gets a clean slate without the migration overhead
- Everything stays in sync: Both databases roll back together, so there's no weird orphaned data
The Numbers Don't Lie
Before this change:
- Single test: 1-3 seconds
- Full test suite: ~5 minutes
- GitHub Actions: ~30 minutes
After:
- Single test: ~0.04 seconds
- Full test suite: ~10 seconds
- GitHub Actions: ~3 minutes
That's roughly a 25× speedup for the full test suite—we went from "I'll go make a coffee" to "already done?" And on GitHub Actions? We went from "let's go to lunch" to "still faster than making that coffee."
What We Learned
A few things stood out from this experience:
Database transactions are criminally underused. They're not just for ensuring data integrity—they're amazing for test isolation and speed.
Laravel has the tools built in. The DatabaseTransactionsManager isn't well-documented, but it's powerful. You just need to wire it up for multiple connections.
Fast tests change behavior. When tests run in 10 seconds instead of 5 minutes, you actually run them. Every time. Before committing. It's been a game-changer for catching bugs early.
Writing Tests With This Approach
One of the best parts of this setup is how clean it makes your actual tests. Here's what it looks like in practice:
Basic Test Usage
Before running your tests, make sure you've created a database called tenant-testing in your MySQL server. This is the database that will be used for all tenant-related test data.
The database name may vary depending on your tenancy configuration prefix—check your config/tenancy.php for the database.prefix setting.
Most tests don't need to do anything special—they just work:
it('can create a tenant', function () { // Tenant initializes automatically expect(tenant()) ->not->toBeNull() ->id->toBe('testing');}); it('can create posts with custom tenant attributes', function () { // Manually initialize the tenancy when we want to update the attributes of tenant $this->initializeTenancy([ 'name' => 'Acme Corp', 'plan' => 'premium', ]); $post = Post::factory()->create([ 'title' => 'My First Post', ]); expect(tenant()) ->name->toBe('Acme Corp') ->plan->toBe('premium'); expect($post) ->title->toBe('My First Post') ->exists->toBeTrue();});
Should You Try This?
If you're building a multi-tenant Laravel app and your tests are slow, absolutely. This pattern works great with stancl/tenancy and Laravel 12.
The only caveat: this assumes your tests can safely use transactions. If you're testing something that requires multiple database connections or nested transactions in specific ways, you might need to tweak the approach.
For us at Jacasa? This was the single biggest productivity boost we've made to our development workflow in months. No more coffee breaks while tests run. Just fast feedback and shipping features.
The Complete Implementation
Ready to implement this yourself? Here's the full code you can copy and paste into your test suite.
ManagesMultipleDatabaseTransactions Trait
This trait handles wrapping multiple database connections in transactions:
<?php namespace Tests\Concerns; use Illuminate\Database\DatabaseManager;use Illuminate\Foundation\Testing\DatabaseTransactionsManager; trait ManagesMultipleDatabaseTransactions{ public function beginDatabaseTransaction(): void { $database = $this->app->make('db'); $connections = $this->connectionsToTransact(); $transactionsManager = new DatabaseTransactionsManager($connections); $this->app->instance('db.transactions', $transactionsManager); foreach ($connections as $name) { $this->beginTransactionForConnection($database, $name, $transactionsManager); } $this->beforeApplicationDestroyed(function () use ($database): void { $this->rollbackDatabaseTransactions($database); }); } protected function beginTransactionForConnection( DatabaseManager $database, string $name, DatabaseTransactionsManager $transactionsManager ): void { if ($name === 'tenant') { $this->initializeTenancy(); } $connection = $database->connection($name); $connection->setTransactionManager($transactionsManager); $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); $connection->beginTransaction(); $connection->setEventDispatcher($dispatcher); } protected function rollbackDatabaseTransactions(DatabaseManager $database): void { foreach ($this->connectionsToTransact() as $name) { $connection = $database->connection($name); $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); $connection->rollBack(); $connection->setEventDispatcher($dispatcher); $connection->disconnect(); } }}
InitializesTenancy Trait
<?php namespace Tests\Concerns; use App\Models\Tenant; trait InitializesTenancy{ public function initializeTenancy(array $attributes = []): Tenant { $tenancy = tenancy(); if ($tenancy->initialized) { $tenant = tenant(); assert($tenant instanceof Tenant); $tenant->update($attributes); return $tenant; } $tenant = Tenant::factory()->state(['id' => 'testing'])->createQuietly(); $this->artisan('tenants:migrate', [ '--tenants' => ['testing'], ]); $tenancy->initialize($tenant); return $tenant; }}
Base TestCase
And here's how they come together in the base TestCase:
<?php namespace Tests; use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Foundation\Testing\TestCase as BaseTestCase;use Tests\Concerns\InitializesTenancy;use Tests\Concerns\ManagesMultipleDatabaseTransactions; abstract class TestCase extends BaseTestCase{ use InitializesTenancy; use ManagesMultipleDatabaseTransactions; use RefreshDatabase { ManagesMultipleDatabaseTransactions::beginDatabaseTransaction insteadof RefreshDatabase; } protected function setUp(): void { parent::setUp(); Event::fake([ TenancyInitialized::class, TenancyEnded::class, ]); } protected function connectionsToTransact(): array { return ['mysql', 'tenant']; }}
Wrapping Up
Going from 5-minute test runs to 10 seconds has been a game changer for us at Jacasa. We're shipping faster, catching bugs earlier, and actually enjoying the development process more. No more context switching while waiting for tests—just rapid iteration and confident deployments.
If you're dealing with slow multi-tenant tests, give this approach a shot. And if you run into any issues or have improvements, I'd love to hear about them. The Laravel community is all about sharing knowledge, and there's probably even more optimization we haven't discovered yet.
Now go make your tests fast. Your future self (and your coffee budget) will thank you. ☕️