PHPUnit Testing: Complete PHP Guide for 2026
Complete guide to PHPUnit testing in PHP for 2026. Covers test setup, assertions, data providers, mocking, database testing, Laravel integration, and CI/CD best practices.
PHPUnit has been the standard testing framework for PHP since its creation in 2004. In 2026 it remains the backbone of PHP quality assurance across frameworks like Laravel, Symfony, and standalone applications. Whether you are writing unit tests for a utility class or integration tests against a database, PHPUnit provides the tools you need.
This guide covers everything from initial setup through advanced patterns like data providers, mocking, database testing, and Laravel-specific integrations. By the end you will have a working mental model for structuring tests in any PHP project.
Key Takeaways
- PHPUnit 11 is the current stable release and requires PHP 8.2 or higher
- Test classes extend
TestCaseand methods are prefixed withtestor annotated with#[Test] - Data providers let you run the same test logic against multiple input sets without duplicating code
- Mocking with
createMock()andcreateStub()isolates units from their dependencies - Database testing benefits from transactions that roll back after each test
- Laravel provides
RefreshDatabase, HTTP testing helpers, and factory-based seeding out of the box
Setting Up PHPUnit
Installation
Install PHPUnit via Composer. For most projects you want it as a dev dependency:
composer require --dev phpunit/phpunit ^11.0
After installation, create a phpunit.xml configuration file in your project root:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
Directory Structure
A typical PHP project organizes tests to mirror the source:
project/
src/
Calculator.php
UserService.php
tests/
Unit/
CalculatorTest.php
UserServiceTest.php
Feature/
ApiEndpointTest.php
phpunit.xml
composer.json
Running Tests
# Run all tests
./vendor/bin/phpunit
# Run a specific test file
./vendor/bin/phpunit tests/Unit/CalculatorTest.php
# Run a specific test method
./vendor/bin/phpunit --filter testAddition
# Run with coverage report
./vendor/bin/phpunit --coverage-html coverage/
Writing Your First Test Class
Every PHPUnit test class extends PHPUnit\Framework\TestCase. Test methods must be public and either start with test or use the #[Test] attribute.
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Calculator;
class CalculatorTest extends TestCase
{
private Calculator $calculator;
protected function setUp(): void
{
$this->calculator = new Calculator();
}
public function testAddition(): void
{
$result = $this->calculator->add(2, 3);
$this->assertSame(5, $result);
}
public function testDivisionByZeroThrowsException(): void
{
$this->expectException(\DivisionByZeroError::class);
$this->calculator->divide(10, 0);
}
}
Setup and Teardown
PHPUnit provides lifecycle hooks that run around each test:
setUp()runs before each test methodtearDown()runs after each test methodsetUpBeforeClass()runs once before the first test in the classtearDownAfterClass()runs once after the last test in the class
protected function setUp(): void
{
parent::setUp();
$this->connection = new DatabaseConnection('sqlite::memory:');
}
protected function tearDown(): void
{
$this->connection->close();
parent::tearDown();
}
Assertions in Depth
PHPUnit ships with dozens of assertion methods. Choosing the right one produces better error messages when tests fail.
Value Assertions
// Strict equality (type + value)
$this->assertSame(42, $result);
// Loose equality (value only)
$this->assertEquals(42, $result);
// Boolean checks
$this->assertTrue($user->isActive());
$this->assertFalse($user->isBanned());
$this->assertNull($user->deletedAt());
$this->assertNotNull($user->createdAt());
String Assertions
$this->assertStringContainsString('error', $message);
$this->assertStringStartsWith('Hello', $greeting);
$this->assertStringEndsWith('.pdf', $filename);
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $hash);
Array and Collection Assertions
$this->assertCount(3, $items);
$this->assertContains('admin', $roles);
$this->assertArrayHasKey('email', $userData);
$this->assertEmpty($errors);
Exception Assertions
public function testInvalidEmailThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid email format');
$this->expectExceptionCode(422);
new Email('not-an-email');
}
Type Assertions
$this->assertInstanceOf(User::class, $result);
$this->assertIsArray($data);
$this->assertIsString($name);
$this->assertIsInt($count);
Data Providers
Data providers eliminate duplication when you need to test the same logic with different inputs. A data provider is a public method that returns an array of arrays (or an iterator).
use PHPUnit\Framework\Attributes\DataProvider;
class EmailValidatorTest extends TestCase
{
#[DataProvider('validEmailProvider')]
public function testValidEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertTrue($validator->isValid($email));
}
public static function validEmailProvider(): array
{
return [
'standard email' => ['user@example.com'],
'with subdomain' => ['user@mail.example.com'],
'with plus alias' => ['user+tag@example.com'],
'numeric domain' => ['user@123.123.123.com'],
];
}
#[DataProvider('invalidEmailProvider')]
public function testInvalidEmails(string $email, string $expectedError): void
{
$validator = new EmailValidator();
$this->assertFalse($validator->isValid($email));
$this->assertSame($expectedError, $validator->getError());
}
public static function invalidEmailProvider(): array
{
return [
'missing at sign' => ['userexample.com', 'Missing @ symbol'],
'missing domain' => ['user@', 'Missing domain'],
'double dots' => ['user@example..com', 'Invalid domain format'],
];
}
}
Named keys in the data provider arrays make test output more readable. When a test fails, PHPUnit reports which data set caused the failure.
Combining Data Providers
You can reference multiple data providers for a single test:
#[DataProvider('positiveNumbers')]
#[DataProvider('negativeNumbers')]
public function testAbsoluteValue(int $input, int $expected): void
{
$this->assertSame($expected, abs($input));
}
public static function positiveNumbers(): array
{
return [[1, 1], [5, 5], [100, 100]];
}
public static function negativeNumbers(): array
{
return [[-1, 1], [-5, 5], [-100, 100]];
}
Mocking and Stubbing
Mocks and stubs let you isolate the class under test from its dependencies. PHPUnit includes a built-in mocking framework.
Creating Stubs
Stubs provide predetermined return values without verifying how they are called:
public function testGetUserReturnsFormattedName(): void
{
$repository = $this->createStub(UserRepository::class);
$repository->method('findById')
->willReturn(new User(name: 'Jane Doe'));
$service = new UserService($repository);
$result = $service->getDisplayName(1);
$this->assertSame('Jane Doe', $result);
}
Creating Mocks
Mocks verify that specific methods are called with expected arguments:
public function testCreateUserSendsWelcomeEmail(): void
{
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->once())
->method('send')
->with(
$this->equalTo('jane@example.com'),
$this->stringContains('Welcome')
);
$service = new UserService(mailer: $mailer);
$service->createUser('jane@example.com', 'Jane');
}
Consecutive Calls
$cache = $this->createStub(CacheInterface::class);
$cache->method('get')
->willReturnOnConsecutiveCalls(null, 'cached-value');
// First call returns null (cache miss)
// Second call returns 'cached-value' (cache hit)
Callback-Based Returns
$repository = $this->createStub(UserRepository::class);
$repository->method('findById')
->willReturnCallback(function (int $id): ?User {
return match ($id) {
1 => new User(name: 'Alice'),
2 => new User(name: 'Bob'),
default => null,
};
});
Mocking Static Methods and Final Classes
PHPUnit cannot mock static methods or final classes natively. For those cases, use wrapper interfaces or consider tools like Mockery:
composer require --dev mockery/mockery
use Mockery;
public function testWithMockery(): void
{
$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldReceive('info')
->once()
->with('User created');
$service = new UserService(logger: $logger);
$service->createUser('test@example.com');
Mockery::close();
}
Database Testing
Testing database interactions requires a strategy that balances isolation with speed. Three common approaches exist.
Approach 1: In-Memory SQLite
Fast but may miss database-specific behavior:
protected function setUp(): void
{
$this->pdo = new \PDO('sqlite::memory:');
$this->pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)');
$this->repository = new UserRepository($this->pdo);
}
Approach 2: Transaction Rollback
Uses a real database but wraps each test in a transaction:
protected function setUp(): void
{
$this->pdo = new \PDO($_ENV['TEST_DATABASE_URL']);
$this->pdo->beginTransaction();
$this->repository = new UserRepository($this->pdo);
}
protected function tearDown(): void
{
$this->pdo->rollBack();
}
Approach 3: Migrate and Seed
Reset the database for each test or test suite. Slower but most accurate:
public static function setUpBeforeClass(): void
{
shell_exec('php artisan migrate:fresh --seed --env=testing');
}
Testing Repositories
public function testFindByEmailReturnsUser(): void
{
// Arrange
$this->repository->create([
'name' => 'Jane',
'email' => 'jane@example.com',
]);
// Act
$user = $this->repository->findByEmail('jane@example.com');
// Assert
$this->assertNotNull($user);
$this->assertSame('Jane', $user->name);
}
public function testFindByEmailReturnsNullForMissing(): void
{
$user = $this->repository->findByEmail('nobody@example.com');
$this->assertNull($user);
}
Laravel Integration Testing
Laravel extends PHPUnit with testing utilities that make it simple to test controllers, middleware, queues, and more.
HTTP Tests
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserControllerTest extends TestCase
{
use RefreshDatabase;
public function testCreateUserReturns201(): void
{
$response = $this->postJson('/api/users', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => 'securepassword123',
]);
$response->assertStatus(201)
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'created_at'],
]);
$this->assertDatabaseHas('users', [
'email' => 'jane@example.com',
]);
}
public function testCreateUserValidatesEmail(): void
{
$response = $this->postJson('/api/users', [
'name' => 'Jane',
'email' => 'not-an-email',
'password' => 'securepassword123',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
}
}
Authentication in Tests
public function testOnlyAdminsCanDeleteUsers(): void
{
$admin = User::factory()->admin()->create();
$regularUser = User::factory()->create();
$target = User::factory()->create();
$this->actingAs($admin)
->deleteJson("/api/users/{$target->id}")
->assertStatus(200);
$target2 = User::factory()->create();
$this->actingAs($regularUser)
->deleteJson("/api/users/{$target2->id}")
->assertStatus(403);
}
Testing Jobs and Events
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Event;
public function testUserCreationDispatchesWelcomeJob(): void
{
Queue::fake();
$this->postJson('/api/users', [
'name' => 'Jane',
'email' => 'jane@example.com',
'password' => 'password123',
]);
Queue::assertPushed(SendWelcomeEmail::class, function ($job) {
return $job->email === 'jane@example.com';
});
}
public function testUserCreationFiresEvent(): void
{
Event::fake([UserCreated::class]);
$this->postJson('/api/users', [
'name' => 'Jane',
'email' => 'jane@example.com',
'password' => 'password123',
]);
Event::assertDispatched(UserCreated::class);
}
Testing with Factories
Laravel factories generate realistic test data:
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
'email_verified_at' => now(),
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
Code Coverage
PHPUnit integrates with Xdebug or PCOV for code coverage analysis.
Configuration
<phpunit>
<source>
<include>
<directory>src</directory>
</include>
<exclude>
<directory>src/Migrations</directory>
</exclude>
</source>
</phpunit>
Generating Reports
# HTML report
./vendor/bin/phpunit --coverage-html coverage/
# Clover XML for CI tools
./vendor/bin/phpunit --coverage-clover coverage.xml
# Text summary in terminal
./vendor/bin/phpunit --coverage-text
Coverage Attributes
PHPUnit 11 uses attributes to link tests to source code:
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Calculator::class)]
class CalculatorTest extends TestCase
{
// Tests here only count toward Calculator coverage
}
Organizing Tests for Large Projects
Test Suites
Define multiple suites in phpunit.xml:
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="E2E">
<directory>tests/E2E</directory>
</testsuite>
</testsuites>
Run a specific suite:
./vendor/bin/phpunit --testsuite Unit
Grouping Tests
use PHPUnit\Framework\Attributes\Group;
#[Group('slow')]
public function testComplexCalculation(): void
{
// ...
}
# Run only fast tests (exclude slow group)
./vendor/bin/phpunit --exclude-group slow
# Run only slow tests
./vendor/bin/phpunit --group slow
CI/CD Integration
GitHub Actions
name: PHP Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: \${{ matrix.php }}
extensions: mbstring, pdo_sqlite
coverage: pcov
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: ./vendor/bin/phpunit --coverage-clover coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
Common Anti-Patterns to Avoid
Testing implementation details. Tests should verify behavior, not internal method calls. If you refactor a class and all tests break despite identical behavior, your tests are too tightly coupled.
Excessive mocking. When a test has more mock setup than actual assertions, consider whether you are testing the wiring or the logic. Integration tests may be more valuable.
Ignoring test isolation. Tests that depend on execution order or shared state are fragile. Use setUp() to build fresh state for each test.
Not testing edge cases. Empty inputs, null values, boundary conditions, and error paths deserve dedicated tests. The happy path is only half the story.
Slow test suites. If your suite takes minutes, developers stop running it. Use in-memory databases for unit tests, reserve real databases for integration tests, and group slow tests so they can be excluded during development.
Summary
PHPUnit remains the definitive testing tool for PHP in 2026. Mastering its test lifecycle, assertion library, data providers, and mocking system gives you everything needed to build reliable PHP applications. Combine those skills with database testing strategies and framework-specific helpers like those in Laravel, and you can write test suites that catch real bugs without slowing down development.
Start with unit tests for your core logic, add integration tests for database and API layers, and use CI to run everything on every push. The investment pays off quickly in fewer production incidents and faster iteration cycles.