Laravel PHPUnit Missing Mail::fake() Caused 12 Chargebacks
Laravel tests caused 12 chargebacks: no Mail::fake(), real addresses sent emails.
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Feature tests: boot full Laravel app, test HTTP endpoints end-to-end, 50-200ms each
- Unit tests: extend PHPUnit directly, no framework overhead, under 1ms each
- Database strategies: RefreshDatabase (slow, schema-precise) vs DatabaseTransactions (fast, rollback-based)
- Side-effect control: Bus::fake()
PHPUnit is the de facto testing framework for Laravel, integrated by default since Laravel 5.0. It's not optional — it's the safety net that prevents production disasters like the 12 chargebacks described in this article. Laravel extends PHPUnit with powerful test helpers like Mail::fake(), Event::fake(), and Http::fake() that intercept real side effects during tests, letting you assert that emails were sent, events were dispatched, or HTTP calls were made without actually executing them.
Skip these fakes, and your tests hit real mail servers, trigger real payments, or modify real databases — exactly the scenario that caused those chargebacks.
In the Laravel ecosystem, PHPUnit competes with Pest PHP (a syntactic sugar layer on top of PHPUnit) and, for browser-level testing, Laravel Dusk. Use PHPUnit for all backend logic: controllers, commands, middleware, and authorization policies. Reserve Dusk for end-to-end browser interactions.
The critical distinction is between feature tests (which boot the full Laravel kernel and hit your routes) and unit tests (which test isolated classes without Laravel's container). Feature tests are where Mail::fake() lives — they're your first line of defense against chargeback-causing bugs.
Laravel's test architecture includes database transactions (via DatabaseTransactions trait or RefreshDatabase), model factories for seeding test data, and a test double matrix that maps fakes (for Laravel facades), mocks (for custom classes via Mockery), and stubs (for predictable return values). The visual testing flow in this article shows exactly how a PHPUnit test boots the application, executes a request through middleware and controllers, and then tears down — all within a database transaction that rolls back after each test.
This isolation is what makes your test suite reliable and fast, preventing the kind of false positives that lead to production chargebacks.
Imagine you build a vending machine. Before shipping it to every office in the country, you run it through a checklist: does button A release a cola? Does it reject fake coins? Does it restock correctly? Laravel's PHPUnit test suite is that checklist — it lets you press every button in your app automatically, thousands of times, before a single real customer touches it. The machine doesn't care if it's Tuesday or if the factory is busy; it runs the same checks every single time.
Shipping Laravel code without a test suite is like pushing a database migration to production and hoping for the best — technically possible, professionally reckless. At scale, a single untested service class can cascade into data corruption, failed payments, or silent queue failures that nobody notices until a client calls.
The problem most teams hit isn't that they don't know PHPUnit exists — it's that their tests are brittle, slow, or don't actually prove anything useful. They mock too much, hit the real database when they shouldn't, ignore queue and event side-effects, or write assertions so loose that a broken feature still produces a green tick.
This isn't a beginner's tour. It covers structuring a real-world test suite with proper isolation strategies, mocking Eloquent and external services cleanly, faking queues and events to assert side-effects, and diagnosing the performance and reliability problems that silently rot test suites over time.
Why Laravel's Mail::fake() Is Not Optional
Laravel's Mail::fake() is a testing helper that intercepts all outgoing mail during a test, preventing actual delivery while allowing assertions on what would have been sent. Without it, every test that triggers a mail — password resets, order confirmations, invoices — hits a real or configured mail driver. In CI or local dev, that means queued jobs, network calls, and potentially real emails to real addresses. The core mechanic: Mail::fake() swaps the underlying mailer with a fake implementation that captures sent mailables in memory, so you can assert on recipients, subject lines, and content without side effects.
In practice, Mail::fake() works by calling Mail::fake() at the start of a test, which replaces the mail facade's underlying driver. After the test actions, you use Mail::assertSent() or Mail::assertNotSent() to verify behavior. The key property: assertions are O(1) lookups on the captured mailables, not network-dependent. This makes tests fast — sub-millisecond per assertion — and deterministic. Without it, a test that sends a password reset email could take seconds, fail intermittently due to SMTP timeouts, or worse, deliver real emails to test accounts that bounce or get flagged.
You must use Mail::fake() in every test that triggers mail, not just mail-specific tests. The real cost of skipping it: in production, a misconfigured test that sends real emails can cause chargebacks if your mail provider charges per recipient, or worse, deliver sensitive data to unintended addresses. A team at a SaaS company once had 12 chargebacks in a month because a test for invoice generation was hitting their real SendGrid account — each test run sent 50+ invoices to dummy addresses. The rule: if your test touches any code path that calls Mail::send(), Mail::queue(), or a mailable's ->send(), call Mail::fake() first.
Feature Tests vs Unit Tests — Choosing the Right Weapon
Laravel ships with two test base classes: Tests\TestCase (feature) and PHPUnit\Framework\TestCase (pure unit). The distinction isn't cosmetic — it determines what Laravel bootstraps, what performance you pay, and what you can actually assert.
A feature test boots the full Laravel application: service providers fire, middleware runs, the IoC container resolves real bindings, and your HTTP kernel processes the request just as Nginx would hand it off. This gives you end-to-end confidence but costs 50–200ms per test depending on your provider stack.
A pure unit test extends PHPUnit directly. No service container, no database, no config loading. It's testing a PHP class in total isolation — a domain service, a value object, a complex calculation. These run in under 1ms each and should make up the bulk of your test count.
The production gotcha most teams miss: writing 'unit tests' that extend Laravel's TestCase. They get the service container for free but pay the full bootstrap cost on every single test, making a 500-test suite crawl to 3+ minutes. Identify your pure logic classes and pull them into real unit tests. Your CI pipeline will thank you.
<?php // Pure unit test — extends PHPUnit directly, zero Laravel overhead // Use this for: domain logic, value objects, utility classes namespace Tests\Unit; use PHPUnit\Framework\TestCase; use App\Domain\Pricing\OrderCalculator; use App\Domain\Pricing\DiscountRule; class OrderCalculatorTest extends TestCase {\n private OrderCalculator $calculator;\n\n protected function setUp(): void\n {\n parent::setUp();\n // No app()->make() here — we new up the class directly.\n // This forces good design: if you can't new it up, it has too many dependencies.\n $this->calculator = new OrderCalculator();\n }\n\n /** @test */\n public function it_applies_percentage_discount_before_tax(): void\n {\n // Arrange: build a 20% discount rule\n $discountRule = new DiscountRule(type: 'percentage', value: 20.0);\n\n // Act: calculate a $100 order with 10% tax\n $result = $this->calculator->calculate(\n subtotalCents: 10000, // $100.00 in cents — always store money as integers\n taxRatePercent: 10.0,\n discountRule: $discountRule\n );\n\n // Assert: discount reduces subtotal FIRST, then tax is applied on discounted amount\n // $100 - 20% = $80, then $80 * 1.10 = $88.00\n $this->assertSame(8800, $result->totalCents); // $88.00\n $this->assertSame(2000, $result->discountCents); // $20.00 saved\n $this->assertSame(800, $result->taxCents); // tax on $80, not $100\n }\n\n /** @test */\n public function it_throws_when_discount_exceeds_subtotal(): void\n {\n $discountRule = new DiscountRule(type: 'fixed', value: 15000); // $150 off a $100 order\n\n // Expect a domain exception — don't let business rule violations become silent null returns\n $this->expectException(\\\App\\\Domain\\\Pricing\\\InvalidDiscountException::class);\n $this->expectExceptionMessage('Discount cannot exceed order subtotal');\n\n $this->calculator->calculate(\n subtotalCents: 10000,\n taxRatePercent: 10.0,\n discountRule: $discountRule\n );\n } }
php artisan test --profile and check the time per test. Anything over 20ms in a 'unit' test is almost certainly booting the framework. Extend PHPUnit\Framework\TestCase directly for pure logic — you'll cut suite runtime by 60–80% on large codebases.Visual Testing Flow — How a Laravel PHPUnit Test Executes End-to-End
Understanding the execution order of a Laravel feature test is critical to debugging failures and writing correct assertions. Many developers treat the test as a magic black box — they write code, run the test, and hope it passes. But when a test intermittently fails or produces unexpected side-effects, you need to know exactly what happens in which order.
The diagram below shows the lifecycle of a typical feature test that uses Laravel's TestCase, fakes external services, and hits an HTTP endpoint. The key takeaway: side-effect faking (Bus::fake()
php artisan test --filter=YourTest --verbose to see the exact lifecycle. The --profile flag shows time spent in each mark, helping pinpoint bottleneck steps like seeding or multiple setUp() calls.Config::set('mail.driver', 'log') in setUp() and didn't restore it. A later test that relied on Mail::fake() to intercept emails silently sent real emails via the 'log' driver. The Mail::assertQueued() assertion passed because the mail was sent, but the test intent was to assert queue dispatch, not log delivery. The fix was adding parent::tearDown() and resetting config in every base class. The visual flow made the problem obvious.Database Testing Strategies — Transactions, Factories & Isolation That Actually Works
Database testing is where most Laravel test suites quietly fall apart. The default RefreshDatabase trait drops and recreates your entire schema on every test run using migrations. That's fine locally — it takes 2–4 seconds. In CI with 300 tests, it's a minutes-long bottleneck. Worse, teams often use it without understanding what it buys them versus the DatabaseTransactions trait.
RefreshDatabase re-runs all migrations from scratch each suite run, giving you a pristine schema that mirrors production exactly. Use it when: you're testing migration correctness itself, or your schema changes frequently and you need to catch broken migrations early.
DatabaseTransactions wraps each test in an open transaction that rolls back after the test. No migrations, no table drops — just a rollback. This is 10–50x faster per test. The catch: anything that commits inside the test (think: DB::statement() with DDL, or code that opens its own connection via queue workers) won't be rolled back. External processes never see the uncommitted data.
Model factories are your best tool for readable, maintainable seed data. But advanced teams go further: they build factory states that encode real business scenarios, not just random attribute overrides. A ->suspended() state on UserFactory is infinitely more readable in a test than a raw ['status' => 'suspended', 'suspended_at' => .now()]
<?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\User; use App\Models\Subscription; use App\Models\Plan; use Laravel\Sanctum\Sanctum; // RefreshDatabase chosen here because we test a migration-dependent JSON column // For simpler CRUD tests, swap this for DatabaseTransactions for a speed boost class SubscriptionFeatureTest extends TestCase {\n use RefreshDatabase;\n\n private Plan $premiumPlan;\n\n protected function setUp(): void\n {\n parent::setUp();\n // Seed only the data EVERY test in this class needs.\n // Don't call $this->seed() blindly — it runs ALL seeders and slows everything down.\n $this->premiumPlan = Plan::factory()->premium()->create();\n }\n\n /** @test */\n public function active_subscriber_can_access_premium_content(): void\n {\n $subscriber = User::factory()\n ->withActiveSubscription($this->premiumPlan)\n ->create();\n\n Sanctum::actingAs($subscriber);\n\n $response = $this->getJson('/api/content/premium-report');\n\n $response\n ->assertOk()\n ->assertJsonStructure([\n 'data' => ['report_id', 'title', 'content', 'published_at']\n ])\n ->assertJsonPath('data.report_id', fn($id) => is_int($id) && $id > 0);\n }\n\n /** @test */\n public function suspended_subscriber_receives_payment_required_response(): void\n {\n $suspendedUser = User::factory()\n ->withActiveSubscription($this->premiumPlan)\n ->suspended()\n ->create();\n\n Sanctum::actingAs($suspendedUser);\n\n $response = $this->getJson('/api/content/premium-report');\n\n $response\n ->assertStatus(402)\n ->assertJsonPath('error.code', 'SUBSCRIPTION_SUSPENDED')\n ->assertJsonMissing(['data']);\n }\n\n /** @test */\n public function subscription_expiry_date_is_stored_as_utc(): void\n {\n $user = User::factory()->create();\n $expiresAt = now('America/New_York')->addMonth();\n\n $subscription = Subscription::factory()->create([\n 'user_id' => $user->id,\n 'plan_id' => $this->premiumPlan->id,\n 'expires_at' => $expiresAt,\n ]);\n\n $subscription->refresh();\n\n $this->assertSame(\n $expiresAt->utc()->toDateTimeString(),\n $subscription->expires_at->utc()->toDateTimeString()\n );\n }\n}", "output": "PHPUnit 10.5 by Sebastian Bergmann and contributors.\n\n... 3 / 3 (100%)\n\nTime: 00:02.341, Memory: 32.00 MB\n\nOK (3 tests, 9 assertions)" }, "callout": { "type": "tip", "title": "Pro Tip: assertJsonPath with Closures", "text": "Since Laravel 9, assertJsonPath() accepts a closure as its second argument. Use it to assert shape and range simultaneously — e.g., assertJsonPath('data.price', fn($p) => $p > 0 && $p < 10000) — instead of pulling the value out with ->json('data.price') and doing a separate assertion. One line, clearer intent.", "hook": "assertJsonPath with closures catches two classes of bugs in one assertion: structural (field exists) and semantic (value is in valid range). Without closures, you need two assertions and a temporary variable.", "bullets": [ "RefreshDatabase: re-runs all migrations. 2-5s overhead per suite. Use for migration testing.", "DatabaseTransactions: wraps test in rollback transaction. Microsecond overhead. Default choice.", "Factory states encode business scenarios. ->suspended() is more readable than raw attributes.", "assertJsonPath with closures: assert structure and value range in one line.", "Never call $this->seed() in every test. Use targeted factories for speed." ] }, "production_insight": "A team used RefreshDatabase for all 400 feature tests. Each test class triggered a full migration run. With 40 test classes, the suite spent 120 seconds just running migrations. Switching 35 test classes to DatabaseTransactions (they only needed clean data, not a fresh schema) dropped migration time to 6 seconds (5 classes still used RefreshDatabase for migration testing). Total suite time dropped from 8 minutes to under 2 minutes. The remaining 5 classes using RefreshDatabase were moved to a separate test group that ran only on schema-change PRs.", "key_takeaway": "DatabaseTransactions is the correct default for 90% of feature tests. Reserve RefreshDatabase for migration testing and schema-dependent edge cases. If your suite spends more than 10% of its time on migrations, you are using the wrong trait." }, { "heading": "Database Testing Strategy Comparison — RefreshDatabase, DatabaseTransactions, SQLite In-Memory", "content": "Choosing the right database isolation strategy is one of the most impactful decisions for your test suite's performance and reliability. Each strategy has specific trade-offs that affect speed, test independence, and production fidelity. Below is a comprehensive comparison of the three most common approaches in Laravel testing.\n\nThe key insight: there is no universal best choice. Your selection should depend on the test's purpose, the database driver you use, and whether tests run in parallel. Use the table below to make an informed decision for each test class, rather than blindly applying one strategy to everything.", "code": { "language": "text", "filename": "comparison.md", "code": "| Aspect | RefreshDatabase | DatabaseTransactions | SQLite In-Memory |\n|---------------------------|-------------------------------------|---------------------------------------|----------------------------------------|\n| **How it works** | Drops all tables, re-runs migrations before suite | Wraps each test in a rollback transaction | Uses :memory: SQLite database per worker |\n| **Speed per suite run** | 2–5 seconds overhead per test class | <10ms overhead per test | Instant schema creation, <1ms overhead |\n| **Migration testing** | Yes — ensures migrations work | No — relies on existing schema | No — schema built from scratch but not real MySQL/Postgres |\n| **Parallel testing safe** | Yes — each worker gets own database via ParallelTesting | Risky — workers share connection pool; transactions may deadlock | Yes — each worker gets isolated :memory: database |\n| **Staging-like schema** | Yes — exact replica of production | Yes — schema unchanged | No — SQLite dialect differences (lack of JSON, fulltext indexes) |\n| **N+1 detection support** | Yes | Yes | Yes (but SQLite may not trigger all query patterns) |\n| **Best for** | Migration checks, schema-dependent tests, data integrity | Standard CRUD tests, business logic | CI environments needing speed, small test suites |\n| **Gotcha** | Slow for many classes; CI overhead | Doesn't catch cross-connection writes (queue workers) | SQLite lacks MySQL/Postgres features — false positive/negative |\n| **When to avoid** | Every test class (overuse kills speed) | When testing commands that open separate connections | When testing SQL-specific features (JSON columns, geospatial, CTEs) |\n| **CI impact** | Each class runs migration: 40 classes = 2 minutes of migrations | No migration cost; near-zero overhead | Fastest option for CI; perfect for unit and light feature tests |", "output": "Use the table above to select the isolation strategy per test class." }, "callout": { "type": "info", "title": "Hybrid Strategy — The Goldilocks Approach", "text": "Many production teams use a hybrid: `RefreshDatabase` for a single 'migration checks' test class, `SQLite in-memory` for CI runs of pure logic and light feature tests, and `DatabaseTransactions` for full feature tests that must run against MySQL/Postgres. This gives you speed where possible and fidelity where needed.", "hook": "In CI, you can switch to SQLite in-memory by changing `DB_CONNECTION` in phpunit.xml. Just ensure you run a full MySQL test suite before deploying." }, "production_insight": "A team with a 12-minute suite switched to SQLite in-memory for all non-database-specific feature tests. They kept DatabaseTransactions for tests that used MySQL-specific features (JSON column queries, fulltext search). Suite time dropped to 45 seconds. No production bugs slipped through because the critical path tests still ran against the real MySQL driver. The SQLite fallback covered the rest with equal confidence.", "key_takeaway": "There is no one-size-fits-all database strategy. Use the comparison table to pick the right tool for each test class. Hybrid approaches give you the best of both worlds: speed and fidelity." }, { "heading": "Mocking Services, Faking Queues & Testing Side-Effects Without Pain", "content": "The single biggest source of flaky, slow tests is side-effects: emails sent, queues dispatched, Stripe charges attempted, Slack messages fired. Laravel's `Bus::fake()`, `Mail::fake()`, `Event::fake()`, and `Queue::fake()` facades exist for exactly this reason — they swap the real implementation with an in-memory spy that records everything dispatched without doing any of it.\n\nThe subtle but critical rule: call `Bus::fake()` or `Mail::fake()` *before* the code under test runs. Laravel replaces the binding in the container at that moment. Calling it after your action is an assertion-free no-op — the real job already dispatched.\n\nFor external HTTP services (payment processors, shipping APIs, OAuth providers), never let real HTTP calls happen in tests. Use `Http::fake()` to intercept Guzzle under the hood. This lets you test both happy paths and the far more important failure paths — 429 rate limit responses, 503 timeouts, malformed JSON — without needing a staging environment or real credentials.\n\nFor Eloquent models with complex relationships, prefer constructor injection over `app()->make()` in your services, then pass Mockery mocks directly. This makes the test's contract explicit: 'this service needs something that looks like a UserRepository' — not 'this service needs the real database'.", "code": { "language": "php", "filename": "tests/Feature/OrderPlacementTest.php", "code": "<?php\n\nnamespace Tests\\\Feature;\n\nuse Tests\\\TestCase;\nuse Illuminate\\\Foundation\\\Testing\\\RefreshDatabase;\nuse Illuminate\\\Support\\\Facades\\\Bus;\nuse Illuminate\\\Support\\\Facades\\\Mail;\nuse Illuminate\\\Support\\\Facades\\\Http;\nuse App\\\Models\\\User;\nuse App\\\Models\\\Product;\nuse App\\\Jobs\\\ProcessPayment;\nuse App\\\Jobs\\\ReserveInventory;\nuse App\\\Mail\\\OrderConfirmation;\n\nclass OrderPlacementTest extends TestCase\n{\n use RefreshDatabase;\n\n /** @test */\n public function placing_an_order_dispatches_payment_and_inventory_jobs_in_correct_order(): void\n {\n // CRITICAL: Fake BEFORE the action — not after.\n Bus::fake();\n Mail::fake();\n\n Http::fake([\n 'api.stripe.com/*' => Http::response([\n 'id' => 'pi_test_abc123',\n 'status' => 'succeeded',\n ], 200),\n ]);\n\n $buyer = User::factory()->withVerifiedEmail()->create();\n $laptop = Product::factory()->inStock(quantity: 5)->create(['price_cents' => 149999]);\n\n $response = $this->actingAs($buyer)->postJson('/api/orders', [\n 'items' => [\n ['product_id' => $laptop->id, 'quantity' => 1],\n ],\n 'payment_method' => 'pm_test_visa',\n ]);\n\n $response->assertCreated();\n\n Bus::assertDispatched(ProcessPayment::class, function (ProcessPayment $job) use ($buyer) {\n return $job->userId === $buyer->id && $job->paymentMethod === 'pm_test_visa';\n }); Bus::assertDispatched(ReserveInventory::class, function (ReserveInventory $job) use ($laptop) { return $job->productId === $laptop->id && $job->quantity === 1; }); Bus::assertDispatchedTimes(ProcessPayment::class, 1); Bus::assertDispatchedTimes(ReserveInventory::class, 1); Mail::assertQueued(OrderConfirmation::class, function (OrderConfirmation $mail) use ($buyer) { return $mail->hasTo($buyer->email); }); Http::assertSent(function ($request) { return str_contains($request->url(), 'api.stripe.com') && $request->data()['payment_method'] === 'pm_test_visa'; }); } /** @test */ public function order_placement_does_not_dispatch_jobs_when_stripe_returns_card_declined(): void { Bus::fake(); Mail::fake(); Http::fake([ 'api.stripe.com/*' => Http::response([ 'error' => ['code' => 'card_declined', 'message' => 'Your card was declined.'] ], 402), ]); $buyer = User::factory()->withVerifiedEmail()->create(); $laptop = Product::factory()->inStock(quantity: 5)->create(['price_cents' => 149999]); $response = $this->actingAs($buyer)->postJson('/api/orders', [\n 'items' => [['product_id' => $laptop->id, 'quantity' => 1]], 'payment_method' => 'pm_test_declined', ]); $response ->assertUnprocessable() ->assertJsonPath('error.code', 'CARD_DECLINED'); Bus::assertNothingDispatched(); Mail::assertNothingQueued(); } }
- Bus::fake(): intercepts ->dispatch(),
dispatch(), and chained/batched jobs. Rich assertion API. - Queue::fake(): intercepts raw Queue::push(). No chaining or batching support.
- Mail::fake(): intercepts all Mailable dispatches. Use assertQueued() not assertSent() for queued mail.
- Http::fake(): intercepts Guzzle calls. Define response patterns per URL glob.
- Event::fake(): intercepts all event dispatches. Use assertDispatched() to verify specific events.
Test Double Matrix — Fakes, Mocks, Stubs, and When to Use Each
Laravel developers often use the terms 'fake', 'mock', and 'stub' interchangeably, but they serve different purposes and have different assertion strengths. Understanding the test double matrix helps you choose the right tool for each situation — avoiding brittle tests or missing critical verification.
Fakes (Mail::fake
| Dimension | Fakes (Laravel built-in) | Mocks (Mockery / PHPUnit) | Stubs (PHPUnit createStub) | |------------------------------|----------------------------------------|------------------------------------------|-----------------------------------------| | **Behavior** | Lightweight in-memory implementation | Dynamic proxy that records and verifies | Returns canned responses, no verification| | **Assertion API** | Rich: assertSent, assertDispatched, assertNotSent, assertNothingSent | Expectation-based: shouldReceive, with, andReturn | No assertions on interactions | | **Best for** | Framework side-effects (mail, queue, http, storage, events) | Your own services (repositories, API clients, domain services) | Simple data injection (collections, value objects) | | **Brittleness** | Low — fakes accept any call that matches signature | Medium-High — extra method calls or different arguments cause failure | Low — only returns what you set up; extra calls ignored | | **Test isolation** | High — fakes replace container binding | High — mocks are injected manually | High — stubs are injected manually | | **Real behavior simulation** | Partial (email not sent, job not executed) | None (returns given values) | None (returns given values) | | **Example usage** | Bus::fake(); Mail::assertQueued(...) | $mock = Mockery::mock(UserRepository::class); $mock->shouldReceive('find')->once()->andReturn($user) | $stub = $this->createStub(UserRepository::class); $stub->method('find')->willReturn($user) | | **When to avoid** | When you need to control return values in a custom service | When a fake provides sufficient assertion coverage (don't over-mock framework components) | When you need to assert that a method was called with specific arguments |
Testing Artisan Commands, Middleware & Authorization Policies
Console commands, middleware, and authorization policies are three areas where coverage is thin in most codebases — and where bugs in production are disproportionately painful. A broken middleware silently lets unauthorized users through. A buggy policy throws a 500 instead of a 403. A misconfigured Artisan command corrupts scheduled data quietly at 3am.
Testing Artisan commands with $this->artisan() gives you a fluent interface to assert exit codes, output text, and even interact with command->ask() prompts. For commands with database side-effects, combine with assertDatabaseHas to verify the outcome, not just the output string.
For middleware, don't test it indirectly through 20 layers of feature test. Write a focused feature test that hits a route with the middleware applied, controls the request state (headers, session, auth), and asserts the exact HTTP outcome. Test the middleware in isolation by registering a test-only route in setUp().
Authorization policies tested through the Gate facade are fast and surgical. Use $this->actingAs($user) with $this->assertTrue(Gate::allows('update', $resource)) rather than routing through HTTP — you get the policy logic tested without the controller overhead.
<?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Gate; use App\Models\User; use App\Models\Article; class ArticlePolicyTest extends TestCase { use RefreshDatabase; /** @test */ public function author_can_update_their_own_article(): void { $author = User::factory()->create(); $article = Article::factory()->for($author, 'author')->create(); $this->actingAs($author); $this->assertTrue( Gate::allows('update', $article), 'The author should always be allowed to update their own article' ); } /** @test */ public function editor_role_can_update_any_article(): void { $editor = User::factory()->withRole('editor')->create(); $author = User::factory()->create(); $article = Article::factory()->for($author, 'author')->create(); $this->actingAs($editor); $this->assertTrue(Gate::allows('update', $article)); } /** @test */ public function random_user_cannot_update_someone_elses_article(): void { $owner = User::factory()->create(); $interloper = User::factory()->create(); $article = Article::factory()->for($owner, 'author')->create(); $this->actingAs($interloper); $this->assertFalse(Gate::allows('update', $article)); $response = $this->patchJson("/api/articles/{$article->id}", [\n 'title' => 'Hijacked Title',\n ]); $response->assertForbidden(); } /** @test */ public function it_does_not_leak_article_existence_to_unauthorized_users(): void { $owner = User::factory()->create(); $draftArticle = Article::factory()->for($owner, 'author')->draft()->create(); $visitor = User::factory()->create(); $this->actingAs($visitor); $this->getJson("/api/articles/{$draftArticle->id}")->assertNotFound(); } } namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\Subscription; class ExpireSubscriptionsCommandTest extends TestCase { use RefreshDatabase; /** @test */ public function expire_subscriptions_command_marks_overdue_subscriptions_as_expired(): void { $expiredOne = Subscription::factory()->expiredYesterday()->create(); $expiredTwo = Subscription::factory()->expiredLastWeek()->create(); $activeOne = Subscription::factory()->expiresNextMonth()->create(); $this->artisan('subscriptions:expire') ->expectsOutput('Processing expired subscriptions...') ->expectsOutput('2 subscription(s) marked as expired.') ->assertExitCode(0); $this->assertDatabaseHas('subscriptions', [\n 'id' => $expiredOne->id,\n 'status' => 'expired',\n ]); $this->assertDatabaseHas('subscriptions', [\n 'id' => $expiredTwo->id,\n 'status' => 'expired',\n ]); $this->assertDatabaseHas('subscriptions', [\n 'id' => $activeOne->id,\n 'status' => 'active',\n ]); } }
Test Parallelism, Performance Tuning & CI Pipeline Optimization
A test suite that takes 8 minutes destroys developer flow. Engineers stop running tests locally, push broken code, and rely on CI to catch failures — which now takes 8 minutes per feedback loop. The compounding effect is catastrophic: broken builds pile up, merge queues stall, and the team develops a culture of 'tests are optional'.
Laravel supports parallel testing via php artisan test --parallel. Under the hood, it uses Paratest to fork multiple PHP processes, each running a subset of test classes. The key constraint: each worker needs its own database to avoid transaction interference. Laravel's ParallelTesting facade handles this automatically for SQLite, but MySQL and PostgreSQL require manual per-worker database creation.
Beyond parallelism, the highest-impact optimizations are: converting fake-unit-tests to real unit tests (60-80% runtime reduction), swapping RefreshDatabase for DatabaseTransactions (10-50x per-test speedup), and using targeted factories instead of full seeders.
<?php namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\ParallelTesting; class TestCase extends BaseTestCase { use CreatesApplication; protected function setUp(): void { parent::setUp(); // Default: fake all side-effects in every feature test. // Individual tests can override by calling ->withoutFakes(). Bus::fake(); Mail::fake(); Event::fake(); Http::fake(); } protected function tearDown(): void { // Flush all fakes between tests to prevent pollution. // Without this, a fake registered in test A bleeds into test B. Bus::fake()->flush(); Mail::fake()->flush(); Event::fake()->flush(); Http::fake()->flush(); parent::tearDown(); } }
- Under 3 seconds: developer runs tests on every save. Maximum feedback loop.
- 3-30 seconds: developer runs tests before commit. Acceptable for pre-commit hooks.
- 30-90 seconds: developer runs tests before push. Push-based feedback loop.
- Over 5 minutes: CI only. Developers will not run locally. Expect broken builds.
- Target: full suite under 90 seconds with --parallel. Individual class under 3 seconds.
CI Pipeline Sample Configuration — GitHub Actions for Laravel PHPUnit
A well-configured CI pipeline is the difference between catching test failures in seconds and losing a day debugging environment inconsistencies. The sample GitHub Actions workflow below runs Laravel's PHPUnit suite with parallel workers, caches dependencies, and uses a dedicated MySQL service container for database testing.
- Service containers: MySQL runs as a separate service, not a locally installed database. This ensures clean state per run.
- Environment variables:
DB_CONNECTIONset tomysql,DB_HOSTpoints to the service container. Never point CI to a staging or production database. - Caching: Composer dependencies and the Laravel configuration cache are cached to speed up installs.
- Parallel testing:
--parallel --processes=4splits tests across 4 workers. Each worker needs its own database; usingParallelTestingwithcreateTestDatabasesautomatically createsmyapp_test_0,myapp_test_1, etc. - Linting and static analysis: Included as separate jobs that run in parallel with tests. Use
--fail-on-empty-test-suiteto fail the job if no tests were executed (catches filter mistakes).
The workflow is designed to be copied directly into .github/workflows/tests.yml. Adjust the PHP version, database credentials, and parallel process count to match your setup.
name: Laravel Tests on: push: branches: [main, develop] pull_request: branches: [main] jobs: laravel-tests: runs-on: ubuntu-latest services: mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: laravel_test ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.2' extensions: mbstring, bcmath, pdo_mysql, xml, ctype, json coverage: none tools: composer:v2 - name: Cache Composer dependencies uses: actions/cache@v3 with: path: vendor key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Prepare Laravel Application run: | cp .env.example .env php artisan key:generate # Use the service container MySQL echo "DB_CONNECTION=mysql" >> .env echo "DB_HOST=127.0.0.1" >> .env echo "DB_PORT=3306" >> .env echo "DB_DATABASE=laravel_test" >> .env echo "DB_USERNAME=root" >> .env echo "DB_PASSWORD=root" >> .env # Cache configuration for speed php artisan config:cache - name: Create test databases for parallel workers run: | # Each worker needs its own database. With --processes=4, we create 4 databases. for i in 0 1 2 3; do mysql -h 127.0.0.1 -u root -proot -e "CREATE DATABASE IF NOT EXISTS laravel_test_$i;" done - name: Run Migration (first worker handles schema) run: php artisan migrate --env=testing --force - name: Run PHPUnit Tests with Parallel Workers run: | php artisan test --parallel --processes=4 \n --recreate-databases \n --fail-on-empty-test-suite \n --verbose lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.2' tools: php-cs-fixer - name: Run PHP CS Fixer run: php-cs-fixer fix --dry-run --diff
--recreate-databases with per-worker databases in the CI pipeline eliminated 100% of parallel test failures. The change added 5 lines to the workflow and reduced flakiness from 5% to zero.laravel_test_0 through laravel_test_N before running tests, and use --recreate-databases to ensure clean state per run.Testing Eloquent Relationships, Scopes & Complex Queries
Eloquent relationships are the most undertested part of most Laravel applications. Teams test that an endpoint returns 200, but never verify that the loaded relationships are correct, that scopes filter properly, or that eager loading prevents N+1 queries.
Testing relationships directly through the model (not through HTTP) is faster and more precise. Assert that a belongsTo returns the correct parent, that a hasMany returns only the expected children, and that a pivot table is populated correctly for many-to-many relationships.
Scopes (global and local) are a common source of silent bugs. A global scope that filters soft-deleted records might accidentally exclude records in a report query. Test scopes by creating records that should be included and excluded, then asserting the query result set.
N+1 queries are a production performance killer that tests can catch. Laravel's withoutExceptionHandling() combined with a query counter lets you assert that a relationship is eager-loaded, not lazy-loaded.
<?php namespace Tests\Unit; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use App\Models\Order; use App\Models\OrderItem; use App\Models\Product; use App\Models\User; class OrderRelationshipTest extends TestCase { use RefreshDatabase; /** @test */ public function order_has_many_items(): void { $order = Order::factory()->create(); $items = OrderItem::factory()->count(3)->for($order)->create(); // Assert the relationship returns all items $this->assertCount(3, $order->items); $this->assertTrue($order->items->contains($items->first())); } /** @test */ public function order_belongs_to_user(): void { $user = User::factory()->create(); $order = Order::factory()->for($user)->create(); $this->assertInstanceOf(User::class, $order->user); $this->assertEquals($user->id, $order->user->id); } /** @test */ public function orders_scope_filters_by_status(): void { Order::factory()->count(3)->completed()->create(); Order::factory()->count(2)->pending()->create(); // Test the local scope directly $completed = Order::completed()->get(); $pending = Order::pending()->get(); $this->assertCount(3, $completed); $this->assertCount(2, $pending); $this->assertTrue($completed->every(fn($o) => $o->status === 'completed')); } /** @test */ public function eager_loading_prevents_n_plus_one_queries(): void { $order = Order::factory()->create(); OrderItem::factory()->count(5)->for($order)->create(); // Reset the query log DB::enableQueryLog(); // Without eager loading: 1 query for orders + 5 queries for items = 6 queries // With eager loading: 1 query for orders + 1 query for items = 2 queries $orders = Order::with('items')->get(); // Trigger relationship access to populate the query log $orders->each(fn($o) => $o->items->count()); $queryLog = DB::getQueryLog(); // Assert no more than 2 queries (orders + items) $this->assertLessThanOrEqual(2, count($queryLog), sprintf('Expected 2 queries but got %d — N+1 detected', count($queryLog)) ); DB::disableQueryLog(); } }
- Test relationships directly through the model, not through HTTP. Faster and more precise.
- Test scopes by creating included and excluded records, then asserting the result set.
- Use DB::getQueryLog() to detect N+1 queries in tests.
- Always test with ->with() eager loading to verify the fix works.
- Global scopes (like soft deletes) can silently exclude records. Test explicitly.
Configuring the Testing Environment — The One That Bites You at 2 AM
Most devs copy-paste phpunit.xml from Laravel docs and call it a day. That works until your CI pipeline fails because SQLite doesn't support some MySQL-specific JSON operator your migration uses. Or worse — your fake mail driver swallows a real email that's actually critical for an observer to fire.
The phpunit.xml file is not optional configuration. It's the contract between your test environment and every external service you mock. Every env variable you set here overrides .env during tests. That means your cache driver needs to be array or redis, not file (which causes test pollution). Your queue connection must be sync, not database — or your tests start depending on queue workers running in the background.
The non-negotiable list: set APP_ENV to testing, CACHE_STORE to array, QUEUE_CONNECTION to sync, SESSION_DRIVER to array, and MAIL_MAILER to array. BCRYPT_ROUNDS to 4 (speed matters when you're registering 300 users in a trait test). And if you use PostgreSQL in production, test with PostgreSQL in CI — SQLite in-memory is fine for unit tests but will lie to you about schema differences. Pick your poison.
// io.thecodeforge — php tutorial <php> <env name="APP_ENV" value="testing"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/> <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="PULSE_ENABLED" value="false"/> <env name="QUEUE_CONNECTION" value="sync"/> <env name="SESSION_DRIVER" value="array"/> <env name="TELESCOPE_ENABLED" value="false"/> </php>
Testing for Failure — Because Happy Paths Are for Demo Videos
Every junior writes the registration test that posts valid data and checks the response is 201. That proves the form works when nobody makes mistakes. Real production failures come from edge cases: duplicate emails, invalid UUIDs, missing optional fields, race conditions on unique constraints.
You need failure tests that cover every validation rule and every database constraint. The pattern is brutal but simple: hit the endpoint with bad data, assert the expected HTTP status (422 for validation, 403 for authorization, 404 for missing resources), and assert the exact error message structure your API returns. Don't just check status codes — validate that your JSON error responses contain the field names and messages frontend engineers are parsing.
Database constraints are the ones that bite the hardest. If you have a composite unique index on subscription_user_id and team_id, test that a duplicate insertion fails with a QueryException. Those are silent failures that can corrupt billing data. Your integration tests should throw an exception when a constraint violation happens, not silently return an error to the UI.
The rule: for every successful test case, write two failure cases. One for validation, one for business logic (e.g., user already subscribed, team already full, balance insufficient). Your test suite should feel like a paranoid security guard with a checklist.
// io.thecodeforge — php tutorial namespace Tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class UserRegistrationFailureTest extends TestCase { use RefreshDatabase; public function test_registration_fails_with_duplicate_email(): void { User::factory()->create([ 'email' => 'existing@user.com' ]); $response = $this->postJson('/api/register', [ 'name' => 'New User', 'email' => 'existing@user.com', 'password' => 'ValidPass123!', ]); $response->assertStatus(422); $response->assertJsonValidationErrors(['email']); } public function test_registration_fails_with_missing_name(): void { $response = $this->postJson('/api/register', [ 'email' => 'new@user.com', 'password' => 'ValidPass123!', ]); $response->assertStatus(422); $response->assertJsonValidationErrors(['name']); } }
Payment Emails Sent to Real Customers from CI Pipeline
- Always call Mail::fake() and Bus::fake() before the action under test — not after. The fake replaces the container binding at call time.
- Never use real email addresses in test factories. Use @example.test domains exclusively.
- Tests that only assert HTTP status codes are false safety nets. Assert side-effects explicitly.
- CI environment variables must never point to production services. Use local fakes (Mailhog, LocalStack) for all external dependencies.
- Add Mail::assertNothingSent() to negative test cases — if a failure path sends an email, something is catastrophically wrong.
php artisan test --profile --order-by=defectsphp artisan test --parallel --processes=4php artisan test --shuffle --reorder=randomphp artisan test --filter=FailingTest --verbosegrep -n 'Bus::fake' tests/Feature/YourTest.phpphp artisan test --filter=YourTest --verbosephp artisan test --parallel --processes=4 --verbosecat tests/TestCase.php | grep -i 'database'php artisan test --filter=YourTest --verbosephp artisan tinker --execute="App\Models\YourModel::latest()->first()"| Aspect | RefreshDatabase | DatabaseTransactions |
|---|---|---|
| How it works | Drops all tables and re-runs migrations before each suite run | Wraps each test in a transaction that rolls back after the test |
| Speed | Slow — 2–5s overhead per suite run, not per test | Fast — microsecond rollback per test, no schema rebuild |
| When to use | Testing migrations themselves, or when schema changes between tests | All standard CRUD and business logic tests — the default choice |
| Gotcha | Slow in CI with many test classes due to repeated migration runs | Doesn't roll back DDL statements or changes made by external processes/queue workers |
| Parallel testing safe? | Yes — each worker gets its own database via ParallelTesting::setUpProcess | Risky — parallel workers share the same connection pool, transactions can interfere |
| Seeder support | Full seeders run cleanly after fresh migration | Must re-seed manually or in setUp() since rollback wipes seeded data |
| Best practice | Use for 5-10% of test classes that need fresh schema | Use for 90-95% of test classes as the default isolation strategy |
| CI impact | Each test class triggers a full migration run. 40 classes = 120s of migrations | No migration overhead. Transaction rollback is near-instantaneous |
| File | Command / Code | Purpose |
|---|---|---|
| tests | namespace Tests\Unit; | Feature Tests vs Unit Tests |
| tests | namespace Tests\Feature; | Database Testing Strategies |
| test-double-matrix.md | | Dimension | Fakes (Laravel built-in) | Mocks ... | Test Double Matrix |
| tests | namespace Tests\Feature; | Testing Artisan Commands, Middleware & Authorization Policie |
| tests | namespace Tests; | Test Parallelism, Performance Tuning & CI Pipeline Optimizat |
| .github | name: Laravel Tests | CI Pipeline Sample Configuration |
| tests | namespace Tests\Unit; | Testing Eloquent Relationships, Scopes & Complex Queries |
| phpunit.xml | Configuring the Testing Environment | |
| UserRegistrationFailureTest.php | namespace Tests\Feature; | Testing for Failure |
Key takeaways
Interview Questions on This Topic
Frequently Asked Questions
Use Bus::fake() to prevent the outer job from actually running, then assert it was dispatched with the correct payload. To test the job's internal logic including its child dispatches, create the job instance directly and call ->handle() with mocked dependencies — Bus::fake() will still intercept any inner dispatches, letting you use Bus::assertDispatched() for child jobs too.
Use Laravel's built-in fakes (Mail::fake, Bus::fake, Http::fake) for framework-level side-effects — they're purpose-built and have the richest assertion API. Use Mockery (or PHPUnit's createMock) for your own application service classes and repositories when you want to control return values and assert specific method calls. Mixing both is normal and correct.
The three most common causes are: (1) test pollution — a test earlier in the suite modifies shared state (static properties, config values, fake timers) that bleeds into yours; run with --shuffle to expose ordering dependencies. (2) Timezone differences — CI servers often run UTC while dev machines don't; always use Carbon::setTestNow() and explicit UTC comparisons in date-sensitive tests. (3) Missing environment variables — CI doesn't have your .env.testing; make sure your CI pipeline sets APP_KEY and any required service keys explicitly.
Livewire provides a testing API via Livewire::test(ComponentClass). This boots the component in a test context, lets you call methods with ->call('methodName'), set properties with ->set('property', value), and assert rendered output with ->assertSee(). For components that dispatch events, use Event::fake() before calling Livewire::test(). For components that make HTTP calls, use Http::fake(). The key insight: Livewire tests are feature tests — they boot the framework and should use DatabaseTransactions.
Use $this->artisan('schedule:run') to trigger the scheduler in a test. Combine with RefreshDatabase to ensure a clean state. Assert both the command output and the resulting database state. For commands that should run at specific intervals, test the command logic directly with $this->artisan('your:command') and assert the outcome. Do not test the cron expression itself — that is framework responsibility.
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
That's Laravel. Mark it forged?
8 min read · try the examples if you haven't