Legacy refactoring isn't the same as rewriting code. Rewriting is easy to describe; refactoring is harder because the old behavior still matters. A legacy system may look messy, but it usually contains years of product decisions, bug fixes, customer exceptions, reporting dependencies, and emergency patches. Some of those decisions are documented. Many are not.
That's why refactoring legacy code is risky. You're not just asking, "Can I make this code cleaner?" You're asking, "Can I make this code cleaner without changing behavior that someone depends on?"
Claude Code can help a lot here, but only if you use it in the right order. The safe order is understand first, test second, refactor third. Not the other way around.
The Old Way: Open File, Feel Pain, Start Editing
Many legacy refactors start with frustration. You open a large controller, you see 800 lines, and you see SQL, validation, business rules, logs, API calls, and email sending packed into one method. You think, "This needs to be cleaned up." And you're right.
But if you start moving code immediately, you may break behavior you didn't even know existed. Here's a simplified example:
public function cancel(Request $request, int $subscriptionId)
{
$subscription = Subscription::findOrFail($subscriptionId);
if ($subscription->status === 'past_due') {
return response()->json([
'message' => 'Please update payment method before cancellation.',
], 422);
}
if ($subscription->plan_code === 'legacy_lifetime') {
Log::warning('Attempt to cancel legacy lifetime subscription', [
'subscription_id' => $subscription->id,
]);
return response()->json([
'message' => 'Contact support to manage this plan.',
], 422);
}
$subscription->status = 'cancelled';
$subscription->cancelled_at = now();
$subscription->save();
CancelSubscriptionJob::dispatch($subscription->id);
return response()->json(['status' => 'ok']);
}
This controller isn't beautiful, but it contains behavior:
- past-due subscriptions can't be cancelled
- legacy lifetime plans are handled manually
- cancellation updates two fields
- a job is dispatched after save
- response status codes matter
- error messages may be used by frontend clients
A careless refactor can easily change one of these. Claude can help you slow down.
Step One: Ask Claude To Read Before It Writes
Your first prompt shouldn't be "refactor this." Use this instead:
Analyze this controller action as legacy production code.
Do not refactor yet.
List all observable behaviors, side effects, response codes, database writes, dispatched jobs, and business rules.
Also list what tests should exist before refactoring.
This kind of prompt forces the model to produce a behavior map. For the example above, the behavior map may include:
Observable behaviors:
- returns 422 for past_due subscriptions
- returns 422 for legacy_lifetime plans
- returns 200 with status ok after successful cancellation
Side effects:
- writes status = cancelled
- writes cancelled_at = current timestamp
- dispatches CancelSubscriptionJob
- logs warning for legacy lifetime cancellation attempts
Risk during refactoring:
- changing response body may break frontend clients
- moving dispatch before save may change job behavior
- removing log may break operations visibility
That's the refactoring checklist. Before you touch the code, you already know what must stay true.

Step Two: Create Characterization Tests
Characterization tests describe how the system works today. They don't prove the behavior is correct or perfect; they protect the current behavior while you refactor. In legacy systems that distinction matters a lot. A characterization test essentially says: "Before I improve this code, I want proof that the important existing behavior still works after my changes."
Claude can help generate a first draft:
Write characterization tests for this cancellation action.
Use PHPUnit and Laravel feature tests.
Protect response codes, database changes, and job dispatching.
Do not assume behavior that is not visible in the code.
Possible tests:
public function test_past_due_subscription_cannot_be_cancelled(): void
{
$subscription = Subscription::factory()->create([
'status' => 'past_due',
]);
$response = $this->postJson("/subscriptions/{$subscription->id}/cancel");
$response->assertStatus(422)
->assertJson([
'message' => 'Please update payment method before cancellation.',
]);
$this->assertDatabaseHas('subscriptions', [
'id' => $subscription->id,
'status' => 'past_due',
]);
}
public function test_successful_cancellation_updates_subscription_and_dispatches_job(): void
{
Queue::fake();
$subscription = Subscription::factory()->create([
'status' => 'active',
'plan_code' => 'standard_monthly',
'cancelled_at' => null,
]);
$response = $this->postJson("/subscriptions/{$subscription->id}/cancel");
$response->assertOk()
->assertJson(['status' => 'ok']);
$this->assertDatabaseHas('subscriptions', [
'id' => $subscription->id,
'status' => 'cancelled',
]);
Queue::assertPushed(CancelSubscriptionJob::class);
}
These tests aren't fancy, and that's the point. They create a safety net. Once they pass, you can refactor with more confidence.
Step Three: Ask Claude To Identify Dependencies
Legacy code often depends on things you don't see in one file. A controller may be called by:
- web frontend
- mobile app
- admin panel
- scheduled script
- webhook retry
- internal integration
Before refactoring, ask Claude:
Find all references to this cancellation endpoint, controller method, job, and subscription status values.
List possible callers and downstream dependencies.
Do not change code.
You want to discover surprises. Maybe cancelled_at is used in revenue reports. Maybe CancelSubscriptionJob sends data to a billing provider. Maybe the frontend checks the exact error message, or mobile clients still expect a 200 response in some old flow.
Claude can help search and summarize, but you should verify the important parts manually, especially if the result affects production behavior.
Step Four: Refactor In Small Steps
After you understand the behavior and add tests, you can refactor. Small steps are safer than one giant rewrite.
Bad prompt:
Rewrite this controller using clean architecture.
Better prompt:
Refactor this controller in the smallest safe step.
Keep public behavior exactly the same.
Use the existing tests as safety checks.
Only extract cancellation decision logic into a private method first.
Do not rename routes, change responses, or change job dispatch order.
A first step might look like this:
public function cancel(Request $request, int $subscriptionId)
{
$subscription = Subscription::findOrFail($subscriptionId);
if ($response = $this->blockedCancellationResponse($subscription)) {
return $response;
}
$subscription->status = 'cancelled';
$subscription->cancelled_at = now();
$subscription->save();
CancelSubscriptionJob::dispatch($subscription->id);
return response()->json(['status' => 'ok']);
}
private function blockedCancellationResponse(Subscription $subscription): ?JsonResponse
{
if ($subscription->status === 'past_due') {
return response()->json([
'message' => 'Please update payment method before cancellation.',
], 422);
}
if ($subscription->plan_code === 'legacy_lifetime') {
Log::warning('Attempt to cancel legacy lifetime subscription', [
'subscription_id' => $subscription->id,
]);
return response()->json([
'message' => 'Contact support to manage this plan.',
], 422);
}
return null;
}
This isn't the final architecture, but it's safer. You extracted one concept without changing the external behavior. Run tests, commit, then continue.

Step Five: Compare Behavior Before And After
One of the best uses of Claude is comparing two versions of code. After a refactor, ask:
Compare the old version and new version of this controller.
Focus only on behavior changes.
Check response codes, response body, database writes, job dispatch order, logs, exceptions, and edge cases.
If behavior changed, show exactly where.
This isn't a replacement for tests. It's an extra review layer. Claude may catch things like:
Potential behavior change:
The old code logged warning before returning for legacy_lifetime plans.
The new code returns before logging.
Or:
Potential behavior change:
The old code dispatched CancelSubscriptionJob after saving the subscription.
The new code dispatches before saving.
These are exactly the small mistakes that happen during cleanup, and exactly the kind that are easy to miss in a large diff.
Use Claude To Create A Refactoring Journal
A refactoring journal is a short document that records what you learned, and it's useful for the team. Prompt:
Create a refactoring journal entry for this change.
Include:
- original problems
- behavior protected by tests
- dependencies discovered
- what changed structurally
- what did not change behaviorally
- remaining cleanup ideas
Example:
## Refactoring Journal: Subscription Cancellation
### Original problem
The cancellation controller mixed HTTP response handling, business decisions, logging, database writes, and job dispatching.
### Protected behavior
- past-due subscriptions return 422
- legacy lifetime plans return 422 and write warning log
- successful cancellation sets status and cancelled_at
- cancellation job is dispatched after save
### Structural change
Cancellation blocking logic was extracted into a dedicated method.
### Behavior intentionally unchanged
Response payloads, status codes, and job dispatch order remain unchanged.
This kind of documentation is boring today and priceless six months later.
What Claude Changes About Legacy Refactoring
Claude doesn't remove the need for engineering discipline. It makes discipline easier to apply. It helps you:
- read more code faster
- find related files
- summarize side effects
- generate characterization test drafts
- compare behavior before and after
- document what changed
- spot risky assumptions
But it can't guarantee correctness. It can miss dynamic behavior, runtime configuration, hidden database triggers, old clients, production data weirdness, and business context outside the repository.
That's why the best workflow combines AI with old-school engineering habits:
- read the code
- protect behavior with tests
- refactor in small commits
- review diffs carefully
- run the suite
- verify risky flows manually

Final Thought
Claude Code changes legacy refactoring because it gives you a faster way to understand the system. But understanding is still the goal. Don't use Claude to blindly rewrite old code; use it to investigate, explain, test, compare, and document. Then refactor slowly.
That's how you improve legacy systems without turning cleanup into a production incident.






