Components

A Preflow component is a PHP class co-located with a template in a single directory. The class handles state and actions; the template handles presentation. Components are rendered server-side and can respond to HTMX requests for interactivity without writing JavaScript.

Component Structure

Each component lives in its own directory under app/Components/:

app/Components/Counter/
├── Counter.php       # PHP class
└── Counter.twig      # Twig template

The PHP class extends Component and exposes public properties as template variables:

use Preflow\Components\Component;

final class Counter extends Component
{
    public int $count = 0;

    public function resolveState(): void
    {
        $this->count = (int) ($_SESSION['count'] ?? 0);
    }
}

The template renders those properties:

<p>Count: {{ count }}</p>

Lifecycle

When a component is rendered, the following happens in order:

  1. Instantiation -- the component is created and dependencies are injected via the constructor
  2. Props -- setProps() is called with the props passed from the parent template
  3. resolveState() -- your hook to load data from the database, session, or any other source
  4. Render -- the template is rendered with all public properties as variables
  5. Wrap -- the output is wrapped in an HTML element with a stable component ID

Props

Pass props when rendering a component in a template:

{{ component('Counter', { initialCount: 5 }) }}

Access props in the PHP class:

public function resolveState(): void
{
    $this->count = $this->props['initialCount'] ?? 0;
}

The componentId variable is always available in the template -- it is a stable hash based on the component class name and props.

Actions and HTMX

Components can define actions that respond to HTMX requests. Declare allowed actions and implement handler methods:

final class Counter extends Component
{
    public int $count = 0;

    public function resolveState(): void
    {
        $this->count = (int) ($_SESSION['count'] ?? 0);
    }

    public function actions(): array
    {
        return ['increment'];
    }

    public function actionIncrement(array $params): void
    {
        $this->count++;
        $_SESSION['count'] = $this->count;
    }
}

In the template, use the hd helper to generate HTMX attributes:

<p>Count: {{ count }}</p>
<button {{ hd.post('increment', 'App\\Components\\Counter\\Counter', componentId, props) }}>
    +1
</button>

When clicked, HTMX sends a POST request to the component endpoint. The action runs, then the component re-renders and swaps the updated HTML in place. No page reload, no custom JavaScript.

Security

Every action request goes through five security layers:

  1. Token is present and base64-decodable
  2. HMAC-SHA256 signature is valid
  3. Component class exists and extends Component
  4. Action name is in the component's actions() whitelist
  5. If the component implements Guarded, the authorize() method is called

Guarded Components

For component-level authorization, implement the Guarded interface:

use Preflow\Htmx\Guarded;
use Preflow\Core\Exceptions\ForbiddenHttpException;

final class AdminPanel extends Component implements Guarded
{
    public function authorize(string $action, ServerRequestInterface $request): void
    {
        $user = $request->getAttribute('user');
        if (!$user?->isAdmin()) {
            throw new ForbiddenHttpException('Admins only.');
        }
    }
}

Co-Located CSS and JS

Keep styles and scripts alongside the template using Twig filter blocks:

{% apply css %}
.counter { padding: 1rem; border: 1px solid #ccc; }
.counter button { cursor: pointer; }
{% endapply %}

{% apply js %}
console.log('Counter component loaded');
{% endapply %}

<div class="counter">
    <p>Count: {{ count }}</p>
    <button {{ hd.post('increment', 'App\\Counter', componentId, props) }}>+1</button>
</div>

CSS and JS blocks are collected by the AssetCollector, deduplicated (even when the same component renders multiple times), and output in the layout via {{ head() }} and {{ assets() }}.

JS supports position arguments: {% apply js('head') %} for head scripts, {% apply js %} for body (default), or {% apply js('inline') %} for inline.

Error Boundaries

If a component throws an exception during render:

  • Development mode -- an inline error panel is shown with the exception class, message, component class, props, lifecycle phase, and stack trace
  • Production mode -- the component's fallback() method is called; if it returns null, a hidden <div> is rendered instead
public function fallback(\Throwable $e): ?string
{
    return '<p>Something went wrong. Please try again later.</p>';
}

Rendering Modes

The ComponentRenderer supports three rendering modes:

Method Use Case
render() Full lifecycle + wrapper element
renderFragment() Inner HTML only (for HTMX partial updates)
renderResolved() Skip resolveState (after action dispatch)