php-testing

PHP testing patterns: PHPUnit 11 with mocks and data providers, Pest v3 with expectations and datasets, Laravel feature/HTTP tests with RefreshDatabase, Symfony WebTestCase, PHPStan static analysis, Infection mutation testing. Use when writing or reviewing PHP tests.

8 stars

Best use case

php-testing is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

PHP testing patterns: PHPUnit 11 with mocks and data providers, Pest v3 with expectations and datasets, Laravel feature/HTTP tests with RefreshDatabase, Symfony WebTestCase, PHPStan static analysis, Infection mutation testing. Use when writing or reviewing PHP tests.

Teams using php-testing should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/php-testing/SKILL.md --create-dirs "https://raw.githubusercontent.com/marvinrichter/clarc/main/skills/php-testing/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/php-testing/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How php-testing Compares

Feature / Agentphp-testingStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

PHP testing patterns: PHPUnit 11 with mocks and data providers, Pest v3 with expectations and datasets, Laravel feature/HTTP tests with RefreshDatabase, Symfony WebTestCase, PHPStan static analysis, Infection mutation testing. Use when writing or reviewing PHP tests.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# PHP Testing

## When to Activate

- Writing PHP tests with PHPUnit or Pest
- Setting up Laravel/Symfony test suites
- Configuring PHPStan for static analysis
- Adding mutation testing with Infection
- Applying TDD layer by layer: domain value objects first, then application handlers, then infrastructure repositories, then HTTP controllers
- Verifying that test assertions are meaningful (not just coverage-padding) by running Infection mutation testing with an 80% MSI gate
- Choosing between PHPUnit data providers and Pest datasets to parameterize tests across multiple input variants

---

## PHPUnit 11 — Unit Tests

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use App\Domain\Email;

class EmailTest extends TestCase
{
    #[Test]
    public function it_normalizes_email_to_lowercase(): void
    {
        $email = new Email('Alice@EXAMPLE.COM');

        $this->assertSame('alice@example.com', $email->value);
    }

    #[Test]
    public function it_throws_on_invalid_email(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Invalid email');

        new Email('not-an-email');
    }

    #[Test]
    #[DataProvider('validEmails')]
    public function it_accepts_valid_email_formats(string $input, string $expected): void
    {
        $this->assertSame($expected, (new Email($input))->value);
    }

    public static function validEmails(): array
    {
        return [
            'simple'       => ['alice@example.com', 'alice@example.com'],
            'mixed case'   => ['ALICE@EXAMPLE.COM', 'alice@example.com'],
            'subdomain'    => ['alice@mail.example.com', 'alice@mail.example.com'],
        ];
    }
}
```

### Mocking with PHPUnit

```php
<?php

declare(strict_types=1);

class RegisterUserHandlerTest extends TestCase
{
    private UserRepository $users;
    private PasswordHasher $hasher;
    private EventBus $events;
    private RegisterUserHandler $handler;

    protected function setUp(): void
    {
        $this->users   = $this->createMock(UserRepository::class);
        $this->hasher  = $this->createMock(PasswordHasher::class);
        $this->events  = $this->createMock(EventBus::class);
        $this->handler = new RegisterUserHandler($this->users, $this->hasher, $this->events);
    }

    #[Test]
    public function it_registers_a_new_user(): void
    {
        $this->users->method('findByEmail')->willReturn(null);
        $this->hasher->method('hash')->willReturn('hashed_pw');
        $this->users->expects($this->once())->method('save');
        $this->events->expects($this->once())->method('dispatch')
            ->with($this->isInstanceOf(UserRegistered::class));

        $user = $this->handler->handle(new RegisterUserCommand(
            name: 'Alice',
            email: 'alice@example.com',
            password: 'secure_password_123',
        ));

        $this->assertSame('alice@example.com', (string) $user->getEmail());
    }

    #[Test]
    public function it_throws_on_duplicate_email(): void
    {
        $this->users->method('findByEmail')->willReturn(new User());

        $this->expectException(DuplicateEmailException::class);

        $this->handler->handle(new RegisterUserCommand(
            name: 'Bob',
            email: 'existing@example.com',
            password: 'password_123',
        ));
    }
}
```

---

## Pest v3 — Expressive Syntax

```php
<?php

use App\Domain\Email;
use App\Handler\RegisterUserHandler;

// describe + it grouping
describe('Email', function () {
    it('normalizes to lowercase', function () {
        expect(new Email('ALICE@EXAMPLE.COM'))
            ->value->toBe('alice@example.com');
    });

    it('throws on invalid input', function () {
        expect(fn () => new Email('bad'))->toThrow(\InvalidArgumentException::class);
    });
});

// Dataset (Pest equivalent of DataProvider)
it('accepts valid email formats', function (string $input, string $expected) {
    expect((new Email($input))->value)->toBe($expected);
})->with([
    'simple'     => ['alice@example.com', 'alice@example.com'],
    'mixed case' => ['ALICE@EXAMPLE.COM', 'alice@example.com'],
]);

// Pest mock via mockery
it('dispatches event on registration', function () {
    $users  = mock(UserRepository::class)->allows('findByEmail')->andReturn(null)->allows('save');
    $hasher = mock(PasswordHasher::class)->allows('hash')->andReturn('hash');
    $events = mock(EventBus::class)->expects('dispatch')->once();

    $handler = new RegisterUserHandler($users, $hasher, $events);
    $handler->handle(new RegisterUserCommand('Alice', 'alice@example.com', 'pw'));
});
```

---

## Laravel Feature Tests

```php
<?php

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_create_user_returns_201(): void
    {
        $response = $this->postJson('/api/users', [
            'name'                  => 'Alice',
            'email'                 => 'alice@example.com',
            'password'              => 'super_secure_pass',
            'password_confirmation' => 'super_secure_pass',
        ]);

        $response->assertStatus(201)
            ->assertJsonPath('data.email', 'alice@example.com');

        $this->assertDatabaseHas('users', ['email' => 'alice@example.com']);
    }

    public function test_create_user_validates_email(): void
    {
        $response = $this->postJson('/api/users', [
            'name'  => 'Bob',
            'email' => 'not-an-email',
        ]);

        $response->assertUnprocessable()
            ->assertJsonValidationErrors(['email', 'password']);
    }

    public function test_duplicate_email_returns_422(): void
    {
        User::factory()->create(['email' => 'alice@example.com']);

        $this->postJson('/api/users', [
            'name'                  => 'Alice 2',
            'email'                 => 'alice@example.com',
            'password'              => 'super_secure_pass',
            'password_confirmation' => 'super_secure_pass',
        ])->assertUnprocessable();
    }
}
```

---

## Symfony WebTestCase

```php
<?php

declare(strict_types=1);

namespace Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function testRegisterUser(): void
    {
        $client = static::createClient();

        $client->request('POST', '/users', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([
            'name'     => 'Alice',
            'email'    => 'alice@example.com',
            'password' => 'super_secure_pass',
        ]));

        $this->assertResponseStatusCodeSame(201);
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame('alice@example.com', $data['email']);
    }
}
```

---

## PHPStan Static Analysis

```bash
vendor/bin/phpstan analyse src/ --level=9
```

`phpstan.neon`:

```neon
parameters:
    level: 9
    paths:
        - src
    checkMissingIterableValueType: true
    checkGenericClassInNonGenericObjectType: true
```

---

## Infection — Mutation Testing

Mutation testing verifies that tests actually catch bugs:

```bash
vendor/bin/infection --min-msi=80 --min-covered-msi=85
```

`infection.json5`:

```json
{
    "source": { "directories": ["src"] },
    "minMsi": 80,
    "minCoveredMsi": 85,
    "testFramework": "phpunit"
}
```

High MSI (Mutation Score Indicator) confirms test assertions are meaningful, not just coverage-chasing.

---

## Strategy: Layer by Layer

| Layer | Framework | Focus |
|-------|-----------|-------|
| Domain (pure PHP) | PHPUnit / Pest | Value objects, domain logic |
| Application (handlers) | PHPUnit with mocks | Command/query handlers |
| Infrastructure (DB) | TestCase + real DB | Repository implementations |
| HTTP (controllers) | Laravel HTTP tests / Symfony WebTestCase | Endpoints, validation, responses |

Related Skills

visual-testing

8
from marvinrichter/clarc

Visual Regression Testing: tool comparison (Chromatic/Percy/Playwright screenshots/BackstopJS), pixel-diff vs AI-based comparison, baseline management, flakiness strategies (masks, tolerances, waitForLoadState), CI integration with GitHub Actions, and Storybook integration.

typescript-testing

8
from marvinrichter/clarc

TypeScript testing patterns: Vitest for unit/integration, Playwright for E2E, MSW for API mocking, Testing Library for React components. Core TDD methodology for TypeScript/JavaScript projects.

swift-testing

8
from marvinrichter/clarc

Swift testing patterns: Swift Testing framework (Swift 6+), XCTest for UI tests, async/await test cases, actor testing, Combine testing, and XCUITest for UI automation. TDD for Swift/SwiftUI.

swift-protocol-di-testing

8
from marvinrichter/clarc

Protocol-based dependency injection for testable Swift code — mock file system, network, and external APIs using focused protocols and Swift Testing.

scala-testing

8
from marvinrichter/clarc

Scala testing with ScalaTest, MUnit, and ScalaCheck: FunSpec/FlatSpec test structure, property-based testing with forAll, mocking with MockitoSugar, Cats Effect testing with munit-cats-effect (runTest/IOSuite), ZIO Test, Testcontainers-Scala for database integration tests, and CI integration with sbt. Use when writing or reviewing Scala tests.

rust-testing

8
from marvinrichter/clarc

Rust testing patterns — unit tests with mockall, integration tests with sqlx transactions, HTTP handler testing (axum), benchmarks (criterion), property tests (proptest), fuzzing, and CI with cargo-nextest.

rust-testing-advanced

8
from marvinrichter/clarc

Advanced Rust testing anti-patterns and corrections — cfg(test) placement, expect() over unwrap(), mockall expectation ordering, executor mixing (#[tokio::test] vs block_on), PgPool isolation with

ruby-testing

8
from marvinrichter/clarc

RSpec testing patterns for Ruby and Rails — factories, mocks, request specs, feature specs, VCR, and SimpleCov coverage.

r-testing

8
from marvinrichter/clarc

R testing patterns: testthat 3e with expect_* assertions, snapshot testing, mocking with mockery and httptest2, covr code coverage, lintr static analysis, property-based testing with hedgehog, testing Shiny apps with shinytest2. Use when writing or reviewing R tests.

python-testing

8
from marvinrichter/clarc

Python testing strategies using pytest, TDD methodology, fixtures, mocking, and parametrization. Core testing fundamentals.

python-testing-advanced

8
from marvinrichter/clarc

Advanced Python testing — async testing with pytest-asyncio, exception/side-effect testing, test organization, common patterns (API, database, class methods), pytest configuration, and CLI reference. Extends python-testing.

load-testing

8
from marvinrichter/clarc

Load and performance testing with k6 (TypeScript/Go/any HTTP) and Locust (Python). Covers test types (smoke, load, stress, spike, soak), SLO thresholds, CI integration, and interpreting results.