The ticket says "add a corporate tier to order quotes." You open QuoteController::store and it is 300 lines of Eloquent calls, magic numbers and an if that checks whether today is a weekend. There are no tests. You know the pricing rules belong in a class of their own, and you also know that the first time you move a line, nothing will tell you whether a gold customer still gets the same total.

Michael Feathers named the way out in Working Effectively with Legacy Code. He calls them characterization tests, which InfoQ sums up as "tests that characterize the actual behavior of a piece of code": what the code does, not what the spec wanted. The same idea is also known as golden master testing. J. B. Rainsberger puts it plainly in his 2014 write-up: "I take a snapshot of the last-known acceptable output—I call that the golden master—and save it for future use."

This tutorial does that to a Laravel controller with Pest: record its real JSON for a table of inputs, move the logic into a framework-free use case behind ports, keep the recording green the whole way, then delete most of it.

The controller you are afraid of

Here is a trimmed, illustrative version: the 40 lines that hold the pricing rules, quirks intact. In the 300-line original they sit between validation, logging and a mailer call. It assumes a customers(id, tier) table and a products(sku, price_cents) table.

<?php namespace App\Http\Controllers; use App\Models\Customer; use App\Models\Product; use Illuminate\Http\Request; class QuoteController extends Controller { public function store(Request $request) { $customer = Customer::find($request->input('customer_id')); if (! $customer) { return response()->json( ['error' => 'unknown customer'], 404 ); } $subtotal = 0; foreach ($request->input('items', []) as $line) { $product = Product::where('sku', $line['sku'])->first(); if (! $product) { continue; } $subtotal += $product->price_cents * $line['qty']; } $discount = 0; if ($customer->tier === 'gold') { $discount = (int) round($subtotal * 0.10); } if ($request->input('coupon') === 'SAVE5' && $subtotal > 5000) { $discount += 500; } $shipping = $subtotal - $discount >= 10000 ? 0 : 750; if (now()->isWeekend()) { $shipping += 200; } return response()->json([ 'subtotal' => $subtotal, 'discount' => $discount, 'shipping' => $shipping, 'total' => $subtotal - $discount + $shipping, ]); } } 

Read it as a list of behaviors you now own. Unknown SKUs vanish without an error. A negative quantity lowers the subtotal. The coupon needs strictly more than 5000 cents. Free shipping still pays the weekend fee. A customer row with a NULL tier gets a normal quote. Some of these are bugs. None of them get fixed today, because a refactor that also changes behavior cannot be verified by anything.

Record the golden master with Pest

The recording is one feature test that runs every case through the real HTTP stack and compares status plus body against a JSON file. Pest already ships toMatchSnapshot(), which stores files in tests/.pest/snapshots and refreshes them with --update-snapshots. A short helper in tests/Pest.php does the same job with two differences you want here: each case gets a readable file named after it, and a missing file fails the run instead of being written.

function goldenMaster(string $case, array $actual): void { $file = __DIR__."/golden/quote/{$case}.json"; if (getenv('GOLDEN_RECORD')) { is_dir(dirname($file)) || mkdir(dirname($file), 0777, true); file_put_contents($file, json_encode( $actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES, )."\n"); } if (! is_file($file)) { throw new RuntimeException( "No golden master for {$case}. " .'Record it with GOLDEN_RECORD=1.' ); } $expected = json_decode( file_get_contents($file), true, flags: JSON_THROW_ON_ERROR ); expect($actual)->toBe($expected); } 

toBe compares with ===, so an integer that turns into a string, or two keys that swap places, counts as a change. That strictness is the point.

The test itself lives in tests/Feature/QuoteGoldenMasterTest.php and uses a keyed dataset, so each case shows up by name in the output. Time gets frozen with Laravel's travelTo, because the weekend rule reads the clock.

<?php use App\Http\Controllers\QuoteController; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; uses(RefreshDatabase::class); mutates(QuoteController::class); beforeEach(function () { DB::table('customers')->insert([ ['id' => 1, 'tier' => 'regular'], ['id' => 2, 'tier' => 'gold'], ['id' => 3, 'tier' => null], ]); DB::table('products')->insert([ ['sku' => 'A', 'price_cents' => 2500], ['sku' => 'B', 'price_cents' => 7000], ]); }); it('matches the golden master', function ( string $case, string $at, array $payload, ) { $this->travelTo(Carbon::parse($at)); $response = $this->postJson('/api/quotes', $payload); goldenMaster($case, [ 'status' => $response->status(), 'body' => $response->json(), ]); })->with('quote cases'); 

The cases live in tests/Datasets/QuoteCases.php. Every quirk from the list above gets a row. October 21, 2026 is a Wednesday and October 24 is a Saturday.

<?php $wed = '2026-10-21 10:00'; $sat = '2026-10-24 10:00'; $a2 = ['sku' => 'A', 'qty' => 2]; $b1 = ['sku' => 'B', 'qty' => 1]; dataset('quote cases', [ 'regular' => ['regular', $wed, ['customer_id' => 1, 'items' => [$a2]]], 'gold-coupon' => ['gold-coupon', $wed, ['customer_id' => 2, 'items' => [$a2, $b1], 'coupon' => 'SAVE5']], 'coupon-at-5000' => ['coupon-at-5000', $wed, ['customer_id' => 1, 'items' => [$a2], 'coupon' => 'SAVE5']], 'free-ship-sat' => ['free-ship-sat', $sat, ['customer_id' => 1, 'items' => [$a2, $b1]]], 'unknown-sku' => ['unknown-sku', $wed, ['customer_id' => 1, 'items' => [ ['sku' => 'ZZZ', 'qty' => 1], $a2]]], 'negative-qty' => ['negative-qty', $wed, ['customer_id' => 1, 'items' => [ ['sku' => 'A', 'qty' => 3], ['sku' => 'B', 'qty' => -1]]]], 'empty-cart' => ['empty-cart', $wed, ['customer_id' => 1, 'items' => []]], 'null-tier' => ['null-tier', $wed, ['customer_id' => 3, 'items' => [$a2]]], 'unknown-customer' => ['unknown-customer', $wed, ['customer_id' => 99, 'items' => [$a2]]], ]); 

Record once with GOLDEN_RECORD=1 ./vendor/bin/pest, then open every JSON file and read it. Commit the fixtures in their own commit. From here until the end of the extraction, GOLDEN_RECORD=1 is off limits: re-recording halfway through quietly swaps the safety net for whatever the half-refactored code happens to return.

A recording only protects the branches it exercises. The mutates(QuoteController::class) line lets you check that: run ./vendor/bin/pest --mutate and Pest's mutation testing makes small changes to the controller's code and reruns the tests to see whether any of them notice. It needs Xdebug 3 or PCOV. A mutant that survives, say > becoming >= on the coupon line with no coupon-at-5000 row, is a missing dataset row. Add rows until the survivors are the ones you can explain.

Move the logic out, keep it green

The target is a use case in App\Quoting\Core that knows nothing about Laravel. It talks to two ports, and the controller hands it the current time instead of the core reading a clock.

<?php namespace App\Quoting\Core; interface Catalog { public function priceCents(string $sku): ?int; } interface Customers { public function tierOf(int $id): ?string; } final class UnknownCustomer extends \DomainException {} final readonly class Quote { public function __construct( public int $subtotal, public int $discount, public int $shipping, public int $total, ) {} } 

In a real codebase each of those goes in its own file for autoloading. The use case is the controller body moved with as few edits as possible:

<?php namespace App\Quoting\Core; use DateTimeImmutable; final class QuoteOrder { public function __construct( private Customers $customers, private Catalog $catalog, ) {} public function __invoke( int $customerId, array $items, string $coupon, DateTimeImmutable $at, ): Quote { $tier = $this->customers->tierOf($customerId); if ($tier === null) { throw new UnknownCustomer((string) $customerId); } $subtotal = 0; foreach ($items as $line) { $price = $this->catalog->priceCents($line['sku']); if ($price === null) { continue; // pinned: unknown SKUs are skipped } $subtotal += $price * $line['qty']; } $discount = $tier === 'gold' ? (int) round($subtotal * 0.10) : 0; if ($coupon === 'SAVE5' && $subtotal > 5000) { $discount += 500; } $shipping = $subtotal - $discount >= 10000 ? 0 : 750; if ((int) $at->format('N') >= 6) { $shipping += 200; // pinned: charged even when free } return new Quote( $subtotal, $discount, $shipping, $subtotal - $discount + $shipping, ); } } 

One line changed on purpose: now()->isWeekend() became a check on format('N'), so the core does not import Carbon. Treat that as a bet about behavior. The Saturday row is the evidence, and a Sunday row and a Friday row cost two more lines in the dataset.

The adapters wrap Eloquent:

<?php namespace App\Quoting\Adapters; use App\Models\Customer; use App\Models\Product; use App\Quoting\Core\Catalog; use App\Quoting\Core\Customers; final class EloquentCatalog implements Catalog { public function priceCents(string $sku): ?int { $price = Product::where('sku', $sku) ->value('price_cents'); return $price === null ? null : (int) $price; } } final class EloquentCustomers implements Customers { public function tierOf(int $id): ?string { $customer = Customer::find($id); return $customer === null ? null : (string) $customer->tier; } } 

That (string) cast is where the null-tier row earns its keep. Return $customer?->tier instead and a customer with a NULL tier turns into a 404, because the use case reads null as "no such customer." The golden master goes red on that row the moment you try it. Without the row, the change would have shipped.

Bind the ports in AppServiceProvider::register():

$this->app->bind(Catalog::class, EloquentCatalog::class); $this->app->bind(Customers::class, EloquentCustomers::class); 

And the controller shrinks to translation between HTTP and the use case:

public function store(Request $request, QuoteOrder $quote) { try { $q = $quote( (int) $request->input('customer_id'), $request->input('items', []), (string) $request->input('coupon'), now()->toImmutable(), ); } catch (UnknownCustomer) { return response()->json( ['error' => 'unknown customer'], 404 ); } return response()->json([ 'subtotal' => $q->subtotal, 'discount' => $q->discount, 'shipping' => $q->shipping, 'total' => $q->total, ]); } 

Run the golden master after each of these steps, not once at the end. A missing customer ID becomes 0, which matches no customer record, so it still returns 404. A missing coupon becomes an empty string, which never equals SAVE5. If any of that reasoning is wrong, a row turns red and tells you which one.

To keep the core honest after you leave, add an architecture test:

arch('quoting core does not use Laravel') ->expect('App\Quoting\Core') ->not->toUse('Illuminate'); 

Give the use case fast tests that need no database

Now the rules can be tested as plain PHP. These go in tests/Unit, with two anonymous classes standing in for the ports:

<?php use App\Quoting\Core\Catalog; use App\Quoting\Core\Customers; use App\Quoting\Core\QuoteOrder; use App\Quoting\Core\UnknownCustomer; function quoteFor( ?string $tier, array $items, string $coupon = '', string $at = '2026-10-21 10:00', ) { $customers = new class($tier) implements Customers { public function __construct(private ?string $tier) {} public function tierOf(int $id): ?string { return $this->tier; } }; $catalog = new class implements Catalog { public function priceCents(string $sku): ?int { return ['A' => 2500, 'B' => 7000][$sku] ?? null; } }; return (new QuoteOrder($customers, $catalog))( 7, $items, $coupon, new DateTimeImmutable($at), ); } 

The helper pins the clock to a Wednesday unless a test passes another date, so each test can state one rule and its numbers:

it('stacks gold 10% with SAVE5 and ships free', function () { $q = quoteFor('gold', [ ['sku' => 'A', 'qty' => 2], ['sku' => 'B', 'qty' => 1], ], 'SAVE5'); expect($q->discount)->toBe(1700) ->and($q->shipping)->toBe(0) ->and($q->total)->toBe(10300); }); it('charges the weekend fee on free shipping', function () { $q = quoteFor('regular', [ ['sku' => 'A', 'qty' => 2], ['sku'<...
Source:https://dev.to/gabrielanhaia/pin-a-legacy-laravel-controller-with-golden-master-tests-then-extract-the-use-case-3dkd