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. The Card component is the worked example.


Introduction

In an earlier article on typed and testable views in PHP, I leaned on two classes without ever explaining them: Card and CardOptions. A view would call Card::create(new CardOptions(title: $article->title, content: $article->abstract)) and move on. This article is about what sits behind that call.

I have spent enough time in React and TypeScript to miss the things they get right: a component is a self-contained unit, its props are typed, and you compose small components into bigger ones. When I came back to writing server-rendered HTML in PHP, 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 real, it has every section a component tends to need, and it composes another component inside itself.

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.

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.
     */
    public string $id = '';

    /**
     * The type identifier for this component.
     */
    public string $type = '';

    /**
     * Sets the component ID from options or generates a default unique ID.
     */
    protected function setId($options)
    {
        if (empty($options->id)) {
            $options->id = str_replace('\\', '_', strtolower(get_class($this)) . '-' . self::$component_num);
        }
    }

    /**
     * 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 in their own id through the options and it wins.

dataAttributes() is the small chore that would otherwise get copy-pasted into every component: turn ['controller' => 'card', 'target' => 'body'] into data-controller="card" data-target="body". 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
{
    /**
     * @param string        $title              Card title text
     * @param ButtonOptions $title_button       Optional button rendered in the title bar
     * @param string        $subtitle           Card subtitle text
     * @param string        $content            Main content HTML
     * @param string        $footer             Footer content HTML
     * @param string        $content_min_height Min height CSS class for content area
     * @param bool          $borderless         Whether to hide the card border
     * @param array         $data               Associative array of data attributes
     * @param bool          $collapsible        Clicking the title expands/collapses content
     * @param bool          $collapsed          Initial collapsed state (collapsible only)
     * @param string        $overflow           Max height class; content scrolls beyond it
     * @param mixed         ...$args            Forwarded to parent ComponentOptions
     */
    public function __construct(
        public string $title = '',
        public ButtonOptions $title_button = new ButtonOptions(),
        public string $subtitle = '',
        public string $content = '',
        public string $footer = '',
        public string $content_min_height = '',
        public bool $borderless = false,
        public array $data = [],
        public bool $collapsible = false,
        public bool $collapsed = false,
        public string $overflow = '',
        ...$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:

// Build the config now, decide how to render it later.
$options = new CardOptions(title: 'Recent Orders');
$options->content = $this->renderOrders();
$options->borderless = $user->prefersFlat();

Card::create($options);

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 CardOptions $options;

    public function __construct(CardOptions $options)
    {
        $this->options = $options;
    }

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

    private function get(): string
    {
        if ($this->options->ob) {
            ob_start();
        }
        ?>
        <div<?= !empty($this->options->id) ? ' id="' . $this->options->id . '"' : ''; ?><?= !empty($this->options->class) ? ' class="' . $this->options->class . '"' : ''; ?><?= $this->data(); ?>>
            <div class="divide-y divide-gray-200 rounded-md bg-white<?= !$this->options->borderless ? ' 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 borderless: true 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<?= !empty($this->options->content_min_height) ? ' ' . $this->options->content_min_height : ''; ?><?= !empty($this->options->overflow) ? ' ' . $this->options->overflow . ' overflow-y-auto' : ''; ?><?= ($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 (content_min_height, overflow, 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 bar is where Card proves it. 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()
{
    // Only impose flex justification when a button shares the title bar;
    // otherwise leave the bar as a block so an ancestor's text-align
    // can position the 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
}

This is the whole idea in one method. The card does not know anything about how a button is built; it only holds a ButtonOptions and calls Button::create(). Because title_button defaults to an empty ButtonOptions, a card with no button set simply has title_button->text empty, $hasButton is false, and the button branch never runs. Set it, and the title bar switches to a flex layout with the button pinned to the right:

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.

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.

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