Testing complex domain workflows in Laravel gets painful fast when every test becomes a maze of mocks. The suite turns brittle, refactors become scary, and you end up “testing the mocks” instead of the behavior that matters. The fix isn’t to ban mocking entirely—it’s to be deliberate: use the real container, real database state, and only mock true boundaries.
This post walks through a pragmatic approach to Laravel testing complex domain logic with minimal mocking: how to structure code to be testable, how to choose the right test type, and how to keep tests fast and reliable while still exercising real workflows.
The core problem with over-mocking in Laravel
Over-mocking usually starts with good intentions: “unit tests should be fast,” “don’t hit the database,” “mock external services.” But in Laravel applications with non-trivial domain logic, the line between domain behavior and framework glue often gets blurred.
Why over-mocking makes tests less reliable
Common failure modes:
- False confidence: You assert that a mocked method was called, but you never validate that the system produced the correct state or output.
- Brittle refactors: Renaming a method, changing an internal collaborator, or moving logic across classes breaks tests even if behavior is unchanged.
- Unrealistic behavior: Your mock returns “happy path” values that can’t happen in production (or ignores edge cases like transactions, concurrency, serialization, timestamps).
- Inability to reproduce bugs: The bug occurred due to real database state or a subtle interaction (e.g., query scopes, constraints, event ordering). Mock-heavy tests can’t capture it.
Laravel specifically amplifies this because:
- Eloquent behavior is deeply tied to persistence, relationships, casts, mutators, and events.
- Transactions and queue dispatching change the timing and ordering of side effects.
- The service container is a runtime composition tool; mocking everything often means you never test the actual wiring.
What to mock (and what not to)
A practical rule:
- Mock network boundaries: HTTP APIs, payment gateways, email/SMS providers, LLM calls, object storage.
- Prefer fakes for Laravel-provided boundaries: Queue::fake(), Event::fake(), Mail::fake(), Http::fake(), Storage::fake().
- Avoid mocking domain collaborators that are part of the same bounded context and run in-process (e.g., pricing rules, state transitions, policy checks). Those are exactly what you want to validate.
When you do mock internal collaborators, do it for one of two reasons:
- The collaborator is slow or nondeterministic (time, randomness, external IO).
- The collaborator is not part of the behavior under test (e.g., a logger, metrics emitter).
A testing strategy that scales: “real state, minimal boundaries”
Instead of “unit vs integration” as a binary, think in layers:
- Domain-level tests: Exercise a workflow/service with real models and persistence, but fake external boundaries.
- HTTP feature tests: Validate request/response, auth, validation, and that the workflow is invoked correctly.
- Pure unit tests (few): Only for algorithmic code that’s genuinely independent of Laravel/Eloquent.
In Laravel terms, most complex business workflows are best tested as application service tests (sometimes called “use-case tests”) using:
-
RefreshDatabase(orDatabaseTransactionsin some setups) - Factories + explicit state setup
- Fakes for external boundaries
- Assertions on database state, events dispatched, jobs queued, domain invariants
Make the database your primary assertion surface
If the workflow’s purpose is to change state, assert state.
- Use
assertDatabaseHas()/assertDatabaseMissing() - Reload models (
$model->refresh()) before asserting - Assert invariants: totals, statuses, relationships, audit records
This is more robust than asserting internal method calls.
Use time control and deterministic IDs when needed
Complex workflows often depend on time.
- Use Carbon helpers:
Carbon::setTestNow() - If you use UUIDs, consider deterministic generation in tests (or assert shape rather than exact value).
Keep tests fast without mocking everything
Speed comes from:
- Efficient factories (avoid creating huge graphs by default)
- SQLite in-memory only if it matches production behavior (often it doesn’t for JSON, constraints, or concurrency)
- Running fewer, more meaningful tests: test workflows, not every private method
If you’re on MySQL/Postgres in production, prefer matching that in CI to avoid “works in SQLite” surprises.
Pattern: test the workflow service with real Eloquent + faked boundaries
A clean way to avoid over-mocking is to concentrate complexity into a workflow class (application service) that is container-resolved and uses Eloquent repositories/models.
Example domain: placing an order with:
- inventory reservation
- coupon validation
- payment authorization (external)
- order state transitions
- dispatching a confirmation email/job
Example 1: Order placement workflow test (minimal mocks)
Let’s assume you have a workflow class like:
App\Domain\Orders\PlaceOrder
It might depend on:
-
PaymentGateway(external boundary) -
InventoryService(could be internal, but still domain critical) - Eloquent models (
Order,OrderItem,Coupon,Product)
In tests, you fake the gateway and assert persisted state.
<?php
namespace Tests\Feature\Domain\Orders;
use App\Domain\Orders\PlaceOrder;
use App\Domain\Payments\PaymentGateway;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class PlaceOrderTest extends TestCase
{
use RefreshDatabase;
public function test_it_places_an_order_reserves_stock_and_queues_confirmation(): void
{
Carbon::setTestNow('2026-04-01 10:00:00');
Bus::fake();
Event::fake();
$user = User::factory()->create();
$product = Product::factory()->create([
'price_cents' => 5000,
'stock' => 10,
]);
$coupon = Coupon::factory()->create([
'code' => 'APRIL10',
'discount_percent' => 10,
'starts_at' => now()->subDay(),
'ends_at' => now()->addDay(),
]);
// Mock only the true external boundary.
$this->mock(PaymentGateway::class, function ($mock) {
$mock->shouldReceive('authorize')
->once()
->andReturn([
'provider' => 'stripe',
'authorization_id' => 'auth_123',
'status' => 'authorized',
]);
});
/** @var PlaceOrder $placeOrder */
$placeOrder = app(PlaceOrder::class);
$order = $placeOrder->handle(
user: $user,
items: [
['product_id' => $product->id, 'quantity' => 2],
],
couponCode: 'APRIL10',
);
$this->assertInstanceOf(Order::class, $order);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $user->id,
'status' => 'authorized',
'subtotal_cents' => 10000,
'discount_cents' => 1000,
'total_cents' => 9000,
'authorized_at' => '2026-04-01 10:00:00',
]);
$this->assertDatabaseHas('order_items', [
'order_id' => $order->id,
'product_id' => $product->id,
'quantity' => 2,
'unit_price_cents' => 5000,
]);
$product->refresh();
$this->assertSame(8, $product->stock);
// Assert the side effect was scheduled, not that some internal method was called.
Bus::assertDispatched(\App\Jobs\SendOrderConfirmation::class, function ($job) use ($order) {
return $job->orderId === $order->id;
});
}
}
What this test buys you:
- It validates real calculations (subtotal/discount/total)
- It validates real persistence and relationships
- It validates inventory mutation
- It validates the workflow’s contract with the external payment boundary
- It validates a meaningful side effect (job dispatched)
What it avoids:
- Mocking Eloquent models
- Mocking internal services that define the behavior under test
- Asserting method calls between internal collaborators
Tradeoffs and how to keep it maintainable
- These tests are slower than pure unit tests, but they’re usually far fewer and far more valuable.
- They require good factories and predictable defaults.
- You must be intentional about boundaries: mock the gateway, but keep the rest real.
Use transactions and outbox-like patterns to avoid flaky “half-committed” tests
Complex workflows often combine:
- database writes
- dispatching jobs/events
- external calls
The classic failure mode: you dispatch a job/event before the transaction commits, the job runs, and it can’t see the data yet (or sees partial data). Laravel has tooling for this, but your tests should enforce the behavior you want.
Prefer after-commit dispatching for jobs/events tied to persisted state
Laravel supports dispatching jobs after commit in a few ways (depending on how you dispatch).
- Jobs can implement
ShouldQueueand use$afterCommit = true; - Events/listeners can be configured to run after commit
If you don’t do this, you’ll see flaky behavior in production under concurrency even if tests pass.
Example 2: Testing after-commit dispatch behavior
Assume SendOrderConfirmation should only be queued after the order is committed.
<?php
namespace Tests\Feature\Domain\Orders;
use App\Domain\Orders\PlaceOrder;
use App\Jobs\SendOrderConfirmation;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class OrderAfterCommitDispatchTest extends TestCase
{
use RefreshDatabase;
public function test_confirmation_job_is_dispatched_only_after_commit(): void
{
Bus::fake();
$user = User::factory()->create();
$product = Product::factory()->create([
'price_cents' => 5000,
'stock' => 10,
]);
/** @var PlaceOrder $placeOrder */
$placeOrder = app(PlaceOrder::class);
DB::beginTransaction();
$order = $placeOrder->handle(
user: $user,
items: [['product_id' => $product->id, 'quantity' => 1]],
couponCode: null,
);
// If the job is configured for after-commit, it should not be visible yet.
Bus::assertNotDispatched(SendOrderConfirmation::class);
DB::commit();
Bus::assertDispatched(SendOrderConfirmation::class, function ($job) use ($order) {
return $job->orderId === $order->id;
});
}
}
This test is incredibly effective at preventing a real class of production bugs. It also demonstrates the broader theme: you’re validating observable behavior (dispatch timing) rather than internal call chains.
When faking is better than mocking
Laravel’s fakes are purpose-built to validate behavior without coupling to implementation.
- Http::fake() for external HTTP calls (and you can assert request payloads)
- Queue::fake() / Bus::fake() for async behavior
- Event::fake() for domain events
- Mail::fake() for emails
Official docs:
- https://laravel.com/docs/testing
- https://laravel.com/docs/http-client
- https://laravel.com/docs/queues
Designing code to be testable without mocks
If your code requires heavy mocking to test, it’s often a design smell. The goal isn’t “more layers,” it’s better seams.
Keep orchestration in one place
A workflow/service should orchestrate:
- loading aggregates (orders, subscriptions, invoices)
- applying domain rules
- persisting changes
- emitting events / scheduling async work
Avoid scattering the logic across controllers, model observers, random helpers, and queued jobs. Otherwise, tests need to mock half the app just to isolate behavior.
Prefer explicit dependencies over static calls
Static/facade calls are testable in Laravel, but they can hide dependencies.
- For domain logic, prefer injecting interfaces (e.g.,
PaymentGateway) and using facades mostly at the application boundary. - For time, prefer
now()/Carbon withsetTestNow()rather thantime().
Use value objects and small pure functions where it matters
Not everything needs to hit the database. The sweet spot is:
- core calculations in pure code (easy unit tests)
- persistence and orchestration tested with real DB
For example, a pricing calculator can be pure:
final class PriceBreakdown
{
public function __construct(
public readonly int $subtotalCents,
public readonly int $discountCents,
public readonly int $totalCents,
) {}
}
final class Pricing
{
public static function calculate(int $unitPriceCents, int $qty, int $discountPercent = 0): PriceBreakdown
{
$subtotal = $unitPriceCents * $qty;
$discount = (int) round($subtotal * ($discountPercent / 100));
$total = max(0, $subtotal - $discount);
return new PriceBreakdown($subtotal, $discount, $total);
}
}
You can unit test this with no Laravel at all, while still testing the full workflow end-to-end with real persistence.
Watch out for Eloquent events/observers as hidden behavior
Observers are tempting, but they create invisible side effects:
- “Saving an Order automatically recalculates totals”
- “Creating a User automatically creates a Profile”
These behaviors are hard to reason about and hard to test without coupling. If you use observers, add tests that validate the observer behavior explicitly, and keep critical workflows from relying on “magic” that triggers indirectly.
Practical heuristics: choosing the right test and avoiding brittleness
A few battle-tested heuristics for complex Laravel apps.
1) Assert outcomes, not interactions
Prefer:
assertDatabaseHasBus::assertDispatchedEvent::assertDispatchedHttp::assertSent
Avoid:
-
shouldReceive('methodX')->once()on internal collaborators unless it’s a true boundary.
2) Use factories, but don’t let them hide intent
Factories should help, but not obscure the scenario.
Bad: a UserFactory that creates 12 related models by default.
Good: defaults are minimal; relationships are opt-in.
3) Test invariants and edge cases where bugs actually happen
For domain workflows, the money is in:
- concurrency-ish issues (stock, idempotency)
- invalid state transitions
- rounding and currency math
- “already processed” / retry behavior
If your workflow supports idempotency (highly recommended for payments/webhooks), add a test that calls the workflow twice and asserts no duplicates.
4) Mock only what you can’t own
If it’s your code and it’s central to the domain, mocking it usually reduces value. If it’s a vendor system or network boundary, mocking/faking is correct.
5) Keep an eye on test runtime, but optimize the right thing
If your suite is slow:
- Reduce unnecessary factory graph creation
- Use
make()instead ofcreate()when persistence isn’t needed - Avoid hitting external services (use fakes)
- Run the heavier tests in parallel (Laravel supports parallel testing)
Official docs: https://laravel.com/docs/testing#running-tests-in-parallel
Conclusion: build fewer tests, but make them real
For complex workflows, the most maintainable Laravel test suites are the ones that validate real state and observable side effects, while mocking only true boundaries. Put orchestration into workflow services, keep calculations pure where possible, use Laravel fakes for queues/events/http, and assert on database outcomes.
If you’re starting from a mock-heavy suite, pick one critical workflow (payments, provisioning, billing, fulfillment), rewrite its tests to use real persistence + minimal boundary mocks, and measure the difference: fewer tests, more confidence, and refactors that stop being scary.
Read the full post on QCode: https://qcode.in/laravel-testing-complex-domain-logic-without-over-mocking/
Top comments (0)