AI coding tools like Claude Code will happily refactor a 500-line controller into clean services in one sitting. They're fast, tireless, and confidently wrong just often enough to ruin your week. After refactoring two production Laravel apps this way — a document-tools site and a price-comparison site, both live, both without staging environments — I've settled on a workflow that lets me move fast without gambling: pin current behavior with tests first, let the AI refactor on a branch, then compare branches with the exact same Playwright suite before anything merges.
Here's the whole system.
The problem with AI refactoring isn't the code — it's the verification
When a human refactors, they carry a mental diff of what should and shouldn't change. An AI doesn't. It will "improve" a quirky method while extracting it, fix a bug you were relying on, or subtly change a response shape. The code reviews clean. The tests it wrote for its own refactor pass. And production breaks in a way nobody typed.
So the rule I work by: the AI never defines what "correct" means. The old branch does. Correctness is whatever the code did before the refactor started — bugs included — and my job is to capture that mechanically before a single line moves.
Step 0: Fix your baseline before you trust it
The first time I pointed an AI at a refactor, my test suite had 15 failing tests out of 140. My instinct said "pre-existing failures, ignore them." That instinct is poison for this workflow, because a red baseline means you can't tell AI-introduced breakage from noise.
It turned out all 15 were drift, not bugs — tests asserting old behavior that had been deliberately changed in production months earlier (routes that became admin-only, a catalog that grew from 5 to 24 entries, an endpoint intentionally made public). The code was right; the tests were stale. I fixed the tests to match shipped behavior, got to green, and only then started.
Whatever your suite looks like: get it green, or explicitly quarantine what isn't. A refactor gate is only as good as the baseline it compares against.
Step 1: Characterization tests — pin the behavior, warts and all
Before the AI touches a class, I write (or generate) golden-master tests: tests that don't assert what the code should do, only what it currently does. Feed the class synthetic fixtures, record the outputs, assert those exact outputs forever.
The critical discipline: pin the bugs too. One model class had a discount-percentage method that returned 0.0 under SQLite because of integer-truncating division. Known quirk. I pinned it in a test with a comment, rather than fixing it, because the refactor's contract is identical behavior — bug fixes are separate commits with their own review. If the AI "helpfully" fixes it mid-refactor, a test goes red and I get to decide, instead of discovering the change in production.
You don't need deep coverage everywhere. I go shallow-first: pure and self-contained public methods get golden masters up front; deep orchestration methods get them just-in-time, right before a change actually touches them.
Step 2: A Playwright smoke net over every route
Unit tests won't catch a controller that renders a view missing a variable. For that I generate a Playwright smoke suite from the router itself: a small artisan command dumps every GET route into a manifest (resolving {param} bindings to real seeded rows), and one spec walks all of them — 225 routes on one project — asserting each returns an acceptable status.
Writing this net paid for itself before the refactor even started. It immediately caught two real production bugs nobody had reported: an API querying columns that had been renamed (silently returning empty results for weeks) and an edit page that 500'd on every product. Broad and shallow beats narrow and deep when your goal is "detect any regression, anywhere."
One Playwright trick worth stealing: listen for page.on('pageerror', ...), not 'console'. Uncaught exceptions don't fire the console event, and one of the nastier bugs I found — a save button that silently did nothing — only surfaced through the pageerror listener.
Step 3: Let the AI refactor — one concern per branch
Now Claude Code gets to work, with tight scoping:
- One concern per branch/deploy. A 15-method controller with four responsibilities became four slices — items, reminders, scheduling, consent/erasure — each extracted, verified, and shipped separately. The highest-consequence concern (data erasure) went last, once the pattern was proven on low-risk slices.
- The prompt states the contract explicitly: "identical behavior; do not fix bugs you find; flag them instead." The AI found several real bugs this way — including that silent save failure — which got logged as follow-ups rather than smuggled into the refactor diff.
- Small slices also keep the AI's context small, which noticeably improves the quality of what it produces.

Step 4: Compare branches — the same suite, both sides
This is the core loop, and it's dumb on purpose:
- On
main: run the full backend suite + the Playwright smoke net + visual snapshots. Record everything — statuses, response shapes, screenshots. This is the baseline. - On the refactor branch: run the exact same suite. Not the tests the AI wrote for its new structure — the pinning tests written against the old one.
- Diff the two runs. Every difference is either (a) an intended change I can name, or (b) a defect. There is no category (c).
Playwright's toHaveScreenshot() makes the visual half of this nearly free: baseline snapshots captured on main, compared pixel-by-pixel on the branch. For backend behavior, the golden masters do the same job — any output drift fails loudly.
The asymmetry is the point. AI-generated tests validate the AI's understanding of the code. Branch comparison validates reality: old branch and new branch, interrogated identically, must answer identically.

Step 5: Deploy with a leash
With no staging environment, the deploy script is the last gate, and it's paranoid by design:
- Full test suite must be green before deploy (with a lockfile so two concurrent runs can't race each other on the shared test database — ask me how I learned that).
- After deploy, a read-only Playwright canary walks the public site: GET-only, no logins, no form posts, redirects asserted but never followed (following an affiliate redirect logs a fake click). A grep gate over the canary specs blocks
.post(and password fills from ever sneaking in. - Red canary →
git revert. That's the entire rollback plan, and with one-concern-per-deploy slices, reverting is always cheap and always unambiguous. - Admin-only areas the canary can't see get a one-off local Playwright run against a throwaway QA account — never my real one.
What this actually bought me
Across both projects: a 500-line god controller reduced to 172 lines of thin methods delegating to services, dead code removed with evidence instead of vibes, and — the part that still surprises me — more production bugs found than introduced. The safety net caught pre-existing defects (renamed columns, broken pages, a save button that swallowed exceptions) that had been shipping silently for weeks. Introduced regressions: zero reached production.
The AI did most of the typing. The tests did all of the trusting.
The checklist
- Green baseline first — fix or quarantine every failing test before starting.
- Golden-master the code you're about to touch; pin bugs, don't fix them.
- Generate a Playwright smoke net over every route from the router itself.
- Refactor one concern per branch, contract stated in the prompt.
- Run the identical suite on both branches; diff outputs and screenshots.
- Deploy small, canary read-only, revert without ceremony.
None of this is exotic. It's characterization testing from the Working-Effectively-with-Legacy-Code era, plus Playwright, applied to a collaborator that types 100× faster than you and never gets tired. The tools changed; the discipline that makes refactoring safe didn't.