Refactoring the Gilded Rose Kata in PHP

The Gilded Rose Kata is a classic refactoring exercise in software craftsmanship. You are handed a working but deeply tangled legacy codebase and asked to add one new feature — without breaking anything. The challenge is not the feature itself. It is figuring out how to safely change code you did not write, that has no tests, and that nobody dares touch.

This post covers the problem exactly as it is presented, the rules you have to satisfy, and the approach I took to solve it cleanly.

The solution is on GitHub: tapasdatta/gildedrosekata-php.


The story and the constraint

The kata begins with a narrative. You have joined the team at Gilded Rose inn. There is an inventory system written by a developer named Leeroy, who has since left. The goblin in the corner owns the Item class and will rage-quit if you touch it — so you cannot modify Item. Every other change is fair game.

Your job is to add support for a new item type: Conjured items, which degrade in quality twice as fast as normal items.


The rules

Before looking at any code, the requirements give you a precise specification for how every item type behaves. These are the rules you must not break:

  • Every item has a sellIn value (days left to sell) and a quality value
  • At the end of each day, both values are lowered for every item
  • Once the sell-by date has passed, quality degrades twice as fast
  • Quality is never negative
  • Quality is never more than 50 (except Sulfuras)
  • Aged Brie increases in quality the older it gets
  • Sulfuras, Hand of Ragnaros is legendary — it never has to be sold and its quality never changes (it is fixed at 80)
  • Backstage passes increase in quality as the sell-in approaches: +2 when 10 days or less, +3 when 5 days or less — then drops to 0 after the concert
  • Conjured items degrade twice as fast as normal items (new requirement)

The legacy code — the actual problem

This is the code you are handed. Item is locked. GildedRose has one method: updateQuality. Read it carefully.

final class GildedRose
{
    /** @param Item[] $items */
    public function __construct(private array $items) {}

    public function updateQuality(): void
    {
        foreach ($this->items as $item) {
            if ($item->name != 'Aged Brie'
                and $item->name != 'Backstage passes to a TAFKAL80ETC concert') {
                if ($item->quality > 0) {
                    if ($item->name != 'Sulfuras, Hand of Ragnaros') {
                        $item->quality = $item->quality - 1;
                    }
                }
            } else {
                if ($item->quality < 50) {
                    $item->quality = $item->quality + 1;
                    if ($item->name == 'Backstage passes to a TAFKAL80ETC concert') {
                        if ($item->sellIn < 11) {
                            if ($item->quality < 50) {
                                $item->quality = $item->quality + 1;
                            }
                        }
                        if ($item->sellIn < 6) {
                            if ($item->quality < 50) {
                                $item->quality = $item->quality + 1;
                            }
                        }
                    }
                }
            }

            if ($item->name != 'Sulfuras, Hand of Ragnaros') {
                $item->sellIn = $item->sellIn - 1;
            }

            if ($item->sellIn < 0) {
                if ($item->name != 'Aged Brie') {
                    if ($item->name != 'Backstage passes to a TAFKAL80ETC concert') {
                        if ($item->quality > 0) {
                            if ($item->name != 'Sulfuras, Hand of Ragnaros') {
                                $item->quality = $item->quality - 1;
                            }
                        }
                    } else {
                        $item->quality = $item->quality - $item->quality;
                    }
                } else {
                    if ($item->quality < 50) {
                        $item->quality = $item->quality + 1;
                    }
                }
            }
        }
    }
}

Every item type is handled inside one method. The rules for Aged Brie are split across two separate blocks — one before the sellIn decrement and one after. Backstage pass logic is buried three levels deep inside an else branch. Sulfuras is handled by exclusion — it is not named, so it falls through doing nothing — rather than by intent.

Try to add Conjured items here. You would need to find every place that handles normal degradation, figure out which guard conditions apply, and slot in new branches while hoping you do not accidentally change Backstage Pass or Aged Brie behaviour at the same time.

This is intentional. The kata is designed to make naïve changes dangerous.


The approach: Strategy pattern

The core observation is that each item type has a completely independent algorithm. They do not share logic — they share only a shape: given an item, update it for one day.

That shape is an interface. Each item type gets its own class that implements it. GildedRose becomes a dispatcher: look up the right strategy by item name, call update. Any unknown item name falls back to the default (normal degradation).

The result:

  • Adding a new item type = one new class + one entry in a map
  • No existing class is touched
  • Each rule lives in one place and can be read and tested in isolation

The solution

The locked Item class

This is what you cannot change. It is a plain mutable data object with three public properties. Every strategy class receives an Item reference and mutates sellIn and quality directly.

// src/Item.php
class Item implements \Stringable
{
    public function __construct(
        public string $name,
        public int $sellIn,
        public int $quality
    ) {}

    public function __toString(): string
    {
        return "{$this->name}, {$this->sellIn}, {$this->quality}";
    }
}

sellIn counts down to zero and then goes negative — that negative value is how every strategy detects the post-sell-by state. quality must always stay between 0 and 50, which each strategy enforces itself.


The strategy interface

Every strategy implements the same single-method interface. This is the contract that lets GildedRose treat all item types identically.

// src/Contract/GildedRoseUpdateInterface.php
namespace GildedRose\Contract;

use GildedRose\Item;

interface GildedRoseUpdateInterface
{
    public function update(Item $item);
}

One method. One responsibility. Nothing else belongs here — no configuration, no lifecycle hooks, no shared state. Each implementation does exactly one thing: advance one item by one day.


Default strategy — normal items

This handles everything that is not a named special item. Quality drops by 1 per day. Once the sell-by date passes (sellIn goes negative), it drops by 2 instead. Quality is clamped at 0.

// src/Items/DefaultUpdateStrategy.php
class DefaultUpdateStrategy implements GildedRoseUpdateInterface
{
    public function update(Item $item): void
    {
        $item->sellIn--;

        if ($item->sellIn >= 0) {
            $item->quality--;
        } else {
            $item->quality -= 2;
        }

        if ($item->quality < 0) {
            $item->quality = 0;
        }
    }
}

sellIn is decremented first. The post-sell-by check then reads the already-decremented value — a day that started at sellIn = 0 becomes sellIn = -1, which triggers the double degradation. The lower clamp at the end prevents quality going negative regardless of the starting value.


Aged Brie strategy

Aged Brie improves with age. Quality goes up by 1 per day — and by an extra 1 after the sell-by date (net +2 when overdue). It cannot exceed 50.

// src/Items/AgedBrieUpdateStrategy.php
class AgedBrieUpdateStrategy implements GildedRoseUpdateInterface
{
    public function update(Item $item): void
    {
        if ($item->quality < 50) {
            $item->quality++;
        }

        $item->sellIn--;

        if ($item->sellIn < 0 && $item->quality < 50) {
            $item->quality++;
        }
    }
}

The logic reads directly as the spec: increment quality before decrementing sellIn, then add another increment if we are now past the sell-by date. The cap check wraps each increment individually so quality never overshoots 50.

Compare this to the original code, where the Aged Brie logic was split across the pre-sellIn and post-sellIn halves of the method and interleaved with Backstage Pass conditions. Here it is eight lines, self-contained, and reads exactly like the requirement.


Backstage pass strategy

Backstage passes are the most complex item. Quality climbs faster as the concert approaches — +1 normally, +2 within 10 days, +3 within 5 days — and drops to exactly 0 the moment the concert is over.

// src/Items/BackstagePassUpdateStrategy.php
class BackstagePassUpdateStrategy implements GildedRoseUpdateInterface
{
    public function update(Item $item): void
    {
        if ($item->sellIn > 0) {
            $item->quality++;

            if ($item->sellIn <= 10 && $item->quality < 50) {
                $item->quality++;
            }

            if ($item->sellIn <= 5 && $item->quality < 50) {
                $item->quality++;
            }
        } else {
            $item->quality = 0;
        }

        $item->sellIn--;

        if ($item->quality > 50) {
            $item->quality = 50;
        }
    }
}

The increments stack additively. When sellIn is 5, all three branches fire: base +1, ≤10 +1, ≤5 +1 — a total of +3. The checks happen before the sellIn decrement so they read the current day's value, matching the spec's phrasing ("when there are 10 days or less"). After the concert (sellIn was already 0 or less before the decrement), quality is hard-reset to 0 — not decremented, not clamped — because the pass is worthless.


Sulfuras strategy

Sulfuras is a legendary item. Nothing about it ever changes. The correct implementation of that rule is an empty method body.

// src/Items/SulfurasUpdateStrategy.php
class SulfurasUpdateStrategy implements GildedRoseUpdateInterface
{
    public function update(Item $item): void
    {
        // Legendary item. Quality is always 80. SellIn never moves.
    }
}

In the original code, Sulfuras was handled entirely by exclusion — every branch checked if ($item->name != 'Sulfuras') to skip it. That means the behaviour of Sulfuras is encoded negatively across the entire method. Here, the intent is explicit and positive: Sulfuras has its own strategy, and that strategy does nothing.


Conjured item strategy — the new feature

This is the item the kata asks you to add. Conjured items degrade twice as fast as normal items: −2 per day, −4 after the sell-by date. Quality still cannot go below 0.

// src/Items/ConjuredUpdateStrategy.php
class ConjuredUpdateStrategy implements GildedRoseUpdateInterface
{
    public function update(Item $item): void
    {
        $item->sellIn--;

        if ($item->sellIn >= 0) {
            $item->quality -= 2;
        } else {
            $item->quality -= 4;
        }

        if ($item->quality < 0) {
            $item->quality = 0;
        }
    }
}

The structure is identical to DefaultUpdateStrategy — same pattern, double the degradation values. Adding this class changed zero lines of existing code. The original tangle would have required surgical edits inside the legacy method and careful manual regression checking. Here, it is a new file dropped into the Items/ directory.


GildedRose — the dispatcher

With all five strategies in place, GildedRose becomes a thin map and a loop. The constructor builds a strategy map keyed by item name. updateQuality looks up the right strategy using the null-coalescing operator to fall back to default for any unrecognised item name.

// src/GildedRose.php
final class GildedRose
{
    private array $strategies;

    /** @param Item[] $items */
    public function __construct(private array $items)
    {
        $this->strategies = [
            'Aged Brie'                                 => new AgedBrieUpdateStrategy(),
            'Backstage passes to a TAFKAL80ETC concert' => new BackstagePassUpdateStrategy(),
            'Sulfuras, Hand of Ragnaros'                => new SulfurasUpdateStrategy(),
            'Conjured Mana Cake'                        => new ConjuredUpdateStrategy(),
            'default'                                   => new DefaultUpdateStrategy(),
        ];
    }

    public function updateQuality(): void
    {
        foreach ($this->items as $item) {
            $strategy = $this->strategies[$item->name] ?? $this->strategies['default'];
            $strategy->update($item);
        }
    }
}

The entire business logic of updateQuality is now two lines: look up a strategy, call it. All the conditional branching that was in the original method is gone. Each item type is handled by its own class, which can be read, tested, and changed in complete isolation.


The test suite

Approval tests — the safety net

Before refactoring any legacy code, you need a safety net. Approval tests are ideal for this: run the full system and capture its complete output as a locked snapshot file. Any future change in behaviour — intended or accidental — causes a test failure.

The 30-day approval test runs the fixture across all item types for a full month and locks the output:

// tests/ApprovalTest.php
public function testThirtyDays(): void
{
    ob_start();
    $argv = ['', '30'];
    include __DIR__ . '/../fixtures/texttest_fixture.php';
    $output = ob_get_clean();

    Approvals::verifyString($output);
}

The first run creates ApprovalTest.testThirtyDays.approved.txt containing every item's state across all 30 days. Every subsequent run compares against it exactly. If a refactoring changes any value on any day for any item, the test fails — even if the change is correct — forcing a deliberate review before re-approving.

This is how you refactor legacy code without a prior unit test suite: write the approval test first, lock the output, then restructure freely as long as the output stays identical.

Unit tests

For targeted assertions there are standard PHPUnit tests. The baseline check verifies that the item name is never mutated by updateQuality:

// tests/GildedRoseTest.php
public function testFoo(): void
{
    $items = [new Item('foo', 0, 0)];
    $gildedRose = new GildedRose($items);
    $gildedRose->updateQuality();

    $this->assertSame('foo', $items[0]->name);
}

Each strategy class can also be unit-tested in isolation — testing the sell-by boundary, the 0 clamp, the 50 cap — without needing the full GildedRose dispatcher.


The before and after

The original updateQuality is ~50 lines of nested conditionals where every item type is interleaved with every other. Understanding the rule for one item requires mentally filtering out all the conditions for the others. There is no clear seam where you could add a new type without risk.

After refactoring:

FileLinesResponsibility
GildedRose.php~15Dispatch only
DefaultUpdateStrategy.php~15Normal items
AgedBrieUpdateStrategy.php~12Aged Brie
BackstagePassUpdateStrategy.php~18Backstage passes
SulfurasUpdateStrategy.php~5Sulfuras (no-op)
ConjuredUpdateStrategy.php~15Conjured items

Each class is independently readable. Adding a new item type in the future means creating one new file and adding one line to the map in GildedRose. No existing code is changed. The approval tests confirm that the external behaviour is byte-for-byte identical to the original.

That is the point of the kata — not to add Conjured items, but to build the kind of structure that makes adding Conjured items trivial.

Refactoring the Gilded Rose Kata in PHP - Tapas Datta