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);
}
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);
}
This test validates six things without even touching 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 expected fields
- 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),
]);
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,
]);
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);
If the method used now() or time-based logic, you could simply do:
Date::setTestNow('2025-01-01 10:00:00');
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);
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);
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());
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:
- Start by writing a simple happy-path integration test.
- Fake all side effects: HTTP, mail, storage, events.
- Assert database state instead of internals.
- Add a test for at least one edge case.
- Only after coverage exists, consider refactoring.
- 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)