- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I talked to had the same fight every Monday standup: should the new service use Pest or PHPUnit. One side had Pest 3 muscle memory from a Laravel codebase. The other side had years of PHPUnit data providers and didn't want to relearn how to write a test. They kept stalling on the question, and meanwhile the use-case layer they were building had no tests at all.
Both runners are good. Both run on the same engine. Pest 3 is a layer on top of PHPUnit 11. The choice depends on what your tests actually look like. Fashion has nothing to do with it. Use-case tests with in-memory adapters are the most common shape in a hex codebase, and the two runners feel different there.
Same domain, same port, same in-memory adapter, side by side. You can read both versions and pick the one you'd rather maintain at 3 AM.
The use case under test
The example is a ConfirmOrder use case from a checkout service. The domain has an Order aggregate, an OrderRepository port, and a Clock port. The use case loads an order, marks it confirmed, and persists it back. The whole thing is ~30 lines of PHP 8.3.
<?php
declare(strict_types=1);
namespace App\Checkout\UseCase;
use App\Checkout\Domain\Order;
use App\Checkout\Port\Clock;
use App\Checkout\Port\OrderRepository;
use App\Checkout\Port\OrderNotFound;
final readonly class ConfirmOrder
{
public function __construct(
private OrderRepository $orders,
private Clock $clock,
) {}
public function __invoke(string $orderId): Order
{
$order = $this->orders->byId($orderId)
?? throw new OrderNotFound($orderId);
$confirmed = $order->confirm($this->clock->now());
$this->orders->save($confirmed);
return $confirmed;
}
}
The in-memory adapter is the test fixture that makes the whole thing fast:
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Checkout\Domain\Order;
use App\Checkout\Port\OrderRepository;
final class InMemoryOrders implements OrderRepository
{
/** @var array<string, Order> */
private array $rows = [];
public function byId(string $id): ?Order
{
return $this->rows[$id] ?? null;
}
public function save(Order $order): void
{
$this->rows[$order->id()] = $order;
}
public function seed(Order ...$orders): void
{
foreach ($orders as $o) {
$this->rows[$o->id()] = $o;
}
}
}
No database. No transactions. No Doctrine bootstrap. The test runs in single-digit milliseconds because nothing leaves the process. That's the payoff of writing ports in the first place. Both runners get to share it.
PHPUnit 11: attributes everywhere
PHPUnit 11 finished the migration from doc-block annotations to PHP attributes. The same use case under PHPUnit looks like this:
<?php
declare(strict_types=1);
namespace App\Tests\UseCase;
use App\Checkout\Domain\Order;
use App\Checkout\Port\OrderNotFound;
use App\Checkout\UseCase\ConfirmOrder;
use App\Tests\Support\FixedClock;
use App\Tests\Support\InMemoryOrders;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ConfirmOrderTest extends TestCase
{
private InMemoryOrders $orders;
private FixedClock $clock;
private ConfirmOrder $confirm;
protected function setUp(): void
{
$this->orders = new InMemoryOrders();
$this->clock = new FixedClock('2026-05-18T10:00:00Z');
$this->confirm = new ConfirmOrder(
$this->orders,
$this->clock,
);
}
#[Test]
#[TestDox('confirms a pending order and stamps the time')]
public function it_confirms_pending(): void
{
$this->orders->seed(Order::pending('ord_1'));
$result = ($this->confirm)('ord_1');
self::assertTrue($result->isConfirmed());
self::assertSame(
'2026-05-18T10:00:00+00:00',
$result->confirmedAt()->format(DATE_ATOM),
);
}
#[Test]
public function it_throws_when_missing(): void
{
$this->expectException(OrderNotFound::class);
($this->confirm)('ord_missing');
}
/** @return iterable<string, array{string}> */
public static function badIds(): iterable
{
yield 'empty' => [''];
yield 'whitespace' => [' '];
yield 'too long' => [str_repeat('a', 65)];
}
#[Test]
#[DataProvider('badIds')]
public function it_rejects_bad_ids(string $id): void
{
$this->expectException(\InvalidArgumentException::class);
($this->confirm)($id);
}
}
It reads exactly how PHPUnit always read: a class with named methods, a setUp, attributes where doc-blocks used to be. The attributes are a real upgrade: IDEs see them, refactors rename them, and #[TestDox] produces human-readable output in the runner.
What stands out, after wiring the same use case through both runners:
-
setUpis verbose but unambiguous. Every collaborator is a typed property on the class. You canCmd+clickinto$this->ordersand the IDE knows what it is. - Data providers are static methods in PHPUnit 11. Non-static providers were deprecated in 10 and removed. The return type hint above is what static analysis wants to see.
- Attributes mean the test file has imports for
Test,DataProvider,TestDox,Group, and friends. The top of every file gets noisy fast. - The class shape forces a
final class FooTest extends TestCaseritual on every file. That's fine, but it's friction for tests that would otherwise be a singleit()block.
Pest 3: the same use case, less ceremony
Pest 3 runs on PHPUnit 11 under the hood. Same engine, different surface. The same use case, again:
<?php
declare(strict_types=1);
use App\Checkout\Domain\Order;
use App\Checkout\Port\OrderNotFound;
use App\Checkout\UseCase\ConfirmOrder;
use App\Tests\Support\FixedClock;
use App\Tests\Support\InMemoryOrders;
beforeEach(function () {
$this->orders = new InMemoryOrders();
$this->clock = new FixedClock('2026-05-18T10:00:00Z');
$this->confirm = new ConfirmOrder(
$this->orders,
$this->clock,
);
});
it('confirms a pending order and stamps the time', function () {
$this->orders->seed(Order::pending('ord_1'));
$result = ($this->confirm)('ord_1');
expect($result->isConfirmed())->toBeTrue();
expect($result->confirmedAt()->format(DATE_ATOM))
->toBe('2026-05-18T10:00:00+00:00');
});
it('throws when the order is missing', function () {
expect(fn () => ($this->confirm)('ord_missing'))
->toThrow(OrderNotFound::class);
});
it('rejects bad ids', function (string $id) {
expect(fn () => ($this->confirm)($id))
->toThrow(InvalidArgumentException::class);
})->with([
'empty' => [''],
'whitespace' => [' '],
'too long' => [str_repeat('a', 65)],
]);
Same coverage. Same in-memory adapter. Fewer lines, zero extends TestCase ceremony. The dataset hangs off the test with ->with(...) instead of a separate static method.
The differences worth naming:
-
beforeEachdoes whatsetUpdoes, with$thisbound by Pest's runtime magic. It works, but$this->ordersis untyped from the perspective of static analysis. PHPStan loses the type unless you use Pest's@varhints in aPest.phpfile or switch to a class-based test. - The
expect()API is fluent and reads close to English.expect($x)->toBeTrue()vsself::assertTrue($x)is a small win on every line, and the wins stack up. - The dataset block inline with
->with(...)is genuinely nicer than a separate static method. Named keys still show up in the runner output.
Where Pest wins: architecture tests
Pest 3 ships pest/plugins/arch out of the box. This is the feature that makes Pest worth picking for a hex codebase, full stop. The point of hexagonal architecture is that the domain depends on nothing. Pest can enforce that as a test:
<?php
arch('domain depends on nothing infrastructural')
->expect('App\Checkout\Domain')
->not->toUse([
'Illuminate',
'Symfony',
'Doctrine',
'PDO',
'GuzzleHttp',
'PHPUnit',
]);
arch('use cases only depend on ports and domain')
->expect('App\Checkout\UseCase')
->toOnlyUse([
'App\Checkout\Port',
'App\Checkout\Domain',
]);
arch('ports are interfaces')
->expect('App\Checkout\Port')
->toBeInterfaces();
arch('value objects are readonly')
->expect('App\Checkout\Domain\ValueObject')
->toBeReadonly();
That's four tests that enforce the entire dependency rule of a hexagonal layout. The first one pays for itself the first time it catches an import. A junior engineer slips a use Illuminate\Support\Carbon into the domain because the IDE auto-imported it. The build goes red. Without this, the rule lives in a CONTRIBUTING.md that nobody re-reads.
PHPUnit has no equivalent. You can write the same checks with deptrac or phpat as separate tools. They're good, but they're separate tools with their own config files. With Pest, arch lives next to the unit tests in the same pest invocation.
The honest decision table
| Concern | PHPUnit 11 | Pest 3 |
|---|---|---|
| Engine | The engine | Wraps PHPUnit 11 |
| Test shape | Class + methods + attributes |
it() blocks + closures |
| Verbosity per test | Higher (class, setUp, asserts) | Lower (closure, expect, beforeEach) |
| IDE support | First-class everywhere | First-class in PhpStorm; mixed in VS Code |
| Static analysis | Clean (typed properties) | Needs hints for $this in closures |
| Data providers | Static methods | Inline ->with(...)
|
| Architecture tests | External (deptrac, phpat) | Built-in (arch()) |
| Mutation testing | Infection (external) | Mutate (built-in) |
| Snapshot testing | External | Built-in |
| Custom expectations | Custom assertion methods | expect()->extend() |
| Learning curve for new hire | Familiar to anyone with xUnit | Different shape; 30 minutes |
| Run on existing PHPUnit tests | N/A | Yes, mixed projects work |
A few of those rows deserve a sentence:
Static analysis with PHPStan. PHPUnit gives PHPStan typed properties on a class. Pest gives PHPStan a closure with $this magically containing things. You can fix it. Pest documents how. But you have to remember to fix it, and a fresh contributor will not. If your team runs PHPStan at level 8 by default, PHPUnit is the lower-friction path.
Mixed projects. Pest can run alongside PHPUnit tests in the same project. This is the strongest argument for trying Pest on a brownfield codebase: you don't have to migrate anything. New tests go in the Pest shape; old tests keep running. Both run with vendor/bin/pest.
Mutation testing. Pest's built-in mutate plugin runs mutation testing in-line. PHPUnit users reach for Infection, which is excellent but is another tool with another config. If you've been meaning to add mutation testing and haven't, Pest lowers that hurdle.
When each one is the right pick
Pick PHPUnit 11 when:
- The team is heavily Symfony, heavily into static analysis at level 8, and
$this-magic in closures will cost you in PHPStan time. - The codebase has 1000+ existing PHPUnit tests and there is no migration appetite. Don't migrate. PHPUnit 11 with attributes is a fine destination on its own.
- The test shape you write most is parameterized end-to-end tests with deep
setUpchains and traits. The class-based shape carries that better than closures withbeforeEach. - You're writing a library and want zero opinionated test-runner dependencies in the dev-dependencies of consumers' projects.
Pick Pest 3 when:
- You're starting a new service or use-case layer, and the unit-tests shape will be many small
it()blocks. The reduction in ceremony is real. - You want architecture tests on a hex/clean codebase. This is the single biggest swing.
arch()is built for exactly this. - The team works in Laravel and the rest of the test pyramid already lives in Pest. Consistency across the test types is worth the small static-analysis tax.
- You want snapshot tests, mutation tests, and arch tests in one runner instead of four tools.
Pick both when:
- You inherit a PHPUnit codebase and want to write new tests in Pest. This works. The arch tests alone are a reason to add Pest to a Symfony+PHPUnit project even if you keep PHPUnit as the primary shape.
What I'd actually do
For a fresh hex codebase in PHP 8.3, I'd start with Pest 3. The arch tests pay for themselves the first time someone tries to import Eloquent into the domain. The lower ceremony per test means more tests get written, and use-case tests are the ones the team will re-read most. The PHPStan friction is real but solvable with a single Pest.php config.
For a 5-year-old codebase that's already in PHPUnit, the answer is: don't migrate. Upgrade to PHPUnit 11, switch annotations to attributes, and add Pest only if you want the arch tests. The cost of converting 1000 tests is not paid back by a slightly cleaner expect() API.
The thing both runners share is the part that matters: they let you test your use cases against in-memory adapters in milliseconds, without booting a framework. That payoff comes from the ports, not from the runner. Pick the surface that fits your team, and ship the tests.
If this was useful
The test layer is one of the last chapters of Decoupled PHP. By the time you get there, the ports and use cases are already in place and the tests almost write themselves. The book walks the full hexagonal layout in PHP 8.3, with both Laravel and Symfony as adapters, and ends with a real migration playbook for legacy services that need to get there incrementally.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)