- 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
You open a PHP repo. The tests/ folder is full of class OrderServiceTest extends TestCase with method names like test_it_throws_when_currency_is_null_and_country_is_not_us. Somebody on the team keeps suggesting Pest. Somebody else keeps saying "we already have PHPUnit, why bother".
Both people are partially right. Pest 3 isn't a different test runner. It's PHPUnit 11 with a different surface. The question isn't which engine is better. It's which surface produces tests you'll still want to read in two years.
What Pest actually is
Pest 3 sits on top of PHPUnit 11. When you run vendor/bin/pest, it spins up PHPUnit's TestRunner under the hood. Every Pest test compiles down to a PHPUnit TestCase method at runtime. Your phpunit.xml still works. Your assertions still use PHPUnit's assertion library. Coverage reports come from the same Xdebug / PCOV pipeline.
That matters for two reasons. First, "switching to Pest" doesn't mean throwing away PHPUnit knowledge. Second, the things Pest adds (it()/test() blocks, higher-order tests, the architecture plugin) are pure ergonomics. The runtime is identical.
Here's the same test in both. PHPUnit 11:
<?php
namespace Tests\Unit;
use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class OrderTest extends TestCase
{
#[Test]
public function it_transitions_from_pending_to_paid(): void
{
$order = Order::pending('ord_123', 4999);
$order->markPaid('pi_abc');
$this->assertSame(OrderStatus::Paid, $order->status());
$this->assertSame('pi_abc', $order->paymentIntentId());
}
}
Pest 3:
<?php
use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
it('transitions from pending to paid', function () {
$order = Order::pending('ord_123', 4999);
$order->markPaid('pi_abc');
expect($order->status())->toBe(OrderStatus::Paid)
->and($order->paymentIntentId())->toBe('pi_abc');
});
Same test. Same engine. About 30% less ceremony. If you stop reading here, that's the case for Pest in one screenshot.
Reason 1: test names that read like sentences
The PHPUnit version names the method it_transitions_from_pending_to_paid. The Pest version names the test transitions from pending to paid. When the suite fails, the output reads:
FAIL Tests\Unit\OrderTest
it transitions from pending to paid
vs
FAIL Tests\Unit\OrderTest
⨯ it transitions from pending to paid
The Pest output isn't magically better. The win is that test names stay readable as English. PHPUnit method names get worse the more conditions you stack. test_it_throws_invalid_currency_when_country_is_not_us_and_amount_under_50 is real code in real repos. Pest writes the same thing as it('throws InvalidCurrency when country is not US and amount under 50', ...). Quotes and spaces aren't decoration. They make the assertion the test failure message produces actually useful.
You can argue PHPUnit's #[TestDox] attribute solves this. It does, sort of. Nobody fills it in.
Reason 2: higher-order tests
This is the feature people misuse, then love once they get it.
A higher-order test in Pest chains methods directly on it() instead of in a closure. So this:
it('returns 200 for the homepage', function () {
$response = $this->get('/');
expect($response->status())->toBe(200);
});
becomes:
it('returns 200 for the homepage')
->get('/')
->assertStatus(200);
By itself, not a big deal. Where it pays off is ->each() and datasets. Imagine validating every route in a public API returns JSON content-type:
it('returns JSON for all public endpoints')
->with([
['GET', '/api/v1/orders'],
['GET', '/api/v1/products'],
['GET', '/api/v1/users/me'],
['GET', '/api/v1/health'],
])
->call(fn ($method, $url) => $this->{strtolower($method)}($url))
->assertHeader('Content-Type', 'application/json');
The PHPUnit equivalent is a #[DataProvider] method, a loop, and four assertions. Maybe 25 lines. The Pest version is 7.
The ->skip() modifier is the other one worth knowing. Want to skip a test in CI but keep it locally?
it('hits the real Stripe API', function () {
// ...
})->skip(fn () => env('CI') === true, 'No Stripe creds in CI');
PHPUnit has markTestSkipped() inside the test body, which means the test starts before it skips. Pest's higher-order skip evaluates first, so the test never enters the runner. Marginally faster, much cleaner.
Gotcha: higher-order chains break when you need anything that's not on the chain target. ->each() works on collections, ->get()/->post() works on the test case, but custom helpers usually don't chain. When you hit "no method foo on Pest\Expectation", drop back to the closure form. Don't twist your code to fit the chain.
Reason 3: architecture tests
This is the killer feature. Nothing in PHPUnit comes close.
The pestphp/pest-plugin-arch plugin lets you write tests against your code's structure. Not behavior. Structure. The kind of thing you'd previously enforce with a homegrown PHPStan rule, a custom rector, or by yelling in PR review.
<?php
arch('domain layer has no framework dependencies')
->expect('App\Domain')
->not->toUse([
'Illuminate',
'Symfony',
'Doctrine\ORM',
]);
arch('value objects are final and readonly')
->expect('App\Domain\ValueObject')
->toBeFinal()
->toBeReadonly();
arch('controllers do not call repositories directly')
->expect('App\Http\Controllers')
->not->toUse('App\Domain\Repository');
arch('only command handlers write to the database')
->expect('App\Application\Command')
->toOnlyUse([
'App\Domain',
'App\Application',
'Doctrine\ORM\EntityManagerInterface',
]);
Run vendor/bin/pest. The architecture tests run in the same suite as your unit tests. CI fails when somebody in your domain layer imports Illuminate\Support\Str.
This isn't a "nice to have". On any codebase trying to keep a clean domain core, architecture tests are the only thing that survives team rotation. PHPStan rules need rule classes and config. Pest's arch() reads like English and lives next to the tests it constrains.
The catch: the parser sometimes misses dynamic uses (string class names passed to app()->make(), for example). Don't treat arch tests as a complete static-analysis replacement. Treat them as a guardrail for the obvious violations.
Reason 4: stress testing built-in
Pest 3 ships with a stress-test command:
vendor/bin/pest --stress /api/orders --duration=10 --concurrency=50
It hits your local app or a URL, reports requests/second, p95 latency, and error rate. Not a replacement for k6 or Vegeta, but useful for "did this endpoint regress" smoke checks in CI. PHPUnit has no equivalent. You'd reach for a separate tool.
Worth it? If you already have k6 wired up, no. If you don't and want a 30-second sanity check on a hot endpoint, yes.
Reason NOT 1: IDE autocomplete varies
PHPUnit's $this->assertSomething() pattern hooks into PHPStorm and VS Code's PHP extension cleanly. Every assertion is a typed method on TestCase. F12 jumps to source. Refactoring is reliable.
Pest's expect() and chained matchers depend on the IDE understanding the plugin's dynamic types. PHPStorm with the Pest plugin handles it well in 2026. It took a few releases to get there. VS Code with Intelephense is decent but still loses autocomplete on chained ->and() calls occasionally. Smaller IDEs (Sublime, Helix, plain Neovim) lose most of it.
If your team lives in PHPStorm, this isn't a real reason against Pest. If half the team is on something else, it might be.
Reason NOT 2: migrating a 10k-test suite
This is where the math gets honest. Pest's CLI ships a migrate command:
vendor/bin/pest --init
vendor/bin/pest --migrate
It converts class FooTest extends TestCase into it() blocks. It mostly works. On a small suite (a few hundred tests) the auto-conversion handles 80–90% and you fix the rest by hand.
On a 10,000-test legacy suite written across five years by 30 engineers, the migration produces a diff that scares everyone. Custom setUp() chains, data providers that depend on protected state, traits that override tearDown(), helpers that walk the test stack: all of these convert into Pest in a form that works but doesn't look like Pest. You end up with Pest-shaped PHPUnit, which is the worst of both worlds.
Teams attempt the full migration, give up halfway, and revert. Don't do that. The hybrid approach below is what teams that succeed actually do.
The hybrid: Pest for new code, PHPUnit for legacy
Pest and PHPUnit coexist in the same project. Both runners share the same phpunit.xml. You can run either:
vendor/bin/pest # runs everything, Pest + PHPUnit tests
vendor/bin/phpunit # also runs everything
The strategy that works:
- Install Pest alongside existing PHPUnit. Don't migrate anything yet.
-
All new tests are Pest. Enforce in code review. The architecture plugin can even check that no new files matching
tests/Unit/*Test.php(PHPUnit-style class form) get added. Write anarch()test for your own tests. - Leave the legacy PHPUnit suite alone. It still runs, still passes, still works.
- When you touch a legacy test for a substantive change, convert that file to Pest as part of the change. Otherwise don't.
Six months in, your tests/ folder has two zones: the Pest zone, growing; the PHPUnit zone, shrinking. Nobody had to commit to a big-bang migration. The new code reads like sentences, the old code keeps running.
The composer setup is just:
{
"require-dev": {
"phpunit/phpunit": "^11.5",
"pestphp/pest": "^3.7",
"pestphp/pest-plugin-arch": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
},
"scripts": {
"test": "vendor/bin/pest --parallel",
"test:arch": "vendor/bin/pest --filter=arch",
"test:coverage": "vendor/bin/pest --coverage --min=80"
}
}
--parallel works the same as PHPUnit's paratest and survives the migration. Coverage works. CI works.
The honest take
Pest 3 wins on test readability, higher-order ergonomics, and the architecture plugin. PHPUnit 11 wins on tooling maturity, IDE support across non-JetBrains editors, and the fact that 90% of PHP developers already know it.
Pick Pest for new projects. Pick the hybrid for anything older than two years. Don't pick "full migration" unless your suite is small enough that you can do it in an afternoon. The architecture plugin alone is worth installing Pest even if you write zero Pest tests. Pointing arch()->expect('App\Domain')->not->toUse('Illuminate') at a legacy codebase is the cheapest architecture audit you'll ever do.
What's stopping your team from adopting Pest right now? IDE friction, the migration cost, or just inertia? Curious where the actual blocker is.
If this was useful
If you're already thinking about how to keep your domain code out of the framework's hands, the architecture-test plugin is the smallest possible step. The next one is structural: directory layout, dependency direction, the boundaries Pest's arch() checks for you. That's what Decoupled PHP is about: the architectural layer your codebase reaches for once the framework defaults start to bend.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)