milpa / live
Render-target-agnostic live component primitives for the Milpa PHP framework: contracts, value objects, components, data sources, and the event-driven mount/handle/render lifecycle.
Requires
- php: >=8.3
- milpa/command: >=0.23 <1.0
- milpa/core: >=0.12 <1.0
- psr/log: ^3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.25.0
- v0.24.0
- v0.23.0
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.0
- v0.18.0
- v0.17.0
- v0.16.0
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.1
- v0.5.0
- v0.4.1
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.1
- v0.1.0
- dev-feat/a-component-declares-its-messages
- dev-feat/component-declares-its-presentation
- dev-feat/declare-events-in-the-manifest
- dev-feat/runtime-canonical-form
- dev-feat/clock-from-core
- dev-feat/deterministic-clock
- dev-feat/state-onexit
- dev-feat/state-onenter
- dev-feat/ref-guard-leaves
- dev-feat/compound-guards
- dev-feat/transition-guard
- dev-feat/state-machine-event-scope
- dev-feat/state-machine-props
- dev-feat/state-machine-effects
- dev-feat/state-machine-component
This package is auto-updated.
Last update: 2026-09-09 11:48:14 UTC
README
Milpa Live
Render-target-agnostic live components for the Milpa PHP framework — the same component definition renders to web AND terminal; state, data sources, and an event-driven interception seam, no HTML or ANSI in the component itself.
milpa/live is the render-target-agnostic core of Milpa's live component system: a
component owns its contract (props/state schema, declared actions), its initial
state (mount()), and how it reacts to client-originated actions (handle()) — but
never how it turns into markup or terminal output. That's a
ComponentRendererInterface's
job, paired with the component at the call site. One component, any number of renderers.
Install
composer require milpa/live
Quick example
A minimal component plus two renderers — one for HTML, one for TUI — sharing the exact
same mount()/handle() logic:
use Milpa\Live\Contracts\Component\ComponentDefinitionInterface; use Milpa\Live\Contracts\Rendering\ComponentRendererInterface; use Milpa\Live\ValueObjects\{ ComponentContext, ComponentContract, InteractionRequest, InteractionResult, RenderRequest, RenderResult, RenderTarget, StateSnapshot, }; final class CounterComponent implements ComponentDefinitionInterface { public static function contract(): ComponentContract { return new ComponentContract(name: 'counter', contractVersion: '1.0.0', actions: ['increment' => []]); } public function mount(array $props, ComponentContext $context): StateSnapshot { return new StateSnapshot( componentId: $context->componentId, componentName: 'counter', version: '1.0.0', data: ['count' => (int) ($props['start'] ?? 0)], ); } public function handle(InteractionRequest $request): InteractionResult { return new InteractionResult(state: new StateSnapshot( componentId: $request->state->componentId, componentName: $request->state->componentName, version: $request->state->version, data: ['count' => $request->state->data['count'] + 1], )); } } final class HtmlCounterRenderer implements ComponentRendererInterface { public function supportsTarget(RenderTarget $target): bool { return $target === RenderTarget::HTML; } public function render(ComponentDefinitionInterface $component, RenderRequest $request): RenderResult { $state = $request->state ?? $component->mount($request->props, $request->context); return new RenderResult( output: sprintf('<button data-count="%d">Count: %d</button>', $state->data['count'], $state->data['count']), state: $state, format: RenderTarget::HTML, ); } } final class TuiCounterRenderer implements ComponentRendererInterface { public function supportsTarget(RenderTarget $target): bool { return $target === RenderTarget::TUI; } public function render(ComponentDefinitionInterface $component, RenderRequest $request): RenderResult { $state = $request->state ?? $component->mount($request->props, $request->context); return new RenderResult(output: "[ Count: {$state->data['count']} ]", state: $state, format: RenderTarget::TUI); } } $component = new CounterComponent(); $context = new ComponentContext('demo-1'); $html = (new HtmlCounterRenderer())->render($component, new RenderRequest(context: $context, target: RenderTarget::HTML)); echo $html->output; // <button data-count="0">Count: 0</button> $tui = (new TuiCounterRenderer())->render($component, new RenderRequest(context: $context, target: RenderTarget::TUI)); echo $tui->output; // [ Count: 0 ]
CounterComponent never printed a single tag or escape code — both renderers turned the
exact same StateSnapshot into their own output, independently.
Web + TUI from one component
That's the thesis this package is built around: a component definition is a pure
description of state and behavior; rendering is a separate, swappable concern. A
ComponentRendererInterface declares which RenderTarget(s)
it supports (HTML, TUI, or the forward-looking ANSI) and turns a mounted
StateSnapshot into output for that target — nothing in ComponentDefinitionInterface
ever needs to know which renderer, or how many, will consume it.
milpa/live ships the component contracts, the mount/handle lifecycle, data sources, and
the event-driven interception seam (component.mounting/mounted,
component.handling/handled, component.rendering/rendered — see
LiveEventEmitter) — but no HTML and no ANSI
renderer. The web surface (AutocompleteHtmlRenderer and friends) lives in
milpa/live-web; a TUI renderer is a live candidate in the Milpa lab. This package is
the seam both build on, not either surface itself.
Every event the emitter dispatches is also declared: LiveEvents
holds the name constants the emitter dispatches with and LiveEvents::declarations() returns one
EventDeclaration per name (milpa/core ≥ 0.11). The emitter declares them lazily — the first
time a dispatcher that implements DeclaredEvents reaches any helper, once per dispatcher
instance; a host that wants them visible before the first dispatch calls
LiveEventEmitter::declareTo($dispatcher). A dispatcher without the contract is asked nothing.
live.request/live.responded are declared here because their dispatch() sites are here:
milpa/live-web's endpoint calls this emitter and holds no dispatch site of its own.
Declared views
A plugin declares its view — its components, the client behaviour they need, their CSS —
and the host's single runtime reconciles it: one Alpine, one milpa-live, one boot, one
endpoint, one signing key per page (greenhouse decisions/0211). This package ships the
render-agnostic half of that contract; milpa/live-web ships the HTML half (the compiler that
collects assets, LiveBoot that emits them once, MilpaLive.register() on the client).
ClientAssets(Milpa\Live\ValueObjects\ClientAssets) — the value:scriptsandstylesURL lists the plugin serves from its own routes.merge()/with()deduplicate by URL and keep first-seen order;empty(),isEmpty(),toArray().DeclaresClientAssets(Milpa\Live\Contracts\Rendering\DeclaresClientAssets) — a sibling ofComponentRendererInterfacethat an HTML renderer implements to declare the files its output depends on. Never theComponentDefinitionInterface: the component contract stays render-target-agnostic, and the TUI renderer of the same component has nothing to declare.RenderResult::clientAssets()— the typed channel a compiler fills by merging every declaring renderer's assets (ClientAssets::merge, so a shared module is emitted once). Empty when no renderer declared any. The legacy string-keyedRenderResult::$assetsbag is untouched and still merged witharray_mergeby compilers.CompositeComponentRegistry(Milpa\Live\Runtime\CompositeComponentRegistry) — oneComponentRegistryInterfaceover ordered, labelled layers (['host' => …, 'billing' => …]) so one endpoint serves every plugin's components and a cross-component effect resolves across them. First layer wins onhas()/get();register()writes to the one layer named writable (or throwsLogicExceptionwhen none);names()is the union in layer order. Shadowing is never silent: the same name bound to different definitions in two layers (a different class, or two instances of a class that carries state) throwsComponentNameConflictExceptionat construction, naming the component and both layers. The same instance in two layers is fine, and so are two instances of a stateless class (no instance property at all). The check is identity or statelessness — never a structural compare, which recurses into whatever the component holds and turns a collaborator pointing back at it into an uncatchable fatal instead of a named exception. The shipped components hold a dispatcher, so twonew TextareaComponent()under one name in two layers are a conflict: a plugin reuses the host's instance or names its own component.ListsComponents(Milpa\Live\Contracts\Component\ListsComponents) —names(): list<string>, implemented byInMemoryComponentRegistryand the composite. A layer that does not list is still resolved but takes no part in conflict detection ornames().ComponentRendererRegistry::registerFor()/resolveFor()— the pair of the composite (amilpa/live-webLiveEndpointorXhtmlComponentCompileraccepts it in place of a name-keyed array): a renderer registered for a component name answers for that name at its target, elsenull— exactly what the array answered. The target-wideregister()/resolve()is a separate question and is deliberately not the fallback: every shipped HTML renderer is single-family and throws for the rest, so a fallback would hand a plugin's component to a renderer that refuses it and turn a missing registration into an uncaught exception in the endpoint. A host with a general renderer registers it for each name it serves.
use Milpa\Live\Runtime\CompositeComponentRegistry; use Milpa\Live\Rendering\ComponentRendererRegistry; $components = new CompositeComponentRegistry(['host' => $hostRegistry, 'billing' => $billingRegistry], writable: 'host'); $renderers = new ComponentRendererRegistry(); $renderers->registerFor('invoice-list', $billingHtmlRenderer); // implements DeclaresClientAssets → its .js/.css travel with every compile
Upgrading
Everything in this section is additive: no existing contract changes shape. RenderResult
gained an optional trailing constructor parameter (clientAssets) with a default, so every
existing renderer compiles; InMemoryComponentRegistry gained names();
ComponentRendererRegistry gained registerFor()/resolveFor(). Nothing is required of a
renderer that has no client files to declare.
Requirements
- PHP ≥ 8.3
milpa/core≥ 0.9, < 1.0psr/log^3
Documentation
Full API reference: getmilpa.github.io/live — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributing
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
License
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.