DEV Community

Cover image for Testing Legacy Laravel Code Without Refactoring First
CodeCraft Diary
CodeCraft Diary

Posted on • Originally published at codecraftdiary.com

Testing Legacy Laravel Code Without Refactoring First

Practical Strategies for Real-World PHP Projects

Legacy codebases are a fact of life. Most of us don’t join greenfield projects. We inherit applications that grew organically over years, feature by feature, often with little test coverage and even less architectural discipline. Yet we still need tests: to avoid regressions, to refactor safely, and to deliver features with confidence.

The challenge is obvious: how do you start testing code that was never designed to be tested?

This article walks through practical, battle-tested strategies for testing legacy Laravel (and PHP) code without refactoring it first. These techniques apply directly to messy, real-world codebases. And to make things concrete, we’ll work with an actual legacy example.

A real legacy example

Before discussing strategies, let’s look at the kind of method you might find in a 6-year-old Laravel codebase.

This is a simplified, but realistic controller method I’ve seen multiple times:

public function processOrder(Request $request)
{
    $order = new Order();
    $order->product_id = $request->product_id;
    $order->quantity = $request->quantity;
    $order->user_id = auth()->id();
    $order->status = 'pending';
    $order->save();

    // business rules mixed directly inside controller
    if ($order->quantity > 10) {
        $order->priority = 'high';
    }

    if ($order->product_id === 999) {
        // fetch external pricing
        $response = Http::post('https://external-api.com/prices', [
            'product_id' => $order->product_id,
        ]);

        if ($response->ok()) {
            $order->external_price = $response->json('price');
        }
    }

    // logging inside business logic
    Log::info('Order processed', [
        'order_id' => $order->id,
        'user' => auth()->id(),
    ]);

    // send email
    Mail::to(auth()->user())->send(new OrderCreatedMail($order));

    // update stock
    $product = Product::find($order->product_id);
    $product->stock -= $order->quantity;
    $product->save();

    return response()->json([
        'id' => $order->id,
        'status' => $order->status,
        'priority' => $order->priority ?? 'normal',
    ], 201);
}

Enter fullscreen mode Exit fullscreen mode

This method has everything that makes testing painful:

  • multiple side effects (HTTP request, email, logging)
  • mixed concerns (stock updates, business rules, external API calls)
  • global state (auth())
  • no dependency injection
  • database operations coupled to control flow
  • controller does too many things

This is the type of code many developers avoid writing tests for.

But you can test it. Without touching a single line.

Step 1: Start with a high-value HTTP integration test

The trick is to treat this controller method as a black box.

You don’t test its internal structure.
You test that it behaves correctly in common scenarios.

Example test:

public function test_it_processes_a_basic_order()
{
    Mail::fake();
    Http::fake();

    $user = User::factory()->create();
    $this->actingAs($user);

    $product = Product::factory()->create(['stock' => 50]);

    $response = $this->postJson('/api/orders/process', [
        'product_id' => $product->id,
        'quantity' => 3,
    ]);

    $response->assertStatus(201);

    $this->assertDatabaseHas('orders', [
        'product_id' => $product->id,
        'quantity' => 3,
        'user_id' => $user->id,
    ]);

    $this->assertDatabaseHas('products', [
        'id' => $product->id,
        'stock' => 47,
    ]);

    Mail::assertSent(OrderCreatedMail::class);
}

Enter fullscreen mode Exit fullscreen mode

This test validates six things without even touching the original code:

  1. 1. the order is created
  2. 2. it belongs to the authenticated user
  3. 3. stock is reduced correctly
  4. 4. the app returns the correct HTTP status
  5. 5. the returned JSON contains expected fields
  6. the email was dispatched

This is full integration coverage with minimal friction.

Notice what we didn’t do:

  • we didn’t mock the controller
  • we didn’t refactor business logic
  • we didn’t rewrite anything
  • we didn’t isolate methods

The test stabilizes the behavior of the legacy code exactly as it exists today.

Step 2: Neutralize external API calls using fakes

The legacy controller calls an external endpoint when product_id === 999.
Without fakes, tests would try to hit a real API. Not acceptable.

Laravel gives you Http::fake():

Http::fake([
    'external-api.com/*' => Http::response([
        'price' => 123.45,
    ], 200),
]);

Enter fullscreen mode Exit fullscreen mode

Now you can safely test even the legacy logic that calculates external_price:

$response = $this->postJson('/api/orders/process', [
    'product_id' => 999,
    'quantity' => 1,
]);

$this->assertDatabaseHas('orders', [
    'external_price' => 123.45,
]);

Enter fullscreen mode Exit fullscreen mode

No refactoring required.
The method stays ugly, but the result is now testable.

Step 3: Control globals like time and auth

The legacy method uses_ auth()_ directly.

That’s fine: Laravel makes it easy to override in tests.

$this->actingAs($user);


Enter fullscreen mode Exit fullscreen mode

If the method used now() or time-based logic, you could simply do:

Date::setTestNow('2025-01-01 10:00:00');
Enter fullscreen mode Exit fullscreen mode

Hard-coded globals lose their power to break your tests.

Step 4: Add coverage for tricky paths with partial mocks

Imagine the method had a line like:

$this->syncInventoryWithWarehouseApi($order);
Enter fullscreen mode Exit fullscreen mode

You don’t want to hit that API, but you can’t extract it yet.

This is where partial mocks help:

$controller = Mockery::mock(OrderController::class)->makePartial();
$controller->shouldReceive('syncInventoryWithWarehouseApi')->andReturn(true);
Enter fullscreen mode Exit fullscreen mode

This allows:

  • real controller logic
  • fake behavior for specific problematic pieces

This little trick often enables testing controllers or services that would otherwise be impossible to test without rewriting them.

Step 5: Snapshot testing for big JSON structures

Imagine the legacy method returns a large JSON response with dynamic keys, nested structures, or data you don’t want to assert field-by-field.

Snapshot testing solves this:

$response = $this->postJson('/api/orders/process', [...]);

$this->assertMatchesJsonSnapshot($response->json());
Enter fullscreen mode Exit fullscreen mode

The snapshot asserts that the entire structure remains stable over time.

If someone breaks it, the test fails instantly.

A practical workflow for adding tests to legacy code

Here is a process you can apply to any messy Laravel code:

  1. Start by writing a simple happy-path integration test.
  2. Fake all side effects: HTTP, mail, storage, events.
  3. Assert database state instead of internals.
  4. Add a test for at least one edge case.
  5. Only after coverage exists, consider refactoring.
  6. Stabilize the system piece by piece.

You don’t need to “fix” the code to test it.
You only need to wrap it with reliable tests.

Final thoughts

Testing legacy Laravel code isn’t about elegance. It’s about reducing risk, increasing safety, and building confidence in code you don’t fully trust yet.

The controller shown above is messy, but even that kind of code becomes approachable once you:

  • test through HTTP
  • isolate side effects with fakes
  • control globals
  • use partial mocks when necessary
  • snapshot complex responses

These techniques let you stabilize yesterday’s code while building a foundation for tomorrow’s refactoring.

Top comments (0)