- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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' => 'B', 'qty' => 1],
], at: '2026-10-24 10:00');
expect($q->shipping)->toBe(200);
});
it('PINNED BUG: accepts negative quantities', function () {
$q = quoteFor('regular', [
['sku' => 'A', 'qty' => 3],
['sku' => 'B', 'qty' => -1],
]);
expect($q->subtotal)->toBe(500);
});
it('rejects an unknown customer', function () {
expect(fn () => quoteFor(null, []))
->toThrow(UnknownCustomer::class);
});
Add one test per remaining row the same way: the plain regular quote, the coupon at exactly 5000 cents, the unknown SKU, the empty cart, and an empty-string tier for the null-tier row. The expected numbers come from the JSON fixtures you already reviewed, which is the handover: every number the golden master guards now has a named test that says why it is that number.
The PINNED BUG name matters. A characterization test proves behavior did not change; the Wikipedia entry is blunt that it does not establish correctness. Naming the test that way leaves the decision to the product owner, and the fix becomes a one-line diff to the assertion in the same PR that changes the code.
The deletion rule
The golden master was scaffolding. Delete a row when both of these are true:
- A unit test asserts the same numbers for the same inputs.
- The row's HTTP outcome (status code and JSON shape) is still covered by a feature test you keep.
In this example that leaves three tests that touch Laravel: one feature test for a 200 with the four keys, one for the 404, and one database test for EloquentCustomers returning an empty string for a NULL tier. Adapter quirks like that cast are the one thing a unit test of the core cannot see, so they keep a small test of their own. The arch test stays too.
Everything else goes in one commit: the nine fixture files, the dataset, the goldenMaster() helper and the mutates() line. If a later refactor needs the safety net again, recording a new one takes minutes, and the corporate tier from the ticket can now be a unit test with no database at all.
If this was useful
Golden master first, ports second, framework at the edge last: that order is how a scary controller becomes a use case you can test in milliseconds. Decoupled PHP walks the same path across a whole application, with the ports and adapters that keep Laravel or Symfony out of your business rules.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)