Clean Code

SOLID Principles in PHP: How we applied them to a real industrial catalogue

Real examples from the ProSilicones64 project: 30 products, 19 series, 127 certifications. How we structured the code to scale without breaking.

Publicado
Lectura 5 min

Introduction

When we at Portocarrero started designing the ProSilicones64 architecture, we had a clear problem: the catalogue wasn't a flat list of products. It was 30 products that could be manufactured in 19 different silicone series, each series with its own thermal and hardness properties, applicable to 10 different industrial sectors, and subject to 127 standards and certifications.

If we'd crammed everything into a 2,000-line ProductController with switches and conditionals, the system would have worked... for about three months. After that, every change would have been a nightmare.

SOLID principles gave us the structure to let the code grow. We didn't apply them for academic purism, but because a complex industrial catalogue needs an architecture that reflects that complexity without becoming an unmaintainable monster.

The Problem

The WordPress we replaced had exactly the problems SOLID prevents. There was a catalogue plugin with a CatalogManager class that did everything: queried products, calculated prices, validated stock, generated PDFs, sent emails. 1,847 lines in a single file.

Every time the client asked for a change—"add the naval sector", "show maximum temperature on the product page"—meant diving into that file, praying nothing broke, and testing manually because unit tests were impossible.

  • 1,847 lines in a single CatalogManager class
  • 34 public methods doing everything
  • 12 hardcoded dependencies (MySQL, mailer, logger...)
  • 0 possible unit tests due to coupling
  • Every change = regression risk

That's what happens when you don't apply SRP. And it drags along the other problems: you can't extend without modifying (violates OCP), you can't substitute components (violates LSP), you depend on concrete implementations (violates DIP).

S

SRP: Each class does one thing

We split the monolith into pieces with clear responsibilities

In ProSilicones64, product-series-sector-standard relationships are the core of the business. If a single class manages everything, any change to certifications affects the catalogue, the exporter, the technical sheets. We separated by responsibility: each class has a single reason to change.

Before: Monolithic CatalogManager (WordPress)

<?php
class CatalogManager
{
    public function getProduct($id)
    {
        // Direct SQL query
        $product = $this->db->query('SELECT * FROM products WHERE id = ?', [$id]);
        // Calculate price with margin
        $product['price'] = $product['cost'] * 1.3;
        // Get applicable series
        $product['series'] = $this->db->query('SELECT * FROM series WHERE...');
        // Get certifications
        $product['certs'] = $this->db->query('SELECT * FROM certifications WHERE...');
        // Generate card HTML
        $product['html'] = $this->renderProductCard($product);
        // Log
        $this->logger->info('Product retrieved');
        return $product;
    }
    // ... 33 more methods
}
  • 6 responsibilities in a single method
  • Impossible to test without real database
  • Changing price calculation affects everything
  • Changing logger requires modifying the class

After: Services separated by responsibility

<?php
// Product queries only
class ProductRepository
{
    public function findById(int $id): ?Product
    {
        return $this->db->query(
            'SELECT * FROM products WHERE id = ?', 
            [$id]
        )->fetch(Product::class);
    }
}

// Series logic only
class SeriesService
{
    public function getForProduct(int $productId): array
    {
        return $this->seriesRepo->findByProduct($productId);
    }
}

// Certification validation only
class CertificationValidator
{
    public function validate(Product $p, Series $s, Sector $sec): bool
    {
        $required = $sec->getRequiredCertifications();
        $available = $s->getCertifications();
        return empty(array_diff($required, $available));
    }
}

// Orchestrates pieces, no logic
class ProductService
{
    public function __construct(
        private ProductRepository $products,
        private SeriesService $series,
        private CertificationValidator $validator
    ) {}

    public function getProductWithSeries(int $id): array
    {
        $product = $this->products->findById($id);
        $series = $this->series->getForProduct($id);
        return ['product' => $product, 'series' => $series];
    }
}
  • Each class testable in isolation
  • Changing validation doesn't touch repository
  • Reusable: SeriesService works for catalogue, sheets, exports
  • New devs understand the code in hours, not weeks
O

OCP: Add sectors without modifying code

Open for extension, closed for modification

When the client said "we're adding the naval sector", we didn't have to touch any existing class. Each sector has its requirements class implementing a common interface. Adding a sector = create class + insert data in DB.

Before: Endless switch that grows with each sector

<?php
class SectorValidator
{
    public function getRequirements(string $sector): array
    {
        switch ($sector) {
            case 'railway':
                return ['EN45545-2-HL2', 'EN45545-2-HL3'];
            case 'food':
                return ['FDA-21CFR177', 'EU-1935-2004'];
            case 'medical':
                return ['ISO-10993', 'USP-CLASS-VI'];
            // Each new sector = modify this switch
        }
    }
}
  • Adding naval sector = modifying existing class
  • Switch grows infinitely
  • Error in one case affects all
  • Teams can't work in parallel

After: Strategy pattern with common interface

<?php
interface SectorRequirements
{
    public function getCertifications(): array;
    public function validateSeries(Series $series): bool;
}

class RailwaySectorRequirements implements SectorRequirements
{
    public function getCertifications(): array
    {
        return ['EN45545-2-HL2', 'EN45545-2-HL3'];
    }

    public function validateSeries(Series $series): bool
    {
        return $series->maxTemperature >= 150;
    }
}

// Adding naval sector = create this class. Nothing else.
class NavalSectorRequirements implements SectorRequirements
{
    public function getCertifications(): array
    {
        return ['DNV-GL', 'USCG-164'];
    }

    public function validateSeries(Series $series): bool
    {
        return $series->saltWaterResistant === true;
    }
}
  • Naval sector implemented in 4 hours (vs 2-3 days)
  • Existing code untouched = 0 regressions
  • Each sector testable independently
  • Teams can work on sectors in parallel
L

LSP: All exporters are interchangeable

Subclasses substitute their parents without breaking anything

ProSilicones64 exports technical sheets in PDF, data for ERPs in CSV, and catalogues in XML. All exporters implement the same interface: receive a Product, return a string. The controller doesn't know or care what format it's generating.

Before: Inconsistent exporters

<?php
class PdfExporter
{
    public function export($product)
    {
        return $this->pdf->generate($product); // Binary
    }
}

class CsvExporter
{
    public function export($product)
    {
        return [$product['name'], $product['sku']]; // Array
    }
}

class XmlExporter
{
    public function export($product)
    {
        echo '<product>' . $product['name'] . '</product>'; // Prints
    }
}
  • Each exporter returns something different
  • Controller needs if/else by type
  • Can't be interchanged
  • Tests must verify specific behaviour

After: Common contract, predictable behaviour

<?php
interface ProductExporter
{
    public function export(Product $product): string;
    public function getContentType(): string;
}

class PdfExporter implements ProductExporter
{
    public function export(Product $product): string
    {
        $html = $this->template->render('technical-sheet', $product);
        return $this->pdfEngine->fromHtml($html);
    }

    public function getContentType(): string { return 'application/pdf'; }
}

// Generic controller works with any exporter
class ExportController
{
    public function download(int $id, string $format): Response
    {
        $product = $this->products->findById($id);
        $exporter = $this->factory->create($format);

        return new Response(
            $exporter->export($product),
            ['Content-Type' => $exporter->getContentType()]
        );
    }
}
  • Adding XLSX = create class, register in factory
  • Controller never changes
  • Tests with generic mock for any exporter
  • New format implementation time: 2 hours
I

ISP: Small, specific interfaces

Nobody depends on methods they don't use

Public catalogue views only read products. Admin panel reads and writes. Search system only filters. Instead of one giant interface everyone implements, we separate by use case.

Before: Bloated interface forcing full implementation

<?php
interface ProductRepositoryInterface
{
    public function findById(int $id): ?Product;
    public function findBySlug(string $slug): ?Product;
    public function findAll(): array;
    public function save(Product $product): void;
    public function delete(int $id): void;
    public function search(string $query): array;
    public function export(string $format): string;
    public function import(string $data): void;
    // 12 methods total
}

// Public catalogue implements this but leaves 6 methods empty
  • Classes with empty methods or throw Exception
  • Changing search() affects read-only users
  • Tests must mock 12 methods to use 2
  • Implicit SRP violation

After: Interfaces segregated by use

<?php
interface ReadableProducts
{
    public function findById(int $id): ?Product;
    public function findBySlug(string $slug): ?Product;
}

interface WritableProducts
{
    public function save(Product $product): void;
    public function delete(int $id): void;
}

interface SearchableProducts
{
    public function search(string $query): array;
}

// Public catalogue only needs reading
class PublicCatalogController
{
    public function __construct(private ReadableProducts $products) {}
}

// Admin needs read and write
class AdminController
{
    public function __construct(
        private ReadableProducts $reader,
        private WritableProducts $writer
    ) {}
}
  • Each controller depends only on what it uses
  • Tests mock 2-3 methods, not 12
  • Search changes don't affect catalogue
  • Dead code eliminated: -70%
D

DIP: Logic doesn't know infrastructure

Depend on abstractions, not implementations

SeriesService doesn't know if the cache is Redis, JSON files, or memory. It only knows it has something implementing CacheInterface. Changing cache system means changing one line in the dependency container.

Before: Hardcoded dependencies

<?php
class ProductService
{
    public function __construct()
    {
        // Concrete dependencies, impossible to change
        $this->cache = new RedisCache('localhost:6379');
        $this->db = new MySQLConnection('host=db user=root');
    }
}
  • Tests require real Redis and MySQL
  • Switching to Memcached = rewrite class
  • Can't use in environment without Redis
  • Class knows too much about infrastructure

After: Abstraction injection

<?php
interface CacheInterface
{
    public function get(string $key): mixed;
    public function set(string $key, mixed $value, int $ttl): void;
}

class ProductService
{
    public function __construct(
        private CacheInterface $cache,
        private ProductRepository $repository
    ) {}
}

// In production
$service = new ProductService(new FileCache('/var/cache'), $repo);

// In tests
$service = new ProductService(new ArrayCache(), $mockRepo);
  • Tests without real infrastructure: run in 0.3s
  • Migrate FileCache to Redis = 1 line
  • Dev environment without external dependencies
  • Business logic unchanged when cache changes

Real metrics from the ProSilicones64 project

Comparison between original WordPress and SOLID architecture after 6 months in production:

Metric Original WordPress SOLID Architecture
Largest file 1,847 lines 187 lines
Time to add sector 2-3 days 4 hours
Time for new exporter 1-2 days 2 hours
Unit tests 0 47
Bugs in 6 months N/A 3 minor
Changes causing regression Frequent 0

Conclusion

At Portocarrero we don't apply SOLID because it looks good on a CV. We apply it because an industrial catalogue with complex relationships needs an architecture that can grow. If you cram everything into one giant file, it'll work today and be hell tomorrow.

The five principles boil down to one idea: each piece does one thing, pieces connect through clear contracts, and adding functionality means adding new pieces, not modifying existing ones.

ProSilicones64 has been in production for six months. It's grown with three new sectors, two export formats, ERP integration, and a dynamic technical sheet system. The code from day one still works unchanged. That's what SOLID enables.

If every change in your system is an adventure, if you have files with thousands of lines that nobody wants to touch, or if adding functionality means weeks of work... your architecture needs a review. At Portocarrero we can run a technical audit: identify coupling, measure complexity, and propose a refactoring plan with clear priorities.

Request code audit

Adrián Morín

Developer & Visual Architecture

Responsible for technical development, interface design and dependency-free web architecture.