Wave your hands in the air

Ooh, ooh. Like you just don't care.
Ooh, ooh. There's a building-a-service party over here.
Ooh, ooh. There's a building-a-service party over there.

If Part 1 laid the foundation, and Part 2 raised the walls, Part 3 (this one) is where we start wiring the place up and maybe even plug in a PS5 (Clair Obscur: Expedition 33 is the bomb, yo).

The current situation

We’ve used TDD (Test Driven Development - don’t worry this article ain’t about that!) to create a FeedProcessorFactory.

Implementation will look like this:

$feed = FeedProcessorFactory::make("product_feed.csv");
$feedData = $feed->toArray();

Our Make method does what we need. It’s job is to accept a feed path, and return the appropriate object to process and return its contents.

class FeedProcessorFactory
{
    /**
     * @throws FileDoesNotExistException
     */
    public static function make(string $filePath): FeedProcessor
    {
        if (!file_exists($filePath)) {
            throw new FileDoesNotExistException($filePath);
        }

        $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));

        return match ($extension) {
            'csv'  => new CsvProcessor($filePath),
            'rss'  => new RssProcessor($filePath),
            'txt'  => new TxtProcessor($filePath),
            'xml'  => new XmlProcessor($filePath),
            'json' => new JsonProcessor($filePath),
            default => throw new InvalidArgumentException("Unsupported file type: {$extension}"),
        };
    }
}

If we pass in a feed path with a CSV extension we return CsvProcessor.

At the end of Part 2 we’d finished up the CSVProcessor, defined the Interface and wrote tests for them.

Let’s finish up the other 4 concrete classes.

Finishing up

You know the drill by now (go back and read part 1 and 2 if you don’t),

I like to scaffold tests first, write them, run them (we don’t touch any actual implementation code at this point), and fix errors (by adding the implementation) until we don’t have any - i.e it works.

I have a confession to make.

It’s done.

I’ve finished.

I’ve wrote the tests and the code already.

Don’t worry though, i’ll talk through, explain my decisions and link to the final version.

Abstraction

Oh no! An abstraction! Abstractions ARE the devil, right?

Now now, don’t reach for the pitch fork just yet, there’s a good reason, honest.

As I started building out the other classes, I realised that each concrete class had several identical methods (or they could) so I figured, I’ll refactor CsvProcessor and extract the boilerplate into an abstract class.

What’s an abstract class?

An abstract class is basically a partially completed class — like a blueprint with gaps you’re expected to fill in later. It lets you define common behaviour (methods, properties) that all child classes inherit, while leaving the bits that vary up to them.

You can’t instantiate an abstract class on its own — it’s just there to give structure and reuse to other classes. It's a great way to avoid duplication and enforce a consistent interface across different implementations.

abstract class BaseProcessor
{
    protected mixed $dataObject = null;
    protected array $recordsArray = [];
    protected string $recordsJson = '';

    /**
     * @throws FileDoesNotExistException
     */
    public function __construct(protected string $filePath)
    {
        if (!file_exists($this->filePath)) {
            throw new FileDoesNotExistException($this->filePath);
        }

        $this->process();
    }

    abstract public function process(): void;

    public function get(): mixed
    {
        return $this->dataObject;
    }

    public function toArray(): array
    {
        return $this->recordsArray;
    }

    public function toJson(): string
    {
        return $this->recordsJson;
    }
}

Refactoring

Oh no! Refactoring! Refactoring IS the devil, right?

Now now, don’t grab the torch and storm the castle just yet — there’s a good reason for it, I promise.

As mentioned, it turns out the only truly unique method we needed was the process method. To achieve this, however I had to change a few things.

I removed every method apart from the process method from the sub classes and I did some refactoring.

Renamed the CSVIsMalformedException to be FileIsMalformedException so we can use it in more places.

class FileIsMalformedException extends Exception
{
    public function __construct(string $error)
    {
        parent::__construct("File is malformed: {$error}");
    }
}

Also, made changes to the __construct.
Hint - the parameter is now protected.

/**
 * @throws FileDoesNotExistException
 */
public function __construct(protected string $filePath)
{
    if (!file_exists($this->filePath)) {
        throw new FileDoesNotExistException($this->filePath);
    }

    $this->process();
}

And the class properties by making the existing ones protected so sub classes can access them. Also added new ones for the toArray and toJson methods.

protected mixed $dataObject = null;
protected array $recordsArray = [];
protected string $recordsJson = '';

Changed the methods to reference them.

public function toArray(): array
{
    return $this->recordsArray;
}

public function toJson(): string
{
    return $this->recordsJson;
}

Had each sub class (concrete class) extend the abstraction.

class CsvProcessor extends BaseProcessor implements FeedProcessor
class JsonProcessor extends BaseProcessor implements FeedProcessor
class RssProcessor extends BaseProcessor implements FeedProcessor
class TxtProcessor extends BaseProcessor implements FeedProcessor
class XmlProcessor extends BaseProcessor implements FeedProcessor

Whoever says stuff like “if it works, don’t touch it” or “abstractions are just over-engineering, bro” is an absolute moron.

And you know how I could do this refactor / abstract confidently? Well… it’s because I wrote the tests, as long as they still passed i’m good, I was able to refactor with confidence.

Tests, ay? Who knew?

… *cough* Me. Me knew. Me rite testz.

The Process methods

Now all we need to worry about is the process method for each concrete class. Their job is to simply process the feed and define the required class properties (recordsArray, recordsJson and dataObject).

Here they are:

class CsvProcessor extends BaseProcessor implements FeedProcessor
{
    /**
     * @throws FileIsMalformedException
     */
    public function process(): void
    {
        try {
            $this->dataObject = Reader::createFromPath($this->filePath, 'r');
            $this->dataObject->setHeaderOffset(0);
            $this->recordsArray = iterator_to_array($this->dataObject->getRecords());
            $this->recordsJson = json_encode($this->recordsArray);
        } catch (Exception $exception) {
            throw new FileIsMalformedException(
                $exception->getMessage()
            );
        }
    }
}
class JsonProcessor extends BaseProcessor implements FeedProcessor
{
    /**
     * @throws FileIsMalformedException
     * @throws \Exception
     */
    public function process(): void
    {
        try {
            $this->recordsJson = file_get_contents($this->filePath);
            $this->dataObject = json_decode($this->recordsJson);
            if ($this->dataObject === null) {
                throw new FileIsMalformedException('unable to parse');
            }

            $this->recordsArray = json_decode($this->recordsJson, true);
        } catch (Exception $exception) {
            throw new FileIsMalformedException(
                $exception->getMessage()
            );
        }
    }
}
class RssProcessor extends BaseProcessor implements FeedProcessor
{
    private string $namespacePrefix = 'g';

    public function setNamespacePrefix(string $prefix): self
    {
        $this->namespacePrefix = $prefix;

        return $this;
    }

    /**
     * @throws FileIsMalformedException
     */
    public function process(): void
    {
        try {
            libxml_use_internal_errors(true);
            $contents = file_get_contents($this->filePath);
            $this->dataObject = new SimpleXMLElement($contents);

            $index = 0;
            $items = $this->dataObject->channel->item;
            foreach ($items as $item) {
                $children = $item->children($this->namespacePrefix, true) ?? null;
                foreach ($children as $key => $value) {
                    $key = trim((string) $key);
                    $value = trim((string) $value);
                    $this->recordsArray[$index][$key] = $value;
                }

                $index++;
            }

            $this->recordsJson = json_encode($this->recordsArray);
        } catch (Throwable $exception) {
            throw new FileIsMalformedException(
                $exception->getMessage()
            );
        }
    }
}
class TxtProcessor extends BaseProcessor implements FeedProcessor
{
    /**
     * @throws FileIsMalformedException
     * @SuppressWarnings("StaticAccess")
     */
    #[\Override]
    public function process(): void
    {
        try {
            $this->dataObject = Reader::createFromPath($this->filePath, 'r');
            $this->dataObject->setDelimiter("\t");
            $this->dataObject->setHeaderOffset(0);
            $this->recordsArray = iterator_to_array($this->dataObject->getRecords());

            foreach ($this->recordsArray as &$record) {
                array_walk_recursive($record, function (&$item): void {
                    if (!mb_check_encoding($item, 'UTF-8')) {
                        $item = mb_convert_encoding($item, 'UTF-8', 'auto');
                    }
                });
            }

            $this->recordsJson = (string)json_encode($this->recordsArray);
        } catch (Exception $exception) {
            throw new FileIsMalformedException(
                $exception->getMessage()
            );
        }
    }
}
class XmlProcessor extends BaseProcessor implements FeedProcessor
{
    private string $namespacePrefix = 'g';

    /**
     * @throws FileIsMalformedException
     */
    public function process(): void
    {
        try {
            libxml_use_internal_errors(true);
            $contents = file_get_contents($this->filePath);
            $this->dataObject = new SimpleXMLElement($contents);

            $index = 0;
            $items = $this->dataObject->channel->item;
            foreach ($items as $item) {
                $children = $item->children($this->namespacePrefix, true) ?? null;
                foreach ($children as $key => $value) {
                    $key = trim((string) $key);
                    $value = trim((string) $value);
                    $this->recordsArray[$index][$key] = $value;
                }

                $index++;
            }

            $this->recordsJson = json_encode($this->recordsArray);
        } catch (Throwable $exception) {
            throw new FileIsMalformedException(
                $exception->getMessage()
            );
        }
    }
}

These aren’t perfect, but they work, if any edge cases come my way, I’ll add new tests to handle them and adjust accordingly, and most importantly, I won’t check in the new fix / addition until ALL the tests pass (the old ones and the new ones).

I’ll shift this into a package in Part 4 and I’ll walk you through how to go about sharing your creation with the world.

But for now, the code can be found here. Bye!