After twenty years of PHPUnit boilerplate, I switched to Pest and never looked back. Here's why the cleaner syntax, dataset testing, higher-order expectations, and architecture tests make it the only testing tool I use for Laravel.

I have been writing PHP for over twenty years. I have shipped code with PHPUnit since before Laravel existed. I know every annotation, every assertion, every brittle mock setup. And I am done with it.
PHPUnit is not broken. It is boring. It is verbose. It turns testing into paperwork. After two decades of public function testSomething(): void and $this->assertEquals(4, $result), my brain started treating tests as a chore instead of a design tool. Then Pest showed up and reminded me that testing can actually feel good.
Look at a standard PHPUnit test. You extend a base class. You import assertions. You prefix every method with test. You annotate data providers. You call $this->assert* like you are filling out a government form.
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
public function test_it_adds_two_numbers(): void
{
$calculator = new Calculator();
$result = $calculator->add(2, 2);
$this->assertEquals(4, $result);
}
public function test_it_throws_on_division_by_zero(): void
{
$this->expectException(DivisionByZeroException::class);
$calculator = new Calculator();
$calculator->divide(10, 0);
}
}There is nothing wrong with this code. It works. But it is noisy. The signal-to-noise ratio is terrible. I am reading more framework ceremony than business logic. After writing hundreds of these files, the friction adds up. You start skipping edge cases because the setup cost is too high. That is how bugs slip through.
Continue Reading
Pest strips away the scaffolding. No classes. No extends. No $this->. You write a function and an expectation. That is it.
it('adds two numbers', function () {
$calculator = new Calculator();
expect($calculator->add(2, 2))->toBe(4);
});
it('throws on division by zero', function () {
$calculator = new Calculator();
expect(fn () => $calculator->divide(10, 0))
->toThrow(DivisionByZeroException::class);
});The difference is not cosmetic. When the syntax gets out of your way, you think about behavior instead of structure. You write more tests because there is less to type. You read tests faster because your eyes land on the logic, not the boilerplate.
Pest is built on top of PHPUnit. It does not replace the engine. It replaces the dashboard. Every PHPUnit assertion still works under the hood. But the developer experience is rewritten from scratch.
Data-driven tests in PHPUnit are a pain. You write a method that returns an array of arrays. You annotate it with @dataProvider. You give it a name. Then you reference that name in your test. Three separate locations for one concept.
/**
* @dataProvider additionProvider
*/
public function test_addition(int $a, int $b, int $expected): void
{
$calculator = new Calculator();
$this->assertEquals($expected, $calculator->add($a, $b));
}
public static function additionProvider(): array
{
return [
[1, 1, 2],
[2, 3, 5],
[0, 0, 0],
];
}In Pest, a dataset is a function call. That is all.
it('adds two numbers', function (int $a, int $b, int $expected) {
$calculator = new Calculator();
expect($calculator->add($a, $b))->toBe($expected);
})->with([
[1, 1, 2],
[2, 3, 5],
[0, 0, 0],
]);You can also name your datasets for clearer failure messages:
])->with([
'small numbers' => [1, 1, 2],
'larger numbers' => [2, 3, 5],
'zeros' => [0, 0, 0],
]);When a test fails, Pest tells you exactly which case broke. No digging through stack traces to map array index three back to your data provider. This alone saves me hours every month.
Pest's expect() function returns an object you can chain. This is not syntactic sugar. It is a different way of thinking about assertions.
it('validates user data', function () {
$user = User::factory()->create(['age' => 25]);
expect($user)
->name->toBeString()
->email->toBeEmail()
->age->toBeInt()->toBeGreaterThan(18)
->created_at->toBeInstanceOf(Carbon::class);
});Each arrow drills into a property and asserts against it. The failure message tells you which property failed and why. In PHPUnit, you would write four separate assertions, each repeating $user-> and a method call. Here, the structure mirrors the data.
You can also chain modifiers:
expect([1, 2, 3])
->toHaveCount(3)
->each->toBeInt()
->each->toBeGreaterThan(0);This reads like English and fails like a precision instrument. I have caught more bugs with each-> than I care to admit, especially when collections mutate unexpectedly during refactors.
The pest-plugin-arch package is the secret weapon nobody talks about. It lets you enforce architectural rules as tests. Not as comments in a README. Not as code review nagging. As automated, CI-blocking tests.
arch('models do not depend on http')
->expect('App\Models')
->not->toDependOn('Illuminate\Http');
arch('controllers are thin')
->expect('App\Http\Controllers')
->toHaveSuffix('Controller')
->not->toHavePrefix('Base');
arch('no debug functions in production code')
->expect('App')
->not->toUse(['dd', 'dump', 'var_dump']);These tests run in seconds. They prevent the gradual decay that kills Laravel codebases: models reaching into the request layer, controllers ballooning with business logic, debug statements leaking to staging. I add these to every project now. They are cheaper than code review and more reliable than team discipline.
Pest plays beautifully with Laravel's testing utilities. You still get actingAs, getJson, postJson, and the full orchestra. But the syntax stays lean.
use function Pest\Laravel\getJson;
use function Pest\Laravel\postJson;
it('returns a list of articles', function () {
Article::factory()->count(3)->create();
getJson('/api/articles')
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug', 'published_at']
]
]);
});
it('creates an article with valid data', function () {
$user = User::factory()->create();
postJson('/api/articles', [
'title' => 'Why Pest Wins',
'body' => 'Testing should not hurt.',
])
->assertCreated()
->assertJsonPath('data.title', 'Why Pest Wins');
expect(Article::where('title', 'Why Pest Wins')->exists())->toBeTrue();
});Notice the helper functions imported from Pest\Laravel. No class. No setup. Just the request, the assertion, and the expectation. When I write API tests this way, I can cover every endpoint in a file that still fits on one screen.
For authentication, Pest's actingAs works exactly like Laravel's:
it('rejects unauthenticated article creation', function () {
postJson('/api/articles', ['title' => 'Nope'])
->assertUnauthorized();
});
it('allows admins to delete any article', function () {
$admin = User::factory()->admin()->create();
$article = Article::factory()->create();
actingAs($admin)
->deleteJson("/api/articles/{$article->id}")
->assertNoContent();
expect(Article::find($article->id))->toBeNull();
});The mental model is identical to Laravel's default testing. The friction is lower. That combination is dangerous in the best way.
Pest runs anywhere PHPUnit runs. It uses the same XML configuration, the same exit codes, the same JUnit output. If your pipeline already runs phpunit, swapping to Pest is a one-line change.
In GitHub Actions:
- name: Run tests
run: vendor/bin/pest --coverage --min=80Pest's output in CI is a joy. It shows a clean tree of passes and failures. It highlights slow tests. It color-codes coverage if you have Xdebug enabled. When a test fails, the error message points to the exact line in your closure, not some generated class method deep in PHPUnit internals.
I also use Pest's parallel testing flag on larger codebases:
vendor/bin/pest --parallelIt splits your test suite across CPU cores automatically. No configuration. No database partitioning headaches. It just works, and it cuts our CI time by half on a project with two thousand tests.
For coverage reporting, Pest integrates with Coveralls and Codecov out of the box:
vendor/bin/pest --coverage --coverage-clover clover.xmlIf your team is paranoid about coverage thresholds, Pest enforces them at the command line. Fail the build if coverage drops. No external services required.
I am opinionated, not delusional. There are times when PHPUnit is the right tool.
If you are maintaining a legacy package with thousands of PHPUnit tests, migrating everything to Pest is a waste of billable hours. Pest can run PHPUnit tests natively, so you can adopt it incrementally. Write new tests in Pest. Leave the old ones alone. Over time, the ratio shifts.
If you are writing a PHPUnit extension or a custom assertion library, you need to speak PHPUnit's language. Pest wraps PHPUnit, but it does not abstract every internal hook. Deep framework integration still requires understanding the underlying engine.
And if your team hates change, forcing Pest is a bad move. The best tool is the one everyone uses. But in my experience, once a team sees a Pest test side by side with its PHPUnit equivalent, the conversion happens fast.
Testing is not about coverage percentages. It is about confidence. The easier it is to write and read a test, the more confidence you will have in your code. PHPUnit gave us a solid foundation. Pest builds a house you actually want to live in.
I have not written a PHPUnit test in two years. My test suites are smaller, faster, and more comprehensive than ever. New developers on my teams understand the codebase quicker because the tests read like documentation. Senior developers catch architectural drift before it becomes technical debt.
If you are still writing $this->assertEquals, stop. Install Pest. Write one test. Feel the difference. Your future self will thank you.
composer require pestphp/pest --dev
vendor/bin/pest --initThat is all it takes. The rest is up to you.

AI Engineer & Full-Stack Tech Lead
Expertise: 20+ years full-stack development. Specializing in architecting cognitive systems, RAG architectures, and scalable web platforms for the MENA region.
Practical AI + full-stack insights for MENA builders. No spam.