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 DXdataset()+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 Covers | Tools | CI Time |
|---|---|---|---|---|
| Feature Tests | 60% | Complete user/API flows: auth, validation, permissions, business logic end-to-end | Pest + Laravel HTTP helpers + Factories | ~90s (parallel) |
| Unit Tests | 30% | Isolated complex logic: calculations, services, value objects, edge cases | Pest + Mockery (only where needed) | ~15s |
| Architecture Tests | 10% | Architecture rules: layers, dependencies, globals, naming | Pest arch() | ~5s |
| Mutation Testing | Gate | Real 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:
| Metric | Minimum | Target |
|---|---|---|
| MSI (Mutation Score Indicator) | 80% | 90% |
| Covered MSI | 70% | 80% |
| PHPStan Level | 6 | 8 |
| Feature Test Coverage | 85% | 95% (business critical) |
| Flaky Test Rate | 0% | 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):
| Metric | Before AI | With AI (My Flow) |
|---|---|---|
| Test scaffolding time | 90-120 min/feature | 10-15 min |
| Feature test coverage | 75% | 92%+ |
| Production bugs | 2-3/release | 0-1/release |
| Code review time | 45 min/PR | 18 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:
- Refactor with confidence (architecture evolves without fear)
- Deploy Fridays at 5pm (CI/CD blocks regressions)
- Onboard devs in days, not weeks (tests = living documentation)
- 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 on This Blog
- How I Do Code Review with Claude Code: Human + AI — Augmented review workflow
- Metrics a Senior Full-Stack Should Defend — Cycle Time, MTTR, Core Web Vitals, infra cost, Bus Factor
- Laravel + Vue for Distributed Teams — Inertia architecture, async workflow, onboarding
- Async-First: How I Work Remote from Barcelona — Communication, tooling, rituals
