Testing Legacy Laravel Code Without Refactoring First

Published: (December 15, 2025 at 02:00 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

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. To make things concrete, we’ll work with an actual legacy example.

A real legacy example

Below is a simplified, but realistic controller method you might find in a 6‑year‑old Laravel codebase:

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);
}

Why this method is hard to test

  • Multiple side effects (HTTP request, email, logging)
  • Mixed concerns (stock updates, business rules, external API calls)
  • Global state (auth())
  • No dependency injection
  • Database operations tightly coupled to control flow
  • The controller does too many things

Testing without touching the code

Treat the controller method as a black box; you don’t need to test its internal structure.

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);
}

This test validates six things without modifying the original code:

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

It provides full integration coverage with minimal friction.

Faking external HTTP calls

The legacy controller calls an external endpoint when product_id === 999. Laravel’s Http::fake() makes this safe:

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

Now you can test the logic that calculates external_price:

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

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

No refactoring required.

Overriding globals

The method uses auth() directly. Laravel lets you override the authenticated user in tests:

$this->actingAs($user);

If the code relied on the current time, you can freeze it:

Date::setTestNow('2025-01-01 10:00:00');

Partial mocks for stubborn side effects

Suppose the method contains a call you cannot hit in a test:

$this->syncInventoryWithWarehouseApi($order);

You can replace just that piece with a partial mock:

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

This keeps the real controller logic while faking the problematic part.

Snapshot testing for complex responses

When the JSON response is large or contains dynamic keys, snapshot testing can simplify assertions:

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

$this->assertMatchesJsonSnapshot($response->json());

If the structure changes unintentionally, the test fails immediately.

A repeatable process for messy Laravel code

  1. Write a simple happy‑path integration test.
  2. Fake all side effects: HTTP, mail, storage, events, etc.
  3. Assert database state instead of internal details.
  4. Add at least one edge‑case test.
  5. Only after you have coverage, consider refactoring.
  6. Stabilize the system piece by piece.

You don’t need to “fix” the code to test it.

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 laying a solid foundation for tomorrow’s refactoring.

Back to Blog

Related posts

Read more »