Source profileQuality 76/100

affaan-m/ECC/skills/laravel-tdd/SKILL.md

laravel-tdd

Laravel testing strategies with PHPUnit, Pest, model factories, HTTP tests, Sanctum authentication testing, mocking, and coverage.

Source repository stars
234,327
Declared platforms
0
Static risk flags
1
Last source update
2026-07-27
Source checked
2026-07-28

Decision brief

What it does—and where it fits

Test-driven development for Laravel applications using PHPUnit, Pest, Laravel factories, and testing helpers.

Best for

    Not for

    • Tasks that require unconfirmed production actions or broad system permissions.
    • Environments where the pinned source and install steps cannot be inspected.

    Compatibility matrix

    Platform support, with evidence labels

    PlatformStatusEvidenceWhat to check
    CodexNot declaredNo explicit evidencePortability before use
    Claude CodeNot declaredNo explicit evidencePortability before use
    CursorNot declaredNo explicit evidencePortability before use
    Gemini CLINot declaredNo explicit evidencePortability before use
    Open the compatibility checker

    Installation

    Inspect first. Install second.

    The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

    Source-detected install commandSource
    npx skills add https://github.com/affaan-m/ECC --skill "skills/laravel-tdd"
    Safe inspection promptEditorial

    Inspect the Agent Skill "laravel-tdd" from https://github.com/affaan-m/ECC/blob/4e973d3eaf92d97f8d2e2d8abb39d8bdc8711b38/skills/laravel-tdd/SKILL.md at commit 4e973d3eaf92d97f8d2e2d8abb39d8bdc8711b38. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

    Workflow

    What the source asks the agent to do

    1. 01

      TDD Workflow for Laravel

      Review the “TDD Workflow for Laravel” section in the pinned source before continuing.

      Review and apply the “TDD Workflow for Laravel” source section.
    2. 02

      Setup

      Review the “Setup” section in the pinned source before continuing.

      Review and apply the “Setup” source section.
    3. 03

      Base TestCase Setup

      Review the “Base TestCase Setup” section in the pinned source before continuing.

      Review and apply the “Base TestCase Setup” source section.
    4. 04

      When to Activate

      Writing new Laravel applications or features

      Writing new Laravel applications or featuresImplementing API endpoints with Sanctum or Passport authenticationTesting Eloquent models, relationships, scopes, and accessors
    5. 05

      Red-Green-Refactor Cycle

      Review the “Red-Green-Refactor Cycle” section in the pinned source before continuing.

      Review and apply the “Red-Green-Refactor Cycle” source section.

    Permission review

    Static risk signals and limitations

    Network access

    medium · line 36

    The documentation includes network, browsing, or remote request actions.

    <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    Evidence record

    Why each signal appears

    EvidenceSourceComputedTestedEditorial
    SignalValueEvidence typeMeaning
    Quality score76/100ComputedDocumentation, specificity, maintenance, and trust rules
    Repository stars234,327SourceRepository attention, not individual Skill quality
    Compatibility0 platformsSourceDeclared in the catalog source record
    Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

    Pinned source

    Provenance and original SKILL.md

    Repository
    affaan-m/ECC
    Skill path
    skills/laravel-tdd/SKILL.md
    Commit
    4e973d3eaf92d97f8d2e2d8abb39d8bdc8711b38
    License
    MIT
    Collected
    2026-07-28
    Default branch
    main
    View the original SKILL.md

    Laravel Testing with TDD

    Test-driven development for Laravel applications using PHPUnit, Pest, Laravel factories, and testing helpers.

    When to Activate

    • Writing new Laravel applications or features
    • Implementing API endpoints with Sanctum or Passport authentication
    • Testing Eloquent models, relationships, scopes, and accessors
    • Setting up testing infrastructure for Laravel projects
    • Writing feature tests for HTTP controllers and form requests
    • Mocking external services (queues, mail, notifications, HTTP)

    TDD Workflow for Laravel

    Red-Green-Refactor Cycle

    // Step 1: RED — Write a failing test
    public function test_a_product_can_be_created(): void
    {
        $product = Product::factory()->create(['name' => 'Test Product']);
        $this->assertDatabaseHas('products', ['name' => 'Test Product']);
    }
    
    // Step 2: GREEN — Write the migration, model, and factory
    // Step 3: REFACTOR — Improve while keeping tests green
    

    Setup

    PHPUnit Configuration

    <?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">
        <testsuites>
            <testsuite name="Unit">
                <directory suffix="Test.php">tests/Unit</directory>
            </testsuite>
            <testsuite name="Feature">
                <directory suffix="Test.php">tests/Feature</directory>
            </testsuite>
        </testsuites>
        <php>
            <env name="APP_ENV" value="testing"/>
            <env name="BCRYPT_ROUNDS" value="4"/>
            <env name="CACHE_STORE" value="array"/>
            <env name="DB_CONNECTION" value="sqlite"/>
            <env name="DB_DATABASE" value=":memory:"/>
            <env name="MAIL_MAILER" value="array"/>
            <env name="QUEUE_CONNECTION" value="sync"/>
            <env name="SESSION_DRIVER" value="array"/>
        </php>
    </phpunit>
    

    Base TestCase Setup

    namespace Tests;
    
    use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
    
    abstract class TestCase extends BaseTestCase
    {
        protected function setUp(): void
        {
            parent::setUp();
            // Call $this->withoutExceptionHandling() only in tests that
            // test non-HTTP exceptions; it suppresses assertStatus() etc.
        }
    
        // Helper: Authenticate and return user
        protected function actingAsUser(): mixed
        {
            $user = \App\Models\User::factory()->create();
            $this->actingAs($user);
            return $user;
        }
    
        protected function actingAsAdmin(): mixed
        {
            $admin = \App\Models\User::factory()->admin()->create();
            $this->actingAs($admin);
            return $admin;
        }
    }
    

    Model Factories

    // database/factories/UserFactory.php
    class UserFactory extends Factory
    {
        protected static ?string $password = null;
    
        public function definition(): array
        {
            return [
                'name' => fake()->name(),
                'email' => fake()->unique()->safeEmail(),
                'email_verified_at' => now(),
                'password' => static::$password ??= Hash::make('password'),
                'remember_token' => Str::random(10),
                'role' => 'user',
            ];
        }
    
        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]);
        }
    }
    
    // database/factories/ProductFactory.php
    class ProductFactory extends Factory
    {
        public function definition(): array
        {
            return [
                'name' => fake()->unique()->words(3, true),
                'slug' => fn (array $attrs) => Str::slug($attrs['name']),
                'description' => fake()->paragraph(),
                'price' => fake()->numberBetween(100, 100000),
                'stock' => fake()->numberBetween(0, 100),
                'is_active' => true,
                'user_id' => UserFactory::new(),
            ];
        }
    
        public function outOfStock(): static
        {
            return $this->state(fn (array $attributes) => ['stock' => 0]);
        }
    }
    

    Using Factories

    $user = User::factory()->create();
    $admin = User::factory()->admin()->create();
    $product = Product::factory()->create(['user_id' => $user->id]);
    $products = Product::factory()->count(10)->create();
    $draft = Product::factory()->make(); // Not persisted
    
    // With relationships
    $user = User::factory()->has(Product::factory()->count(3))->create();
    
    // Sequences
    User::factory()->count(3)->sequence(
        ['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],
    )->create();
    

    Model Testing

    namespace Tests\Unit\Models;
    
    use App\Models\User;
    use App\Models\Product;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Tests\TestCase;
    
    class UserTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_it_hides_sensitive_attributes(): void
        {
            $user = User::factory()->create();
            $this->assertArrayNotHasKey('password', $user->toArray());
        }
    
        public function test_admin_scope_returns_only_admins(): void
        {
            User::factory()->admin()->create();
            User::factory()->count(3)->create();
    
            $this->assertCount(1, User::admin()->get());
        }
    }
    
    class ProductTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_active_scope_filters_correctly(): void
        {
            Product::factory()->count(3)->create(['is_active' => true]);
            Product::factory()->count(2)->create(['is_active' => false]);
    
            $this->assertCount(3, Product::active()->get());
        }
    
        public function test_it_belongs_to_a_user(): void
        {
            $user = User::factory()->create();
            $product = Product::factory()->create(['user_id' => $user->id]);
    
            $this->assertTrue($product->user->is($user));
        }
    }
    

    Feature / HTTP Testing

    namespace Tests\Feature\Http\Controllers;
    
    use App\Models\Product;
    use App\Models\User;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Tests\TestCase;
    
    class ProductControllerTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_guests_are_redirected_to_login(): void
        {
            $this->get(route('products.create'))->assertRedirect(route('login'));
        }
    
        public function test_it_stores_a_new_product(): void
        {
            $user = User::factory()->create();
            $this->actingAs($user);
    
            $response = $this->post(route('products.store'), [
                'name' => 'New Product',
                'description' => 'Description',
                'price' => 2999,
                'stock' => 10,
            ]);
    
            $response->assertRedirect(route('products.index'));
            $this->assertDatabaseHas('products', [
                'name' => 'New Product',
                'user_id' => $user->id,
            ]);
        }
    
        public function test_it_validates_required_fields(): void
        {
            $this->actingAs(User::factory()->create());
            $this->post(route('products.store'), [])
                ->assertSessionHasErrors(['name', 'price']);
        }
    
        public function test_users_cannot_modify_others_products(): void
        {
            $owner = User::factory()->create();
            $attacker = User::factory()->create();
            $product = Product::factory()->create(['user_id' => $owner->id]);
    
            $this->actingAs($attacker)
                ->delete(route('products.destroy', $product))
                ->assertForbidden();
        }
    }
    

    JSON API Testing

    namespace Tests\Feature\Http\Controllers\Api;
    
    use App\Models\Product;
    use App\Models\User;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Tests\TestCase;
    
    class ProductApiTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_unauthenticated_requests_are_rejected(): void
        {
            $this->getJson('/api/products')->assertUnauthorized();
        }
    
        public function test_it_lists_paginated_products(): void
        {
            $user = User::factory()->create();
            Product::factory()->count(5)->create(['user_id' => $user->id]);
    
            $response = $this->actingAs($user)->getJson('/api/products');
    
            $response->assertOk();
            $response->assertJsonCount(5, 'data');
            $response->assertJsonStructure([
                'data' => [['id', 'name', 'price']],
                'meta' => ['current_page', 'last_page', 'total'],
            ]);
        }
    
        public function test_it_creates_a_product(): void
        {
            $user = User::factory()->create();
    
            $response = $this->actingAs($user)->postJson('/api/products', [
                'name' => 'API Product',
                'price' => 4999,
            ]);
    
            $response->assertCreated();
            $response->assertJsonPath('data.name', 'API Product');
        }
    
        public function test_users_cannot_delete_others_products(): void
        {
            $owner = User::factory()->create();
            $attacker = User::factory()->create();
            $product = Product::factory()->create(['user_id' => $owner->id]);
    
            $this->actingAs($attacker)
                ->deleteJson("/api/products/{$product->id}")
                ->assertForbidden();
        }
    }
    

    Sanctum API Auth Testing

    namespace Tests\Feature\Http\Controllers\Api;
    
    use App\Models\User;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Illuminate\Support\Facades\Hash;
    use Tests\TestCase;
    
    class AuthControllerTest extends TestCase
    {
        use RefreshDatabase;
    
        public function test_users_can_register(): void
        {
            $response = $this->postJson('/api/register', [
                'name' => 'Test User',
                'email' => 'test@example.com',
                'password' => 'Password123!',
                'password_confirmation' => 'Password123!',
            ]);
    
            $response->assertCreated();
            $response->assertJsonStructure(['data' => ['user', 'token']]);
        }
    
        public function test_users_can_login(): void
        {
            User::factory()->create([
                'email' => 'test@example.com',
                'password' => Hash::make('Password123!'),
            ]);
    
            $response = $this->postJson('/api/login', [
                'email' => 'test@example.com',
                'password' => 'Password123!',
            ]);
    
            $response->assertOk();
            $response->assertJsonStructure(['data' => ['token']]);
        }
    
        public function test_users_cannot_login_with_wrong_password(): void
        {
            User::factory()->create(['email' => 'test@example.com']);
    
            $this->postJson('/api/login', [
                'email' => 'test@example.com',
                'password' => 'wrong',
            ])->assertUnprocessable();
        }
    
        public function test_token_bearer_authenticates_requests(): void
        {
            $user = User::factory()->create();
            $token = $user->createToken('test')->plainTextToken;
    
            $this->withToken($token)
                ->getJson('/api/user')
                ->assertOk()
                ->assertJsonPath('data.email', $user->email);
        }
    }
    

    Mocking and Fakes

    HTTP Fake

    use Illuminate\Support\Facades\Http;
    
    public function test_it_handles_successful_payment(): void
    {
        Http::fake([
            'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),
        ]);
    
        $result = (new PaymentService())->charge(2999);
        $this->assertTrue($result->success);
    }
    
    public function test_it_handles_gateway_failure(): void
    {
        Http::fake([
            'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),
        ]);
    
        $this->expectException(PaymentFailedException::class);
        (new PaymentService())->charge(2999);
    }
    
    public function test_it_retries_on_timeout(): void
    {
        Http::fake([
            'api.stripe.com/*' => Http::sequence()
                ->pushStatus(408)
                ->pushStatus(200),
        ]);
    
        $this->assertTrue((new PaymentService())->charge(2999)->success);
    }
    

    Mail Fake

    Mail::fake();
    
    $order->sendConfirmation();
    
    Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
        return $mail->hasTo($order->user->email);
    });
    

    Notification Fake

    Notification::fake();
    
    $user->notify(new WelcomeUser());
    
    Notification::assertSentTo($user, WelcomeUser::class);
    

    Queue Fake

    Queue::fake();
    
    ProcessImage::dispatch($product);
    
    Queue::assertPushed(ProcessImage::class, function ($job) use ($product) {
        return $job->product->id === $product->id;
    });
    

    Storage Fake

    Storage::fake('public');
    
    $file = UploadedFile::fake()->image('photo.jpg', 200, 200);
    
    $response = $this->actingAs($user)->post('/avatar', [
        'avatar' => $file,
    ]);
    
    $response->assertSessionHasNoErrors();
    Storage::disk('public')->assertExists('avatars/' . $file->hashName());
    

    Event Fake

    Event::fake();
    
    $order->markAsShipped();
    
    Event::assertDispatched(OrderShipped::class, function ($event) use ($order) {
        return $event->order->id === $order->id;
    });
    

    Artisan Command Tests

    public function test_it_sends_newsletters(): void
    {
        Mail::fake();
        User::factory()->count(5)->create(['subscribed' => true]);
    
        $this->artisan('newsletter:send')
            ->expectsOutput('Sending newsletter to 5 subscribers...')
            ->assertExitCode(0);
    
        Mail::assertSent(NewsletterMail::class, 5);
    }
    
    public function test_it_handles_no_subscribers(): void
    {
        $this->artisan('newsletter:send')
            ->expectsOutput('No subscribers found.')
            ->assertExitCode(0);
    }
    

    Authorization Tests

    public function test_users_can_update_own_posts(): void
    {
        $user = User::factory()->create();
        $post = Post::factory()->create(['user_id' => $user->id]);
    
        $this->actingAs($user)
            ->put(route('posts.update', $post), ['title' => 'Updated'])
            ->assertRedirect();
    }
    
    public function test_users_cannot_update_others_posts(): void
    {
        $post = Post::factory()->create();
        $this->actingAs(User::factory()->create())
            ->put(route('posts.update', $post), ['title' => 'Hacked'])
            ->assertForbidden();
    }
    
    public function test_gate_before_grants_super_admin_full_access(): void
    {
        $super = User::factory()->create(['role' => 'super-admin']);
        $post = Post::factory()->create();
    
        $this->actingAs($super)
            ->delete(route('posts.destroy', $post))
            ->assertRedirect();
    
        $this->assertSoftDeleted($post);
    }
    

    Pest Feature Tests

    <?php
    
    use App\Models\Product;
    use App\Models\User;
    
    uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
    
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->actingAs($this->user);
    });
    
    it('lists products', function () {
        Product::factory()->count(3)->create(['user_id' => $this->user->id]);
    
        $this->get(route('products.index'))
            ->assertOk()
            ->assertViewHas('products');
    });
    
    it('creates a product with valid data', function () {
        $this->post(route('products.store'), [
            'name' => 'Test Product', 'price' => 1999,
        ])->assertRedirect();
    
        $this->assertDatabaseHas('products', ['name' => 'Test Product']);
    });
    
    it('fails validation without required fields', function () {
        $this->post(route('products.store'), [])
            ->assertSessionHasErrors(['name', 'price']);
    });
    
    it('authorizes updates', function () {
        $other = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $other->id]);
    
        $this->put(route('products.update', $product), ['name' => 'Hacked'])
            ->assertForbidden();
    });
    

    Coverage

    # PHPUnit (use clover output for CI threshold checks)
    vendor/bin/phpunit --coverage-html coverage --coverage-clover clover.xml
    
    # Pest (built-in threshold support)
    vendor/bin/pest --coverage --min=80
    

    Coverage Goals

    ComponentTarget
    Models95%+
    Actions/Services90%+
    Form Requests90%+
    Controllers85%+
    Policies95%+
    Overall80%+

    Testing Best Practices

    DO

    • Use factories over manual create() calls
    • One logical assertion per test
    • Descriptive names: test_guests_cannot_create_products
    • Test edge cases and authorization boundaries
    • Mock external services with Http::fake(), Mail::fake()
    • Use RefreshDatabase for clean state

    DON'T

    • Don't test Laravel internals (trust the framework)
    • Don't make tests dependent on each other
    • Don't over-mock — mock only service boundaries
    • Don't test private methods — test through the public interface
    • Don't couple tests to HTML structure

    Quick Reference

    PatternUsage
    RefreshDatabaseReset database between tests
    $this->actingAs($user)Authenticate as user
    $this->withToken($token)Bearer token auth for APIs
    Model::factory()->create()Create model with factory
    Model::factory()->count(5)->create()Create multiple records
    Http::fake([...])Mock HTTP calls
    Mail::fake()Trap sent mail
    Notification::fake()Trap sent notifications
    Queue::fake()Trap queued jobs
    Event::fake()Trap dispatched events
    Storage::fake('public')Trap file operations
    assertDatabaseHasAssert DB row exists
    assertSoftDeletedAssert soft-delete
    assertSessionHasErrorsAssert validation errors
    assertForbiddenAssert 403 status

    Related Skills

    • laravel-patterns — Laravel architecture, Eloquent, routing, and API patterns
    • laravel-security — Laravel authentication, authorization, and secure coding
    • tdd-workflow — The repo-wide RED -> GREEN -> REFACTOR loop
    • backend-patterns — General backend API and database patterns

    Alternatives

    Compare before choosing