preflow / components
Preflow components — component base class, renderer, error boundaries
Requires
- php: >=8.4
- preflow/core: ^0.60 || @dev
- preflow/view: ^0.1 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
- preflow/core: <0.60
Replaces
None
- dev-main
- v0.76.0
- v0.75.0
- v0.74.0
- v0.73.0
- v0.72.1
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.2
- v0.69.1
- v0.69.0
- v0.68.1
- v0.68.0
- v0.67.0
- v0.66.2
- v0.66.1
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.2
- v0.19.1
- v0.19.0
- v0.18.1
- v0.18.0
- v0.17.2
- v0.17.1
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.4
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.4
- v0.10.3
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.1
- v0.4.0
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-11-14 19:29:44 UTC
README
Core component system for Preflow. Provides an abstract base class, a renderer with lifecycle management, an error boundary, and a Twig integration.
Installation
composer require preflow/components
Requires PHP 8.4+.
What it does
Each component is a PHP class co-located with a .twig template. The renderer calls resolveState() (load data from DB, session, etc.) then renders the template with the component's public properties as variables. The output is wrapped in a tagged element keyed by a stable component ID.
Errors are caught by ErrorBoundary: in dev mode it renders an inline panel with the exception class, message, component class, props, lifecycle phase, and stack trace; in prod mode it calls the component's fallback() or renders a hidden <div>. An HttpException (a 403, a 404…) is not caught: the page, or the component request, answers with its status (since 0.63.2).
A component's {% apply css %} and {% apply js('head') %} are printed by the layout's {{ head() }}, its body and inline JS by {{ assets() }}; what is collected after those calls ran (a component rendered further down) goes in before </head> and </body>.
A component with $scopeCss = true gets its CSS nested under its own class; a child component without it is not scoped by its parent. @media and @supports stay inside the scope; @keyframes and @font-face (and other at-rules that can't be nested) are kept outside it, so their names are global: give them names of the component's own (card-spin). @import, @namespace and the statement form @layer a, b; are left inside the scope, where browsers ignore them: put those in a stylesheet.
A page where a boundary stood in for a component is never stored by the page cache (refused:component-error). To show what a boundary renders without failing (an error-boundary demo, a style guide), implement Preflow\Components\PreviewsFailure: the renderer prints ErrorBoundary::preview() of previewFailure() in place of the component, the same output a real failure in resolveState gets, and reports no error, so the page is cached. The skeleton's home page does this.
final class ErrorDemo extends Component implements PreviewsFailure { public function previewFailure(): \Throwable { return new \RuntimeException('Crashed on purpose'); } public function fallback(\Throwable $e): ?string { return '<div class="error-demo-fallback">…</div>'; } }
API
Component (abstract base class)
| Method | Description |
|---|---|
resolveState(): void |
Override to load data before render. |
actions(): string[] |
Return allowed action names for this component. |
action{Name}(array $params): void |
Implement one method per declared action. |
fallback(\Throwable $e): ?string |
Return fallback HTML on error (prod only). Return null to hide. |
setProps(array $props): void |
Set props from outside (called by the renderer/extension). |
getProps(): array |
Read current props. |
getComponentId(): string |
ShortClassName-{classHash} (8 hex characters of the class name's hash), plus -{key} when the props have a key: the same for every instance of a class, so give a key to components that appear more than once on a page. |
getTag(): string |
Wrapper HTML tag (default div). Override $tag to change. |
Public properties are automatically exposed as Twig template variables alongside componentId.
ComponentRenderer
$renderer->render(Component $component): string // full lifecycle + wrapper $renderer->renderFragment(Component $component): string // inner HTML only (for HTMX partials) $renderer->renderResolved(Component $component): string // skip resolveState (after action dispatch) $renderer->renderResolvedFragment(Component $component): string // skip resolveState, inner HTML only
renderResolvedFragment() combines the two partial-render flags: it skips resolveState() (state was already mutated by an action) and returns inner HTML without the wrapper element. Used by ComponentEndpoint when the HTMX target does not match the component's own ID.
Live components: #[Live] and PrefetchesLive
A component class marked #[Live] is a live part of cached pages (preflow/core's page cache):
while a page renders for the cache, ComponentRenderer::render() returns a signed placeholder
of its class and props, and the component is made and rendered for each visitor when the page
is served (wrapper and error boundary as always). Props must be scalars and arrays and the
same for every visitor; the component reads what is personal (the visitor's cookie, from
RequestContext::$request) in resolveState().
#[Live] final class AlbumButton extends Component implements PrefetchesLive { public function __construct(private readonly Albums $albums) {} public function prefetchLive(array $propsList): void { $this->albums->prefetch(array_column($propsList, 'number')); // one call for the page's 24 buttons } }
prefetchLive() runs once per class before a served page's live parts are rendered, on an
instance made like the others: keep what it loads in an injected service (or a static).
Freshness: #[Freshness]
What a component reads, as a dependency of the cached page (preflow/core's page cache,
"Freshness, per dependency"), around its resolveState() and its render:
use Preflow\Components\Freshness; use Preflow\Core\PageCache\Freshness as Dial; #[Freshness(Dial::NextVisit)] // a write marks the page stale final class OtherJobs extends Component { ... } #[Freshness(Dial::Timed, seconds: 43200)] // writes ignored; expires after 12 h (or max_age, if shorter) final class Ticker extends Component { ... }
A parent's attribute counts for its subclasses; a scope inside (a template's {% freshness %})
wins for its own reads; on a #[Live] component it does nothing. Timed without seconds is
an error (the component's error boundary shows it). A timed page never outlives the page
cache's max_age (4 h by default): raise it for longer times (up to 20 h).
ComponentExtension (Twig)
Registers {{ component('Name', {props}) }}. Component names are resolved via a short-name → FQCN map or a fully-qualified class name.
Usage
Component class (src/Components/Counter/Counter.php):
use Preflow\Components\Component; 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; } }
Template (src/Components/Counter/Counter.twig):
<p>Count: {{ count }}</p> <button>+1</button>
In a page template:
{{ component('Counter', { initialCount: 0 }) }}
{# Or using a fully-qualified class name: #}
{{ component('App\\Components\\Counter\\Counter') }}
Register the extension:
$componentMap = ['Counter' => App\Components\Counter\Counter::class]; $twig->addExtension(new ComponentExtension($renderer, $componentMap));