Jul 23, 2026

A Composable PHP Component System

React taught me to think in components with typed props. This is how I get the same thing in plain PHP: a tiny abstract base, a typed options object per component, and a static factory that renders.


Introduction

In an earlier article on typed and testable views in PHP, I leaned on two classes without ever explaining them: Card and CardOptions.

Card::create(
    new CardOptions(
        title: $article->title,
        content: $article->abstract
    )
)

I have spent enough time in React and TypeScript and like the things they get right: a component is a self-contained unit, its props are typed, and you compose small components into bigger ones. I wanted to do something similar in PHP, and I did not want to give that up. What I did not want was a template engine, a build step, or a new syntax to learn. I wanted plain PHP with strict types to give me components that feel like the ones I already know.

The system is small. There is an abstract Component base, a ComponentOptions base for configuration, and then each component is a pair: the component class and its options class. Once you have seen one pair, you have seen them all. Card is a good one to start with because it is simple, and it has common sections a component tends to need, plus it composes another component inside itself. I use Tailwind in my projects, so the classes on my components are based in this system.

Requirements

When I designed this, I set myself a few rules:

  1. One typed options object per component - every input is a named, typed, defaulted constructor parameter. No positional argument soup, no untyped associative arrays that you have to read the source to understand. The options object is the component’s public contract.
  2. Composable and nestable - a component can render another component, and the rendered output of one is just content for the next. A card can hold a button; a page can hold cards.
  3. Flexible output - a component can either echo straight into a template or return its HTML as a string, chosen per call. The same Card works when you are dropping it into a page and when you are building a string to pass somewhere else.
  4. Shared behavior in one place - id handling, data attributes, and the common class/id/output options live in the base classes. Writing a new component should be mostly writing its own markup, not re-implementing plumbing.
  5. Testable in a clean way - a component should be assertable without a browser, a running app, or a mocking framework. Options in, HTML out: if I can call a component with a set of options and get a string back, I can test it like any other function.

The base Component class

Every component extends this abstract base. It is deliberately thin: it holds the shared identity fields and two helpers that most components end up needing.

<?php

namespace Ui\Components;

/**
 * Component
 *
 * Abstract base class for all UI components in the system.
 * Provides core functionality for component identification and rendering.
 */
abstract class Component
{
    /**
     * Static counter used to generate unique default IDs for components.
     */
    public static int $component_num = 0;

    /**
     * The unique identifier for this component instance.
     * Auto-generated if not provided in options.
     * NOTE: This uses php8.4+ property hooks.
     * If you are not on 8.4+, you can make a method called "setId()" that can compute the id
     */
    public string $id = ''
    {
        get {
            if (empty($this->id)) {
                $this->id = str_replace('\\', '_', strtolower(static::class)) . '-' . self::$component_num++;
            }

            return $this->id;
        }
        set(string $value) => trim($value);
    }

    /**
     * Builds an HTML data-attribute string from an associative array.
     *
     * @param array $data Associative array of data attribute key-value pairs
     * @return string Space-prefixed data attributes string, or empty if no data
     */
    protected static function dataAttributes(array $data = []): string
    {
        if (empty($data)) {
            return '';
        }

        return ' ' . implode(' ', array_map(
            fn($key, $value) => 'data-' . $key . '="' . $value . '"',
            array_keys($data),
            $data
        ));
    }
}

Two things are worth pointing out. setId() gives a component a stable, predictable default id derived from its class name when the caller did not supply one, so Card becomes something like id="ui_components_card-0". A caller who wants to target the element hands their own id through the options and it take precedence.

dataAttributes() is a small chore that would otherwise get copied and pasted into every component. You can turn ['attr-1' => 'val-1', 'attr-2 => 'val-2'] into data-attr-1="val-1" data-attr-2="val-2". It is static because it does not touch instance state, and it lives on the base so any component can render data attributes the same way. Card uses it, and so does everything else that accepts a data array.

The base ComponentOptions class

If the component class is the behavior, the options class is the contract. Every options object extends this base, which carries the three things nearly every component needs regardless of what it draws.

<?php

namespace Ui\Components;

/**
 * ComponentOptions
 *
 * Base configuration class for all component options.
 * Provides common properties shared across all UI components.
 */
class ComponentOptions
{
    /**
     * @param string $id    Optional custom HTML id attribute for the component
     * @param string $class Optional CSS classes to apply to the component
     * @param bool   $ob    Whether to use output buffering and return the HTML string
     */
    public function __construct(
        public string $id = '',
        public string $class = '',
        public bool $ob = false
    ) {
    }
}

This is PHP 8 constructor property promotion doing the heavy lifting. Three named, typed, defaulted parameters, and each one becomes a public property. id and class are the universal HTML hooks. ob is the output-buffering switch that requirement three hangs on, and I will come back to it.

The important move is that specific options classes extend this one and forward to it. That is how a CardOptions can add a dozen card-specific parameters and still automatically accept id, class, and ob without redeclaring them.

CardOptions: the typed contract

Here is where a component declares exactly what it accepts. There is no reading the render method to find out which array keys matter; the constructor signature tells you everything, and your IDE autocompletes it.

<?php

namespace Ui\Components;

/**
 * CardOptions
 *
 * Configuration class for the Card component.
 * Options for card sections: title, subtitle, content, and footer.
 */
class CardOptions extends ComponentOptions
{
    public function __construct(
        public string $title = '',
        public ButtonOptions $title_button = new ButtonOptions(),
        public string $subtitle = '',
        public string $content = '',
        public string $footer = '',
        public bool $bordered = true,
        public bool $collapsible = false,
        public bool $collapsed = false,
        public array $data = [],
        ...$args
    ) {
        parent::__construct(...$args);
    }
}

Everything defaults, so the minimal call is new CardOptions() and you add only what you need with named arguments. new CardOptions(title: 'Recent Orders', content: $html) is unambiguous at the call site without any comments, and if you pass the wrong type PHP tells you in the editor before the page ever renders.

Two details make this scale. First, title_button defaults to new ButtonOptions() - an options object nested inside an options object, which is the seed of composition. Second, the ...$args spread caught at the end and forwarded with parent::__construct(...$args). That is the whole trick behind requirement four: CardOptions never declares id, class, or ob, yet new CardOptions(title: 'X', class: 'mt-4', ob: true) works, because the named arguments it does not recognize fall through to the parent. Add a common option to ComponentOptions and every component gets it for free.

Why an options object instead of constructor arguments?

This is the obvious question, and I want to answer it head on: PHP 8 has named arguments, so why not put title, content, and the rest straight on Card’s constructor and skip CardOptions entirely? You could. new Card(title: 'X', content: $html) would give you the same autocomplete on the same typed parameters. For a single flat call, the options class looks like pure ceremony.

The reason it earns its keep is that an options object is a value, and constructor arguments are not. Once the configuration is its own object, it can be built early, passed around, adjusted, and reused - none of which you can do with a bare argument list.

That “config as data” property is exactly what powers the composition in the next sections. title_button: new ButtonOptions(...) works because a card can hold a button’s configuration without a button existing yet. If a button’s inputs lived only on Button’s constructor, there would be no value to hand a card short of eagerly constructing the button. The options object is what lets configuration nest inside configuration, all the way down a page.

It also keeps a clean line between data and behavior. CardOptions is a plain object with no rendering, no Heroicon imports, nothing to boot - so a test can construct one and assert on it, a factory can return one, and the Card that consumes it stays the only place that knows how to draw HTML. And because Card::create() takes a CardOptions, there is exactly one typed entry point; the alternative, forwarding every card parameter through a static factory and a constructor, means maintaining the same signature in two places.

So yes, it is a little more boilerplate than a direct constructor - one extra class per component. I consider that an acceptable price for configuration I can name, store, mutate, reuse, and nest. If your components are always rendered inline the instant you configure them and never composed, the plain constructor is the lighter choice. Mine are composed constantly, so the options object wins.

The Card component

Now the component itself. It follows a shape every component in the system shares: a constructor that stores its options, a static create() factory, and a private get() that produces the markup.

<?php

namespace Ui\Components;

use App\Enums\HeroiconSize;
use App\Enums\HeroiconStyle;
use App\Enums\Icon;
use App\Support\Heroicon;

class Card extends Component
{
    public function __construct(public readonly CardOptions $options)
    {
        $this->id = $options->id;
    }

    /**
     * Static factory: create and render a card in one call.
     */
    public static function create(CardOptions $options): string
    {
        return new self($options)->get();
    }

    private function get(): string
    {
        if ($this->options->ob) {
            ob_start();
        }
        ?>
        <div id="<?= $this->id; ?>" class="<?= $this->options->class; ?>"<?= $this->data(); ?>>
            <div class="divide-y divide-gray-200 rounded-md bg-white<?= $this->options->bordered ? ' border border-gray-300' : ''; ?>">
                <?php
                $this->title();
                $this->subtitle();
                $this->content();
                $this->footer();
                ?>
            </div>
        </div>
        <?php
        if ($this->options->ob) {
            $contents = ob_get_contents();
            ob_end_clean();
            return $contents;
        }

        return '';
    }
}

create() is the only entry point callers use. Card::create($options) constructs the instance and renders it, so you never new-up a Card by hand. The type hint on create(CardOptions $options) means you cannot accidentally hand a card the wrong options object; it has to be a CardOptions.

get() writes the outer structure and then delegates each region of the card to a private method. Note the id and class are only emitted when they are non-empty, so a card with no id produces clean markup rather than id="". The border is on by default and bordered: false turns it off. Everything inside is the four section methods.

Sections render themselves, and skip themselves

Each region of the card is a private method that decides whether it should render at all. This is what keeps the call site simple: you set the options you care about, and the sections you left empty produce no markup.

private function subtitle()
{
    if (!empty($this->options->subtitle)) {
        ?>
        <div class="card-subtitle text-gray-500 px-5 py-3 sm:px-4">
            <?= $this->options->subtitle; ?>
        </div>
        <?php
    }
}

private function content()
{
    if (strlen($this->options->content) > 0) {
        ?>
        <div class="card-content font-normal text-gray-700 px-4 py-5 sm:p-4<?= ($this->options->collapsible && $this->options->collapsed) ? ' hidden' : ''; ?>">
            <?= $this->options->content; ?>
        </div>
        <?php
    }
}

private function footer()
{
    if (!empty($this->options->footer)) {
        ?>
        <div class="text-sm px-4 pt-3 mb-3">
            <?= $this->options->footer; ?>
        </div>
        <?php
    }
}

A card with only a title and content renders exactly two regions. There is no empty subtitle div, no stray footer. The template stays declarative from the outside - you describe the card you want, and the component figures out which pieces to draw. The content method also shows how a handful of optional options (collapsible, collapsed) modify a single element’s classes without any of them being required.

Composition: a card that holds a button

Requirement two was that components nest, and the title is where Card demonstrates this. The title method renders a Button inside itself, and it does so the same way any caller renders a button: through ButtonOptions and Button::create().

private function title()
{
    $hasButton = !empty($this->options->title_button->text);
    ?>
    <div class="card-title text-secondary-800 font-bold px-4 py-3 sm:px-4<?= $hasButton ? ' flex items-center justify-between' : ''; ?>">
        <?php
        if (!empty($this->options->title)) {
            ?>
            <div class="inline-flex items-center gap-x-1">
                <?= $this->options->title; ?>
            </div>
            <?php
        }

        if ($hasButton) {
            ?>
            <div class="flex-shrink-0">
                <?php Button::create($this->options->title_button); ?>
            </div>
            <?php
        }
        ?>
    </div>
    <?php
}
Card::create(
    new CardOptions(
        title: 'Recent Orders',
        title_button: new ButtonOptions(
            text: 'View all',
            href: '/orders',
        ),
        content: $ordersHtml
    )
);

Options objects composing options objects is what lets a page be built out of components all the way down, each one typed, each one rendered the same way.

The ob flag: echo or return

The last requirement was flexible output, and it comes down to one boolean. Look again at the top and bottom of get():

if ($this->options->ob) {
    ob_start();
}

// ...render markup directly...

if ($this->options->ob) {
    $contents = ob_get_contents();
    ob_end_clean();
    return $contents;
}

return '';

By default (ob: false) the component writes its HTML straight to the output stream, which is what you want when you are inside a template and just placing a card on the page:

// echoes directly where it sits in the template
Card::create(
    new CardOptions(
        title: 'Summary',
        content: $summary
    )
);

Flip ob: true and the same method buffers everything it would have printed, returns it as a string, and prints nothing. Now the card is a value you can compose - pass it as the content of another card, collect several into an array, or hand it to a view:

// captured as a string to nest inside another component
$inner = Card::create(
    new CardOptions(
        title: 'Line Items',
        content: $itemsHtml,
        ob: true
    )
);

Card::create(
    new CardOptions(
        title: 'Invoice',
        content: $inner
    )
);

One component, two modes, chosen per call. That is why the earlier views article could pass content: $this->content() around so freely - those content strings were components rendered with ob: true.

Writing a new component

Because the base classes hold the shared behavior, a new component is a short recipe:

  1. Create WidgetOptions extends ComponentOptions with your typed, defaulted, named parameters and a ...$args forwarded to the parent.
  2. Create Widget extends Component with a constructor that stores the options, a static create() factory, and a private get() that renders (honoring the ob flag).
  3. Break the markup into small private methods that each render-and-skip based on the options.

Everything else - id, class, ob, data attributes - you inherit. The system already has a dozen of these pairs (Button, Dialog, Dropdown, Table, Pagination, and more), and every one of them follows the same shape, which means once you can read Card you can read all of them.

DescriptionList is that recipe at its smallest. Its one input is a list of term/description pairs, and the obvious way to write that is array $items full of ['dt' => ..., 'dd' => ...]. That is exactly the untyped associative array requirement one was meant to get rid of - the keys are a contract you can only discover by reading the render method. So the pair gets its own tiny value object:

<?php

namespace Ui\Components;

/**
 * DescriptionListItem
 *
 * A single term/description pair in a DescriptionList.
 */
class DescriptionListItem
{
    public function __construct(
        public string $dt = '',
        public string $dd = ''
    ) {
    }
}

That is the same constructor property promotion as ComponentOptions, just for a row instead of a component. Now the options class declares the input it needs and forwards the rest:

<?php

namespace Ui\Components;

class DescriptionListOptions extends ComponentOptions
{
    /**
     * @param DescriptionListItem[] $items The term/description pairs to render
     */
    public function __construct(
        public array $items = [],
        ...$args
    ) {
        parent::__construct(...$args);
    }
}

PHP has no native generic array type, so array is as far as the engine will go and the docblock carries the element type the rest of the way. That is enough for the IDE to autocomplete $item->dd inside the loop, and it means a typo is a property error you can see rather than a silently empty array key.

And the component is the same three parts as Card - constructor, static create(), private get() - with a loop where the section methods would be:

<?php

namespace Ui\Components;

/**
 * DescriptionList Class
 *
 * PHP Version 8
 *
 * @author Matthew A Price
 */

class DescriptionList extends Component
{
    public function __construct(public readonly DescriptionListOptions $options)
    {
        $this->id = $options->id;
    }

    public static function create(DescriptionListOptions $options)
    {
        return new self($options)->get();
    }

    private function get(): string
    {
        if ($this->options->ob) {
            ob_start();
        }

        if (empty($this->options->items)) {
            ?>
            <dl id="<?= $this->id; ?>" class="sm:grid sm:grid-cols-[auto_1fr]">
                <?php foreach ($this->options->items as $item) { ?>
                    <dt class="text-sm/6 font-medium text-gray-900 mr-3"><?= $item->dt; ?></dt>
                    <dd class="mt-1 text-sm/6 text-gray-700 sm:mt-0"><?= $item->dd; ?></dd>
                <?php } ?>
            </dl>
            <?php
        }

        if ($this->options->ob) {
            $contents = ob_get_contents();
            ob_end_clean();
            return $contents;
        }

        return '';
    }
}

That is the entire component. No id handling, no data attributes, no ob plumbing beyond the two blocks the recipe hands you - and it still accepts id, class, and ob at the call site because ...$args forwards them to ComponentOptions:

DescriptionList::create(
    new DescriptionListOptions(
        items: [
            new DescriptionListItem(dt: 'Customer', dd: $order->customer_name),
            new DescriptionListItem(dt: 'Placed', dd: $order->created_at),
        ]
    )
);

And because those items are objects rather than array literals, they are values in exactly the sense the options objects are: a repository method can return DescriptionListItem[], a test can assert on one without rendering anything, and the list of rows can be built somewhere far away from the component that eventually draws them.

Testing a component

The last requirement was that all of this stay testable in a clean way, and the pieces that make it possible are already in place. A component takes one typed object and produces HTML. Set ob: true and that HTML comes back as a string. That is a pure function in every way that matters for a test: no request, no session, no database, no DOM.

So a test is the call site plus an assertion.

<?php

namespace Tests\Ui\Components;

use PHPUnit\Framework\TestCase;
use Ui\Components\{
    Card,
    CardOptions
};

class CardTest extends TestCase
{
    public function testTitleSectionRendersWhenTitleIsSet(): void
    {
        $html = Card::create(
            new CardOptions(
                title: 'Recent Orders',
                ob: true
            )
        );

        $this->assertStringContainsString('card-title', $html);
        $this->assertStringContainsString('Recent Orders', $html);
    }
}

Nothing here is scaffolding. There is no fixture to build, no container to boot, and no test double, because a CardOptions is the input and the string is the output. Every requirement pays off in this file as the typed options object means the test states its inputs by name and the compiler checks them. The ob flag hands the test a value to assert on.

Conclusion

Back to the requirements, and how the system meets each one:

  1. One typed options object per component - CardOptions is a constructor of named, typed, defaulted parameters. The contract is the signature; the IDE autocompletes it and PHP enforces it before render.
  2. Composable and nestable - Card renders a Button through the same Options + create() mechanism any caller uses, and options objects nest inside options objects.
  3. Flexible output - the single ob flag switches every component between echoing in place and returning a string, so the same component both places itself and composes.
  4. Shared behavior in one place - Component and ComponentOptions hold id, class, output, and data-attribute handling, and ...$args forwarding means a new component inherits all of it for free.
  5. Testable in a clean way - CardTest is options in and a string out. No app to boot, no mocks, no browser, and the nested Button can be tested for free because composition happens through the same create() call the test makes.

None of this needs a template engine, a compiler, or a custom syntax. It is plain PHP: an abstract base, constructor property promotion, named arguments, and a static factory. If you already think in typed frontend components, this will feel like home - and if you read the typed views article first, this is the component layer those views were built on.


✌️ Matt
Share this