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:
- Instantiation -- the component is created and dependencies are injected via the constructor
- Props --
setProps()is called with the props passed from the parent template - resolveState() -- your hook to load data from the database, session, or any other source
- Render -- the template is rendered with all public properties as variables
- 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:
- Token is present and base64-decodable
- HMAC-SHA256 signature is valid
- Component class exists and extends
Component - Action name is in the component's
actions()whitelist - If the component implements
Guarded, theauthorize()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 returnsnull, 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) |