Pest vs PHPUnit for Laravel teams

Run the same invoice authorization test in Pest and PHPUnit, then break the application to see what each assertion protects.
Choose from the failure, then the syntax
An invoice endpoint can return 403 after it has already changed the invoice. A test that checks only the response accepts that bug. The comparison below runs equivalent Pest and PHPUnit tests against one application, then introduces that faulty write order to demonstrate why persisted state belongs in the assertion.
This revision uses Laravel 12.69.3, Pest 4.7.8 and PHPUnit 12.5.33 on PHP 8.5.6. The downloadable fixture includes the dependency lock and an Orchestra Testbench 10.12.0 application. These are the versions executed for this comparison, not a claim that the original 2024 article ran this stack. Pest 4 uses PHPUnit 12 underneath; changing runners does not provide a different authorization mechanism.
The application boundary
The fixture exposes PATCH /api/invoices/{invoice}. An authenticated owner may change status to draft or paid. Another user receives 403, an unauthenticated request receives 401, and an owner submitting an invalid status receives 422. The controller authorizes before validating and writing. User and Invoice factories, a registered policy and an in-memory SQLite schema are included in the bundle.
Laravel’s HTTP testing methods dispatch through the application in process. They do not send a real network request, and the testing environment disables CSRF checks. actingAs injects authentication. These tests therefore demonstrate authorization and persisted state; they do not verify login, cookies, bearer tokens, browser behavior or production database concurrency.
The PHPUnit test
The class extends the fixture’s Tests\TestCase, which boots Laravel through Testbench. RefreshDatabase isolates the test database. After the forbidden response, refresh reads the invoice back from the database: the final assertion must inspect persisted state rather than the original model’s unchanged attributes.
<?php
namespace Tests\Feature;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class InvoiceAuthorizationTest extends TestCase
{
use RefreshDatabase;
public function test_another_user_cannot_mark_an_invoice_paid(): void
{
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$invoice = Invoice::factory()->for($owner, 'user')->create([
'status' => 'draft',
]);
$this->actingAs($otherUser)
->patchJson('/api/invoices/'.$invoice->getKey(), ['status' => 'paid'])
->assertForbidden();
$this->assertSame('draft', $invoice->refresh()->status);
}
}
The Pest test
The uses declaration binds this closure to the same TestCase and database trait. It sends the same request and asserts the same persisted value. In this fixture the declaration lives in this file; duplicating a directory-wide binding in tests/Pest.php is unnecessary. The shorter closure syntax changes how the test is expressed, not the boundary it checks.
<?php
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
it('prevents another user from marking an invoice paid', function () {
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$invoice = Invoice::factory()->for($owner, 'user')->create([
'status' => 'draft',
]);
$this->actingAs($otherUser)
->patchJson('/api/invoices/'.$invoice->getKey(), ['status' => 'paid'])
->assertForbidden();
expect($invoice->refresh()->status)->toBe('draft');
});
Break the application and inspect the failure
With the correct controller, both published tests pass. The runner then generates a controller with the authorization call removed. Each test fails because the response is 200 instead of 403. That negative control establishes that the forbidden-response assertion detects this specific missing check.
The second generated controller writes the submitted status before calling authorization. Both tests receive 403, so their response assertions pass. Both then fail on the database assertion: expected draft, actual paid. This is why a denied response alone was insufficient. The runner verifies the failure type and captured value difference; a syntax error or an unrelated exception does not count as detecting the defect.
Three additional cases pass: an owner can pay, a guest cannot write, and invalid input leaves the invoice unchanged. The owner case prevents an endpoint that rejects everyone from appearing correct. A separate Pest discovery run finds all five cases, including both published styles. They coexist here to demonstrate equivalence, not to increase the claimed coverage of an application.
Reproduce the result and choose a convention
Download and extract the source bundle, then run python3 frontend/scripts/verify-test-runners.py from its root with Docker available. The build installs the locked dependencies; execution uses a network-disabled container with no host database or credentials. The report records eight runner invocations, the expected failures, exact executed-source hashes and package versions. Build downloads require internet access. No timing comparison or coverage percentage was measured.
I recommend keeping PHPUnit when its class structure, extensions and diagnostics already fit the team. Pest is a reasonable choice when maintainers prefer its closure and expectation style and the dependency requirements fit. In this example both catch the same defects, so syntax preference cannot justify claiming stronger authorization coverage.
I recommend migrating one representative test first and comparing discovered cases and failure messages before converting a suite. The fixture also exposed a reporting difference: Pest’s JUnit failure text omitted the expected/actual string diff, while its console output retained it. Check the report your CI actually displays. A useful migration preserves diagnosable failures as well as green results.
Reproduce the examples
Download the complete Laravel application, both test styles, locked dependencies and negative controls. Requires Docker and Python 3; no repository account is needed.
Download the source and tests (.zip)
