Skip to main content

Laravel 11 + Pest 3: Testing Strategy That Doesn't Slow Releases (with AI)

Author
Ignacio AmatIgnacio Amat
Published
Reading Time10 min
Terminal showing successful Pest test execution in modern Laravel 11 project

In many teams, testing is seen as a “necessary evil” that slows down development. “We don’t have time for tests, we need to ship now.” As a Full Stack developer, my answer is always the same: you don’t have time NOT to test.

With Laravel 11 + Pest 3, testing has gone from a burden to a speed tool. Here’s my complete strategy (real code, metrics, and AI workflow) to maintain quality without slowing deployment.


Why Pest 3: Mindset Shift, Not Just Syntax

Pest 3 isn’t “PHPUnit with pretty syntax.” It’s a mindset shift. The functional syntax + expect() API makes tests living documentation and therefore maintainable.

// tests/Feature/Billing/SubscriptionTest.php
use App\Models\{User, Subscription, Plan};
use App\Actions\Billing\CreateSubscription;

test('allows a premium user to create monthly subscription', function () {
    $user = User::factory()->premium()->create();
    $plan = Plan::factory()->monthly()->create(['price' => 2900]); // €29.00
    
    $subscription = (new CreateSubscription())->handle(
        user: $user,
        plan: $plan,
        paymentMethodId: 'pm_test_123',
        idempotencyKey: 'idem_abc123'
    );
    
    expect($subscription)
        ->toBeInstanceOf(Subscription::class)
        ->status->toBe('active')
        ->plan_id->toBe($plan->id)
        ->ends_at->toBeNull(); // Monthly = no end
})
    ->group('billing', 'subscription', 'happy-path');

// Dataset parametrization: cards, errors, idempotency
dataset('payment_scenarios', [
    'visa_ok'           => ['visa', '4242424242424242', 'succeeded'],
    'mastercard_ok'     => ['mastercard', '5555555555554444', 'succeeded'],
    'declined_card'     => ['visa', '4000000000000002', 'declined'],
    'expired_card'      => ['visa', '4000000000000069', 'expired'],
    'insufficient_funds'=> ['visa', '4000000000009995', 'insufficient_funds'],
]);

test('handles payment scenarios correctly', function (string $brand, string $number, string $expected) {
    $user = User::factory()->create();
    $plan = Plan::factory()->monthly()->create();
    
    $result = (new CreateSubscription())->handle(
        user: $user,
        plan: $plan,
        paymentMethodId: "pm_{$brand}_{$expected}",
        idempotencyKey: "idem_{$brand}"
    );
    
    expect($result->status)->toBe($expected === 'succeeded' ? 'active' : 'failed')
        ->and($result->failure_reason ?? '')->toContain($expected);
})->with('payment_scenarios');

Key Pest 3 Advantages:

  • expect() API: fluent assertions, better DX
  • dataset() + with(): clean parametrization (real DRY)
  • group(): fast filtering (php artisan test --group=billing)
  • arch(): native architecture tests
  • Native parallel execution (--parallel --processes=4)

My 2026 Testing Pyramid (Real ROI)

Not all tests are equal. To maximize ROI of time invested:

Layer%What It CoversToolsCI Time
Feature Tests60%Complete user/API flows: auth, validation, permissions, business logic end-to-endPest + Laravel HTTP helpers + Factories~90s (parallel)
Unit Tests30%Isolated complex logic: calculations, services, value objects, edge casesPest + Mockery (only where needed)~15s
Architecture Tests10%Architecture rules: layers, dependencies, globals, namingPest arch()~5s
Mutation TestingGateReal test quality (not coverage)Infection (PHPStan + Pest)~60s (gate)

Golden rule: If a Feature Test passes, the business is safe. If only Unit Tests pass, you can have integration bugs.


1. Feature Tests (60%) — The Heart

// tests/Feature/Billing/SubscriptionTest.php
use App\Models\{User, Subscription, Plan, PaymentMethod};
use App\Actions\Billing\{CreateSubscription, CancelSubscription};
use App\Events\Billing\{SubscriptionCreated, SubscriptionCancelled};

test('create subscription: happy path with Stripe', function () {
    $user = User::factory()->withStripeCustomer()->create();
    $plan = Plan::factory()->monthly()->create(['price' => 4900]);
    $pm = PaymentMethod::factory()->for($user)->default()->create();
    
    // Arrange: idempotency key for retry safety
    $idempotencyKey = 'sub_create_' . $user->id . '_' . $plan->id . '_' . now()->format('Ym');
    
    // Act
    $subscription = (new CreateSubscription())->handle(
        user: $user,
        plan: $plan,
        paymentMethod: $pm,
        idempotencyKey: $idempotencyKey
    );
    
    // Assert: business outcome
    expect($subscription)
        ->toBeInstanceOf(Subscription::class)
        ->status->toBe('active')
        ->plan_id->toBe($plan->id)
        ->stripe_subscription_id->toStartWith('sub_')
        ->current_period_ends_at->toBeAfter(now()->addMonth());
    
    // Assert: side effects
    $this->assertDatabaseHas('stripe_events', [
        'type' => 'customer.subscription.created',
        'payload->data->object->id' => $subscription->stripe_subscription_id,
    ]);
    
    // Assert: event dispatched
    Event::assertDispatched(SubscriptionCreated::class, fn($e) => $e->subscription->id === $subscription->id);
});

test('create subscription: idempotency key prevents double charge', function () {
    $user = User::factory()->withStripeCustomer()->create();
    $plan = Plan::factory()->monthly()->create();
    $pm = PaymentMethod::factory()->for($user)->default()->create();
    $idemKey = 'idem_test_123';
    
    // First call succeeds
    $sub1 = (new CreateSubscription())->handle($user, $plan, $pm, $idemKey);
    
    // Second call with SAME key returns existing (no double charge)
    $sub2 = (new CreateSubscription())->handle($user, $plan, $pm, $idemKey);
    
    expect($sub1->id)->toBe($sub2->id)
        ->and($sub1->stripe_subscription_id)->toBe($sub2->stripe_subscription_id);
});

test('create subscription: failed validation returns 422', function () {
    $user = User::factory()->create();
    
    $response = $this->actingAs($user)
        ->postJson('/api/v1/subscriptions', [
            'plan_id' => 99999, // Not found
            'payment_method_id' => 'pm_invalid',
        ]);
    
    $response->assertStatus(422)
        ->assertJsonValidationErrors(['plan_id', 'payment_method_id']);
});

test('create subscription: unauthorized user gets 403', function () {
    $user = User::factory()->create(); // No premium role
    $plan = Plan::factory()->monthly()->create();
    
    $response = $this->actingAs($user)
        ->postJson('/api/v1/subscriptions', [
            'plan_id' => $plan->id,
            'payment_method_id' => 'pm_test',
        ]);
    
    $response->assertStatus(403);
});

2. Unit Tests (30%) — Pure Logic, No DB

// tests/Unit/Billing/SubscriptionCalculatorTest.php
use App\Services\Billing\SubscriptionCalculator;
use App\ValueObjects\Billing\ProrationResult;

test('calculates proration for mid-cycle upgrade', function () {
    $calculator = new SubscriptionCalculator();
    
    $result = $calculator->calculateProration(
        currentPlan: Plan::factory()->monthly()->create(['price' => 2900]),
        newPlan: Plan::factory()->yearly()->create(['price' => 29000]),
        currentPeriodEnd: now()->addDays(15), // Half month
        currentPrice: 2900,
        newPrice: 29000,
    );
    
    expect($result)
        ->toBeInstanceOf(ProrationResult::class)
        ->credit->toBe(1450) // 15 days * €29 = €14.50 credit
        ->charge->toBe(14500) // €290 - €14.50 = €275.50 net charge
        ->netCharge->toBe(13050);
});

test('calculate proration: downgrade mid-cycle creates credit', function () {
    $calculator = new SubscriptionCalculator();
    
    $result = $calculator->calculateProration(
        currentPlan: Plan::factory()->yearly()->create(['price' => 29000]),
        newPlan: Plan::factory()->monthly()->create(['price' => 2900]),
        currentPeriodEnd: now()->addDays(180), // 6 months remaining
        currentPrice: 29000,
        newPrice: 2900,
    );
    
    expect($result)
        ->credit->toBeGreaterThan(0)
        ->charge->toBe(0) // No immediate charge on downgrade
        ->creditNote->toBeTrue();
});

// Dataset for edge cases
dataset('proration_edge_cases', [
    'day_1_of_30'       => [1, 30, 0.967],
    'day_15_of_30'      => [15, 30, 0.5],
    'day_29_of_30'      => [29, 30, 0.033],
    'exact_half'        => [15, 30, 0.5],
]);

test('proration ratio matches expected formula', function (int $day, int $total, float $expectedRatio) {
    $ratio = SubscriptionCalculator::calculateUnusedRatio($day, $total);
    expect($ratio)->toBeApproximately($expectedRatio, 0.01);
})->with('proration_edge_cases');

3. Architecture Tests (10%) — Invisible Guardians

// tests/Architecture/ArchitectureTest.php
use Pest\Arch\Arch;

arch('layer boundaries')
    ->expect('App\Modules\*')
    ->toOnlyBeUsedIn('App\Modules\*')
    ->and('App\Shared\*')
    ->and('App\Http\*');

arch('actions in modules')
    ->expect('App\Modules\*')
    ->toOnlyBeUsedIn('App\Modules\*\Actions', 'App\Modules\*\Http\Controllers', 'App\Modules\*\Services');

arch('models only in repositories/actions')
    ->expect('App\Models')
    ->toOnlyBeUsedIn('App\Repositories', 'App\Actions', 'App\Http\Controllers');

arch('forbidden globals')
    ->expect(['dd', 'dump', 'var_dump', 'ray', 'logger'])
    ->not->toBeUsed();

arch('controllers thin')
    ->expect('App\Http\Controllers')
    ->toOnlyCall(['App\Actions', 'App\Http\Requests', 'App\Http\Resources']);

arch('no facades in domain')
    ->expect('Illuminate\Support\Facades\*')
    ->not->toBeUsedIn('App\Modules\*\Actions', 'App\Modules\*\Services', 'App\Modules\*\DTOs');

arch('events naming')
    ->expect('App\Events\*')
    ->toHaveSuffix('Created|Updated|Deleted|Completed|Failed');

arch('jobs on correct queue')
    ->expect('App\Jobs\*')
    ->toHaveProperty('queue', fn($queue) => in_array($queue, ['critical', 'default', 'low']));

Run in CI:

php artisan test --filter=Architecture --parallel
# Fails build if architecture breaks → catch early

4. Mutation Testing — The Real Quality Gate

Coverage ≠ Quality. Mutation testing = do your tests catch real bugs?

# .github/workflows/ci.yml
- name: Mutation Testing (Infection)
  if: runner.os == 'Linux'  # Only Linux for performance
  run: |
    composer install --no-dev --prefer-dist --no-progress
    php vendor/bin/infection \
      --min-msi=80 \
      --min-covered-msi=70 \
      --threads=4 \
      --show-mutations \
      --coverage=build/coverage.xml \
      --log-verbosity=all
// phpstan.neon — Complementary static analysis
parameters:
    level: 6
    paths:
        - app/
        - tests/
    excludes_analyse:
        - tests/Feature/**/*Test.php  # Tests have their own rules
    rules:
        - PHPStan\Rules\Variables\UnusedVariableRule
        - PHPStan\Rules\Methods\MethodCallOnNonObjectRule

2026 Thresholds I Enforce:

MetricMinimumTarget
MSI (Mutation Score Indicator)80%90%
Covered MSI70%80%
PHPStan Level68
Feature Test Coverage85%95% (business critical)
Flaky Test Rate0%0% (immediate quarantine)

AI-Augmented Testing: Claude Code as “Scaffolding”

Don’t let AI write your tests blindly. Use it as scaffolding — you provide the judgment.

# My real flow (8 months proven)
# 1. Write the Action/Controller logic
# 2. Prompt to Claude Code:
cat > /tmp/prompt.md << 'EOF'
Read `app/Actions/Billing/CreateSubscription.php` and generate Pest tests for:
- Happy path complete (premium user, monthly plan, payment OK)
- Failed validation (non-existent plan, invalid payment method)
- Permissions (user without premium role → 403)
- Idempotency key (same key = same subscription, no double charge)
- Edge cases: declined card, insufficient funds, expired card
- Webhook: invoice.payment_succeeded → activates subscription
- Webhook: invoice.payment_failed → marks failed + email

Patterns to follow:
- Use `dataset()` for Stripe test cards
- Use `group('billing', 'subscription')`
- Factories: User::factory()->premium(), Plan::factory()->monthly()
- Asserts: `expect()->toBe()`, `assertDatabaseHas()`, `Event::assertDispatched()`
- NO Stripe mocks (we use Stripe CLI in CI for real tests)
EOF

claude-code --prompt-file /tmp/prompt.md --output tests/Feature/Billing/SubscriptionTest.php
# 3. Review & adjust (15 min vs 2h manual)
# - Add business asserts AI doesn't know
# - Fix subtle edge cases (proration, downgrades)
# - Adjust factories to my real schema
# 4. Run: php artisan test --filter=Subscription --parallel
# 5. Mutation test: php vendor/bin/infection --min-msi=80

Measured Results (My Projects):

MetricBefore AIWith AI (My Flow)
Test scaffolding time90-120 min/feature10-15 min
Feature test coverage75%92%+
Production bugs2-3/release0-1/release
Code review time45 min/PR18 min/PR

CI/CD Pipeline: The Ultimate Truth

# .github/workflows/ci.yml
name: CI Pipeline

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

jobs:
  test:
    name: Tests (PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.3']
        laravel: ['11.*']
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: redis, pdo, pdo_mysql, mbstring, bcmath
          coverage: pcov
          tools: composer:v2
      
      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.composer/cache
            vendor
          key: ${{ runner.os }}-php-${{ hashFiles('composer.lock') }}
      
      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction
      
      - name: Cache npm
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
      
      - name: Install frontend
        run: npm ci
      
      - name: Build frontend
        run: npm run build
      
      - name: Run Pest (parallel, coverage)
        run: |
          php artisan test --parallel --coverage --min=80
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ":memory:"
          CACHE_DRIVER: array
          QUEUE_CONNECTION: sync
          SESSION_DRIVER: array
      
      - name: Static Analysis (PHPStan)
        run: php vendor/bin/phpstan analyse --memory-limit=512M
      
      - name: Code Style (Laravel Pint)
        run: ./vendor/bin/pint --test
      
      - name: Mutation Testing (Infection) - Only on main PRs
        if: github.event_name == 'pull_request' && github.base_ref == 'main'
        run: |
          php vendor/bin/infection \
            --min-msi=80 \
            --min-covered-msi=70 \
            --threads=4 \
            --show-mutations \
            --coverage=build/coverage.xml
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./build/coverage.xml
          flags: unittests

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Forge
        run: |
          curl -X POST "https://forge.laravel.com/api/v1/servers/${{ secrets.FORGE_SERVER }}/sites/${{ secrets.FORGE_SITE }}/deploy" \
            -H "Authorization: Bearer ${{ secrets.FORGE_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"commit":"${{ github.sha }}","branch":"main"}'

Conclusion: Testing = Product Advantage, Not Obstacle

Testing with Laravel 11 + Pest 3 + AI + Mutation Testing isn’t an obstacle. It’s your product’s life insurance. It lets you:

  1. Refactor with confidence (architecture evolves without fear)
  2. Deploy Fridays at 5pm (CI/CD blocks regressions)
  3. Onboard devs in days, not weeks (tests = living documentation)
  4. Sleep peacefully (mutation testing = tests that catch real bugs)

The difference: A team that “has tests” vs a team that uses testing as a real delivery advantage.


Want to Implement This in Your Team?

Available for technical consulting (2-4 weeks) or hiring as Senior who brings this workflow ready:

  • Testing architecture audit (2 days): Identify gaps, propose prioritized fixes
  • Hands-on implementation (1-2 weeks): Pest 3, Infection, AI workflow, CI/CD, quality gates
  • Realistic load testing: Simulate your traffic pattern, validate limits, document runbooks

Modalities:

  • EOR (Deel, Remote, Oyster) — core indefinite role
  • Freelance B2B (Autónomo, intra-community invoice 0% VAT) — 3-12 month projects
  • Direct Permanent — if you have Spanish entity

Current Stack: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.

View My Profile, Full Stack & Terms →


Related articles

Review my developer profile

If this article matches the kind of product work your team is facing, review my stack or professional availability.

Tell me what you need

You can reach out about a role, contract, technical collaboration, question, or general inquiry. A few lines of context are usually enough for me to reply within 24 business hours.

0/500
Availability