Why Your Domain Tests Should Not Boot Laravel
Every time a Pest test calls uses(RefreshDatabase::class) for a piece of pure business logic, you're paying a tax you don't owe. Booting the service container, running migrations, and seeding state adds hundreds of milliseconds per suite. For domain actions, DTOs, and value objects — code that has no legitimate dependency on the framework — that cost is entirely avoidable.
This article shows a concrete approach: structure your domain so its core is framework-agnostic, then write Pest tests that verify it in pure PHP.
The Domain Layer Contract
A well-bounded action looks like this:
// app/Domain/Billing/Actions/ApplyPromoCode.php
final class ApplyPromoCode
{
public function __construct(
private readonly PromoCodeRepository $promos,
private readonly Clock $clock,
) {}
public function execute(ApplyPromoCodeData $data): OrderTotal
{
$promo = $this->promos->findByCode($data->code)
?? throw new InvalidPromoCode($data->code);
if ($promo->isExpiredAt($this->clock->now())) {
throw new ExpiredPromoCode($data->code);
}
return OrderTotal::withDiscount($data->subtotal, $promo->discountPercent());
}
}
Notice: no Illuminate imports, no facades, no Eloquent. The two dependencies are interfaces. That's the seam Pest exploits.
Structuring the Pest Suite
Keep domain tests in tests/Unit/Domain/. No uses() trait needed — no database, no HTTP.
// tests/Unit/Domain/Billing/ApplyPromoCodeTest.php
use App\Domain\Billing\Actions\ApplyPromoCode;
use App\Domain\Billing\Data\ApplyPromoCodeData;
use App\Domain\Billing\Exceptions\ExpiredPromoCode;
use App\Domain\Billing\Exceptions\InvalidPromoCode;
use App\Domain\Billing\ValueObjects\Money;
beforeEach(function () {
$this->promos = Mockery::mock(PromoCodeRepository::class);
$this->clock = new FrozenClock('2025-06-01 12:00:00');
$this->action = new ApplyPromoCode($this->promos, $this->clock);
});
it('applies a valid promo code and returns a discounted total', function () {
$promo = PromoCodeFactory::make(code: 'SAVE10', discountPercent: 10, expiresAt: '2025-12-31');
$this->promos->allows('findByCode')->with('SAVE10')->andReturn($promo);
$data = new ApplyPromoCodeData(code: 'SAVE10', subtotal: Money::of(100_00));
$result = $this->action->execute($data);
expect($result->total())->toBe(Money::of(90_00));
});
it('throws when the promo code does not exist', function () {
$this->promos->allows('findByCode')->andReturn(null);
$data = new ApplyPromoCodeData(code: 'GHOST', subtotal: Money::of(50_00));
expect(fn () => $this->action->execute($data))
->toThrow(InvalidPromoCode::class);
});
it('throws when the promo code is expired', function () {
$promo = PromoCodeFactory::make(code: 'OLD20', discountPercent: 20, expiresAt: '2024-01-01');
$this->promos->allows('findByCode')->andReturn($promo);
$data = new ApplyPromoCodeData(code: 'OLD20', subtotal: Money::of(80_00));
expect(fn () => $this->action->execute($data))
->toThrow(ExpiredPromoCode::class);
});
These three tests run in under 10 ms combined. No database. No container.
Testing Value Objects Directly
Value objects are the easiest wins — pure functions with equality semantics:
// tests/Unit/Domain/Billing/MoneyTest.php
dataset('addition', [
[100_00, 50_00, 150_00],
[0, 99_99, 99_99],
]);
it('adds two Money values correctly', function (int $a, int $b, int $expected) {
expect(Money::of($a)->add(Money::of($b)))->toBe(Money::of($expected));
})->with('addition');
it('prevents mixing currencies', function () {
expect(fn () => Money::ofCurrency(10_00, 'USD')->add(Money::ofCurrency(5_00, 'EUR')))
->toThrow(CurrencyMismatch::class);
});
Pest datasets keep these exhaustive without repetition.
Where the Framework Re-Enters: Integration Tests
Infrastructure concerns — Eloquent repositories, queued jobs, HTTP endpoints — belong in tests/Feature/. Use RefreshDatabase there and only there. The boundary is intentional:
| Layer | Test type | Uses framework? | |---|---|---| | Value objects, DTOs | Unit | No | | Domain actions | Unit | No | | Eloquent repositories | Integration | Yes | | HTTP controllers | Feature | Yes |
Takeaways
- Interfaces at domain boundaries are the prerequisite — without them, mocking is painful and coupling is hidden.
FrozenClockand factory helpers replace time-dependent and state-dependent setup without the database.- Pest datasets make value object edge cases exhaustive and readable.
- Separate
tests/Unit/Domain/fromtests/Feature/and enforce the split in CI with--testsuite=unitfor the fast gate. - A domain test suite that boots zero framework code is a design signal, not just a speed win.