Server-driven reactive components for MonkeysLegion. Write PHP, get a reactive UI — no API, no SPA build step.
monkeyslegion-live is the server half. MonkeysJS is the client runtime that ships with it. Together they give you Livewire/LiveComponent-class interactivity: components are plain PHP 8.4 classes, the UI updates over the wire, and you never hand-write fetch calls or JSON endpoints.
The differentiator: MonkeysJS is a purpose-built runtime, not a wrapper around Alpine.js or Stimulus. It's designed against this exact wire protocol — hydration, morphing, and batching are first-class. No PHP framework ships streamed AI rendering from a server into the DOM token-by-token.
Installation
composer require monkeyscloud/monkeyslegion-live
Add to your layout:
@liveScripts {{-- injects MonkeysJS + CSRF + config --}}
@liveStyles {{-- optional: loading/transition styles --}}
Requirements
- PHP 8.4+
monkeyscloud/monkeyslegion-template^2.0monkeyscloud/monkeyslegion-http^2.1monkeyscloud/monkeyslegion-router^2.1monkeyscloud/monkeyslegion-di^2.0monkeyscloud/monkeyslegion-encryption^1.0
Optional
monkeyslegion-validation— live form validation viaWithValidationmonkeyslegion-permissions—#[RequiresPermission]on actionsmonkeyslegion-sockets— real-time push viaBroadcastsmonkeyslegion-apex— streamed AI rendering viaStreamsmonkeyslegion-files— chunked file uploads viaWithFileUploads
Quick Start
1. Write a component
<?php
declare(strict_types=1);
namespace App\Live;
use MonkeysLegion\Live\LiveComponent;
use MonkeysLegion\Live\Attributes\State;
use MonkeysLegion\Live\Attributes\Action;
use MonkeysLegion\Live\Attributes\Computed;
final class Counter extends LiveComponent
{
#[State] public int $count = 0;
#[State] public int $step = 1;
#[Action]
public function increment(): void
{
$this->count += $this->step;
}
#[Action]
public function decrement(): void
{
$this->count -= $this->step;
}
#[Computed]
public function parity(): string
{
return $this->count % 2 === 0 ? 'even' : 'odd';
}
public function render(): string
{
return $this->view('live.counter');
}
}
2. Write the template
{{-- resources/views/live/counter.mlv --}}
<div>
<p>Count: <strong>{{ $count }}</strong> ({{ $this->parity() }})</p>
<button ml:click="decrement">−</button>
<button ml:click="increment">+</button>
<label>
Step: <input type="number" ml:model.live="step" min="1">
</label>
</div>
3. Use it
@live(App\Live\Counter::class)
@live(App\Live\Counter::class, { count: 10, step: 5 })
Directive Set
| Directive | Purpose |
|---|---|
ml:model |
Two-way bind input to #[State] |
ml:model.live |
Sync on every input event |
ml:model.live.debounce.300ms |
Debounced live sync |
ml:model.blur |
Sync on blur |
ml:model.lazy |
Sync on change |
ml:click |
Call #[Action] method |
ml:submit.prevent |
Form submission action |
ml:keydown.enter |
Key event → action |
ml:loading |
Show during round-trip |
ml:loading.remove |
Hide during round-trip |
ml:loading.attr.disabled |
Set attribute during loading |
ml:loading.delay.200ms |
Delayed loading indicator |
ml:dirty |
Reflect unsynced changes |
ml:poll.5s |
Re-render on interval |
ml:poll.keep-alive.30s |
Poll even when tab hidden |
ml:offline |
Show when offline |
ml:online |
Show when online |
ml:transition |
CSS enter/leave transitions |
ml:ignore |
Opt subtree out of morphing |
ml:replace |
Force full replacement |
ml:preserve |
Keep node identical across morphs |
ml:stream |
Target for streamed content |
Modifiers: .prevent, .stop, .self, .debounce.<ms>, .throttle.<ms>, .once, .window, .outside
Component API
State
#[State] public string $name = '';
#[State(persist: true)] public string $theme = 'light'; // survives navigation
#[State(url: true)] public string $tab = 'overview'; // synced to query string
#[State(readonly: true)] public int $userId; // signed, never accepted back
#[State(defer: true)] public array $data = []; // lazy-fetched
Actions
#[Action]
public function save(): void { /* ... */ }
#[Action(confirm: 'Delete permanently?')]
public function delete(int $id): void { /* ... */ }
#[Action(renderless: true)]
public function trackClick(): void { /* ... */ } // skip re-render
Computed
#[Computed]
public function fullName(): string
{
return $this->firstName . ' ' . $this->lastName;
}
#[Computed(cache: true)]
public function expensiveReport(): array { /* ... */ }
Lifecycle Hooks
public function mount(int $postId): void {} // once, on initial load
public function hydrate(): void {} // every request, after snapshot restored
public function dehydrate(): void {} // every request, before snapshot sent
public function updating(string $prop, $value): void {}
public function updated(string $prop, $value): void {}
public function rendering(): void {}
public function rendered(string $html): void {}
Property-specific hooks: updatedEmail(), updatingStatus().
Events
$this->emit('saved', $id); // to all components
$this->emitUp('child:done'); // to parent only
$this->emitSelf('refresh'); // to self only
$this->emitTo(Sidebar::class, 'sync'); // to specific component
$this->dispatchBrowser('confetti', ['count' => 50]); // browser CustomEvent
Validation
use MonkeysLegion\Live\Concerns\WithValidation;
final class RegisterForm extends LiveComponent
{
use WithValidation;
#[State] public string $email = '';
#[State] public string $password = '';
protected function rules(): array
{
return [
'email' => 'required|email|unique:users,email',
'password' => 'required|min:12',
];
}
#[Action]
public function register(): void
{
$data = $this->validate();
// create user…
}
public function updatedEmail(): void
{
$this->validateOnly('email');
}
}
<input ml:model.live.debounce.400ms="email">
@error('email') <span class="err">{{ $message }}</span> @enderror
Streaming AI (Apex Integration)
use MonkeysLegion\Live\Concerns\Streams;
final class AiChat extends LiveComponent
{
use Streams;
#[State] public array $messages = [];
#[State] public string $prompt = '';
#[Action]
public function send(Apex $apex): void
{
$this->messages[] = ['role' => 'user', 'content' => $this->prompt];
$this->prompt = '';
$this->stream('reply', function (StreamTarget $out) use ($apex) {
foreach ($apex->pipeline('chat')->stream($this->messages) as $token) {
$out->append($token);
}
});
}
}
<div ml:stream="reply" class="assistant-msg"></div>
Testing
use MonkeysLegion\Live\Testing\LiveTest;
final class CounterTest extends TestCase
{
use LiveTest;
public function test_increments(): void
{
Live::test(Counter::class)
->assertSee('0')
->set('step', 5)
->call('increment')
->assertSet('count', 5);
}
public function test_validation(): void
{
Live::test(RegisterForm::class)
->set('email', 'not-an-email')
->call('register')
->assertHasErrors(['email'])
->assertNoRedirect();
}
}
CLI
ml make:live Counter # component + template stub
ml make:live Posts/Editor --form # with WithValidation scaffold
ml live:list # registered components
ml live:assets # (re)publish MonkeysJS runtime
Configuration
Copy config/live.example.mlc to your project's config/live.mlc. See the file for all options.
Security Model
| Concern | Mitigation |
|---|---|
| State tampering | HMAC checksum on every snapshot; mismatch → 419 |
| Calling private methods | Only #[Action] methods are routable |
| Mass-assignment | Only #[State] (non-readonly) properties accept client updates |
| Argument injection | Action args type-coerced against method signature |
| Authorization | #[RequiresPermission] runs before the action body |
| Entity tampering | Entities rehydrate by id with checksum |
| CSRF | Token injected by @liveScripts, validated on every POST |
| File-upload abuse | Size/MIME allowlists enforced server-side |
License
MIT © MonkeysCloud
Related Packages
Multi-engine full-text, hybrid, and semantic search adapter for MonkeysLegion v2 with attribute-driven index syncing, auto-sync observers, queued indexing, and enterprise-grade query features.
Subprocess execution for MonkeysLegion v2 — async, pools, pipelines, signals, PHP 8.4 property hooks
Production-grade MCP (Model Context Protocol) server and client for PHP 8.4 with attribute-based tool registration, stdio + Streamable HTTP transports, and JSON-RPC 2.0 engine.