Software Teams

Testing Strategies for PHP Applications: A Practical Framework

A practical testing strategy for PHP applications covering unit, integration, and feature tests with real patterns and examples.

The Testing Pyramid Is Wrong (For Most PHP Apps)

The traditional testing pyramid says: lots of unit tests, fewer integration tests, even fewer end-to-end tests. This makes sense for library code where units are well-defined. For a typical Laravel application, blindly following this pyramid leads to hundreds of unit tests for Eloquent models that test nothing useful, and too few tests that verify actual application behavior.

A more practical model for PHP web applications is the testing trophy:

  • Static analysis (the base, catching the most bugs per effort)
  • Integration tests (the bulk of your test suite)
  • Unit tests (for complex business logic)
  • End-to-end tests (a small number for critical paths)

Static Analysis: Your First Line of Defense

PHPStan or Psalm catch entire categories of bugs without executing a single test:

# Start at level 5 for existing projects, level 8 for new ones
phpstan analyse --level=5 app/ tests/

Type errors, undefined method calls, incorrect argument counts, and dead code paths are caught instantly. This eliminates the need for unit tests that merely verify method signatures and return types.

Invest in static analysis before writing more tests. The bugs-caught-per-minute ratio is unbeatable.

Unit Tests: For Complex Logic Only

Unit tests are valuable when testing code with complex business rules, calculations, or algorithms that are independent of the framework and database.

Good candidates for unit tests:

class MoneyTest extends TestCase
{
    public function test_adding_different_currencies_throws(): void
    {
        $euros = Money::EUR(1000);
        $dollars = Money::USD(500);

        $this->expectException(CurrencyMismatchException::class);
        $euros->add($dollars);
    }

    public function test_percentage_discount_rounds_correctly(): void
    {
        $price = Money::EUR(9999); // 99.99 EUR
        $discounted = $price->discount(Percentage::fromFloat(15.0));

        $this->assertEquals(Money::EUR(8499), $discounted); // 84.99 EUR
    }
}
class VatCalculatorTest extends TestCase
{
    public function test_eu_reverse_charge_for_b2b(): void
    {
        $calculator = new VatCalculator();

        $result = $calculator->calculate(
            netAmount: 10000,
            sellerCountry: 'NL',
            buyerCountry: 'DE',
            buyerVatId: 'DE123456789',
        );

        $this->assertEquals(0, $result->vatAmount);
        $this->assertTrue($result->reverseCharged);
    }
}

Poor candidates for unit tests: Eloquent models, controllers, form requests, and any class that primarily orchestrates framework features. Testing these in isolation requires so much mocking that the tests verify the mocks, not the behavior.

Integration Tests: The Backbone

Integration tests verify that your application components work together correctly. In Laravel, these hit the database, use the service container, and exercise real middleware.

Testing Actions and Services

class CreateInvoiceActionTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_invoice_with_correct_totals(): void
    {
        $customer = Customer::factory()->create();
        $product = Product::factory()->create(['price' => 5000]);

        $action = app(CreateInvoiceAction::class);

        $invoice = $action->execute($customer, [
            ['product_id' => $product->id, 'quantity' => 3],
        ]);

        $this->assertEquals(15000, $invoice->subtotal);
        $this->assertEquals(3150, $invoice->tax_amount); // 21% VAT
        $this->assertEquals(18150, $invoice->total);
        $this->assertCount(1, $invoice->lines);
    }

    public function test_fires_invoice_created_event(): void
    {
        Event::fake([InvoiceCreated::class]);

        $customer = Customer::factory()->create();
        $action = app(CreateInvoiceAction::class);
        $action->execute($customer, $this->validLineItems());

        Event::assertDispatched(InvoiceCreated::class);
    }
}

These tests verify real behavior with real database interactions. They catch bugs that unit tests with mocked repositories never would.

Testing Repositories and Query Builders

class InvoiceQueryBuilderTest extends TestCase
{
    use RefreshDatabase;

    public function test_overdue_scope_returns_unpaid_past_due(): void
    {
        $overdue = Invoice::factory()->create([
            'due_date' => now()->subDays(5),
            'paid_at' => null,
        ]);

        $paid = Invoice::factory()->create([
            'due_date' => now()->subDays(5),
            'paid_at' => now()->subDays(2),
        ]);

        $notYetDue = Invoice::factory()->create([
            'due_date' => now()->addDays(5),
            'paid_at' => null,
        ]);

        $results = Invoice::query()->overdue()->get();

        $this->assertCount(1, $results);
        $this->assertTrue($results->first()->is($overdue));
    }
}

Feature Tests: Full Request Lifecycle

Feature tests in Laravel send HTTP requests and assert on responses. They verify routing, middleware, validation, authorization, and response format all at once.

class InvoiceApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_list_own_invoices(): void
    {
        $user = User::factory()->create();
        Invoice::factory()->count(3)->for($user)->create();
        Invoice::factory()->count(2)->create(); // other user's invoices

        $response = $this->actingAs($user)
            ->getJson('/api/invoices');

        $response->assertOk()
            ->assertJsonCount(3, 'data');
    }

    public function test_validation_rejects_invalid_invoice(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->postJson('/api/invoices', [
                'customer_id' => 999999, // does not exist
                'lines' => [],           // empty
            ]);

        $response->assertUnprocessable()
            ->assertJsonValidationErrors(['customer_id', 'lines']);
    }

    public function test_unauthenticated_access_returns_401(): void
    {
        $this->getJson('/api/invoices')
            ->assertUnauthorized();
    }
}

Testing Patterns That Save Time

Factory States for Common Scenarios

class InvoiceFactory extends Factory
{
    public function overdue(): static
    {
        return $this->state([
            'due_date' => now()->subDays(30),
            'paid_at' => null,
        ]);
    }

    public function paid(): static
    {
        return $this->state([
            'paid_at' => now(),
        ]);
    }

    public function finalized(): static
    {
        return $this->state([
            'status' => InvoiceStatus::Finalized,
            'finalized_at' => now(),
        ]);
    }
}

Freeze Time in Date-Sensitive Tests

public function test_monthly_report_includes_current_month(): void
{
    $this->travelTo(Carbon::create(2026, 3, 15));

    Invoice::factory()->create(['created_at' => Carbon::create(2026, 3, 1)]);
    Invoice::factory()->create(['created_at' => Carbon::create(2026, 2, 28)]);

    $report = app(MonthlyReportGenerator::class)->generate();

    $this->assertCount(1, $report->invoices);
}

Mock External Services, Not Your Own Code

public function test_payment_processing_handles_gateway_failure(): void
{
    Http::fake([
        'payment-gateway.com/*' => Http::response(['error' => 'declined'], 402),
    ]);

    $result = app(ProcessPaymentAction::class)->execute($this->invoice);

    $this->assertFalse($result->successful);
    $this->assertEquals('declined', $result->failureReason);
}

Mock the boundary, not the internals. When you mock your own services, you test your mocks. When you mock external APIs, you test your integration logic.

What Not to Test

  • Framework features (Laravel's validation rules already have their own tests)
  • Getter and setter methods with no logic
  • Constructor assignment without transformation
  • Code that is already covered by static analysis type checks

Every test has a maintenance cost. Write tests that catch real bugs, not tests that make a coverage number look good.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call