- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
1115 lines
29 KiB
Markdown
1115 lines
29 KiB
Markdown
# LiveComponents End-to-End Creation Guide
|
|
|
|
Complete guide for creating LiveComponents from scratch - from concept to production deployment.
|
|
|
|
## Table of Contents
|
|
|
|
- [Overview](#overview)
|
|
- [Quick Start](#quick-start)
|
|
- [Step-by-Step Tutorial](#step-by-step-tutorial)
|
|
- [Component Architecture](#component-architecture)
|
|
- [Advanced Patterns](#advanced-patterns)
|
|
- [Testing](#testing)
|
|
- [Deployment](#deployment)
|
|
- [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
A **LiveComponent** is a server-rendered, interactive UI component with automatic state synchronization. Components are:
|
|
|
|
- **Server-rendered**: HTML generated on the server
|
|
- **Stateful**: Maintain state across interactions
|
|
- **Interactive**: Respond to user actions without full page reloads
|
|
- **Framework-compliant**: Readonly classes, Value Objects, Composition
|
|
|
|
### When to Use LiveComponents
|
|
|
|
✅ **Use LiveComponents for:**
|
|
- Interactive forms with validation
|
|
- Real-time dashboards
|
|
- Shopping carts
|
|
- Search interfaces with filters
|
|
- File uploads with progress
|
|
- Paginated lists
|
|
- Wizards/multi-step forms
|
|
|
|
❌ **Don't use LiveComponents for:**
|
|
- Static content (use regular templates)
|
|
- Simple links (use standard HTML)
|
|
- High-frequency updates (use WebSockets directly)
|
|
- Complex client-side logic (use dedicated JavaScript)
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
### Minimal Component (5 minutes)
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Application\LiveComponents\Counter;
|
|
|
|
use App\Framework\LiveComponents\Attributes\Action;
|
|
use App\Framework\LiveComponents\Attributes\LiveComponent;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
|
|
// 1. Mark class with #[LiveComponent] attribute
|
|
#[LiveComponent('counter')]
|
|
// 2. Implement LiveComponentContract
|
|
final readonly class CounterComponent implements LiveComponentContract
|
|
{
|
|
// 3. Constructor: ComponentId + State
|
|
public function __construct(
|
|
public ComponentId $id,
|
|
public CounterState $state
|
|
) {}
|
|
|
|
// 4. Define actions with #[Action]
|
|
#[Action]
|
|
public function increment(): CounterState
|
|
{
|
|
return $this->state->increment();
|
|
}
|
|
|
|
#[Action]
|
|
public function decrement(): CounterState
|
|
{
|
|
return $this->state->decrement();
|
|
}
|
|
|
|
// 5. Render method returns template path
|
|
public function render(): string
|
|
{
|
|
return 'components/counter';
|
|
}
|
|
}
|
|
```
|
|
|
|
### State Value Object
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Application\LiveComponents\Counter;
|
|
|
|
use App\Framework\LiveComponents\Contracts\ComponentData;
|
|
use DateTimeImmutable;
|
|
|
|
// State must implement ComponentData
|
|
final readonly class CounterState implements ComponentData
|
|
{
|
|
public function __construct(
|
|
public int $count = 0,
|
|
public ?DateTimeImmutable $lastUpdate = null
|
|
) {}
|
|
|
|
// Immutable state updates
|
|
public function increment(): self
|
|
{
|
|
return new self(
|
|
count: $this->count + 1,
|
|
lastUpdate: new DateTimeImmutable()
|
|
);
|
|
}
|
|
|
|
public function decrement(): self
|
|
{
|
|
return new self(
|
|
count: max(0, $this->count - 1), // Prevent negative
|
|
lastUpdate: new DateTimeImmutable()
|
|
);
|
|
}
|
|
|
|
// Required by ComponentData
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'count' => $this->count,
|
|
'lastUpdate' => $this->lastUpdate?->format('Y-m-d H:i:s')
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Template
|
|
|
|
```html
|
|
<!-- resources/views/components/counter.view.php -->
|
|
<div data-lc-component="counter" data-lc-id="{component_id}">
|
|
<h2>Counter: {count}</h2>
|
|
|
|
<button data-lc-action="increment">+1</button>
|
|
<button data-lc-action="decrement">-1</button>
|
|
|
|
<if condition="lastUpdate">
|
|
<p>Last update: {lastUpdate}</p>
|
|
</if>
|
|
</div>
|
|
```
|
|
|
|
### Usage in Controller
|
|
|
|
```php
|
|
use App\Framework\LiveComponents\ComponentRegistry;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use App\Framework\View\ViewResult;
|
|
|
|
public function show(ComponentRegistry $registry): ViewResult
|
|
{
|
|
$component = $registry->resolve(
|
|
ComponentId::fromString('counter:main'),
|
|
initialData: ['count' => 0]
|
|
);
|
|
|
|
return new ViewResult('pages/counter', [
|
|
'component' => $component
|
|
]);
|
|
}
|
|
```
|
|
|
|
**That's it!** Component is now interactive. Actions automatically sync state to the server.
|
|
|
|
---
|
|
|
|
## Step-by-Step Tutorial
|
|
|
|
Let's build a **Shopping Cart Component** from scratch.
|
|
|
|
### Step 1: Define the Domain
|
|
|
|
```php
|
|
<?php
|
|
// src/Application/LiveComponents/ShoppingCart/CartItem.php
|
|
|
|
namespace App\Application\LiveComponents\ShoppingCart;
|
|
|
|
use App\Framework\Core\ValueObjects\Price;
|
|
|
|
final readonly class CartItem
|
|
{
|
|
public function __construct(
|
|
public string $productId,
|
|
public string $name,
|
|
public Price $price,
|
|
public int $quantity
|
|
) {
|
|
if ($quantity < 1) {
|
|
throw new \InvalidArgumentException('Quantity must be at least 1');
|
|
}
|
|
}
|
|
|
|
public function getTotal(): Price
|
|
{
|
|
return $this->price->multiply($this->quantity);
|
|
}
|
|
|
|
public function withQuantity(int $quantity): self
|
|
{
|
|
return new self(
|
|
$this->productId,
|
|
$this->name,
|
|
$this->price,
|
|
$quantity
|
|
);
|
|
}
|
|
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'product_id' => $this->productId,
|
|
'name' => $this->name,
|
|
'price' => $this->price->toDecimal(),
|
|
'quantity' => $this->quantity,
|
|
'total' => $this->getTotal()->toDecimal()
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 2: Create State Value Object
|
|
|
|
```php
|
|
<?php
|
|
// src/Application/LiveComponents/ShoppingCart/ShoppingCartState.php
|
|
|
|
namespace App\Application\LiveComponents\ShoppingCart;
|
|
|
|
use App\Framework\LiveComponents\Contracts\ComponentData;
|
|
use App\Framework\Core\ValueObjects\Price;
|
|
|
|
final readonly class ShoppingCartState implements ComponentData
|
|
{
|
|
/** @param array<CartItem> $items */
|
|
public function __construct(
|
|
public array $items = [],
|
|
public ?string $couponCode = null,
|
|
public int $discountPercentage = 0
|
|
) {}
|
|
|
|
public function addItem(CartItem $item): self
|
|
{
|
|
// Check if item already exists
|
|
foreach ($this->items as $existingItem) {
|
|
if ($existingItem->productId === $item->productId) {
|
|
// Update quantity
|
|
return $this->updateQuantity(
|
|
$item->productId,
|
|
$existingItem->quantity + $item->quantity
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add new item
|
|
return new self(
|
|
items: [...$this->items, $item],
|
|
couponCode: $this->couponCode,
|
|
discountPercentage: $this->discountPercentage
|
|
);
|
|
}
|
|
|
|
public function removeItem(string $productId): self
|
|
{
|
|
return new self(
|
|
items: array_values(array_filter(
|
|
$this->items,
|
|
fn(CartItem $item) => $item->productId !== $productId
|
|
)),
|
|
couponCode: $this->couponCode,
|
|
discountPercentage: $this->discountPercentage
|
|
);
|
|
}
|
|
|
|
public function updateQuantity(string $productId, int $quantity): self
|
|
{
|
|
if ($quantity < 1) {
|
|
return $this->removeItem($productId);
|
|
}
|
|
|
|
$updatedItems = array_map(
|
|
fn(CartItem $item) => $item->productId === $productId
|
|
? $item->withQuantity($quantity)
|
|
: $item,
|
|
$this->items
|
|
);
|
|
|
|
return new self(
|
|
items: $updatedItems,
|
|
couponCode: $this->couponCode,
|
|
discountPercentage: $this->discountPercentage
|
|
);
|
|
}
|
|
|
|
public function applyCoupon(string $couponCode, int $discountPercentage): self
|
|
{
|
|
return new self(
|
|
items: $this->items,
|
|
couponCode: $couponCode,
|
|
discountPercentage: $discountPercentage
|
|
);
|
|
}
|
|
|
|
public function getSubtotal(): Price
|
|
{
|
|
$total = 0;
|
|
foreach ($this->items as $item) {
|
|
$total += $item->getTotal()->cents;
|
|
}
|
|
|
|
return Price::fromCents($total);
|
|
}
|
|
|
|
public function getDiscount(): Price
|
|
{
|
|
if ($this->discountPercentage === 0) {
|
|
return Price::fromCents(0);
|
|
}
|
|
|
|
$subtotal = $this->getSubtotal();
|
|
$discountAmount = ($subtotal->cents * $this->discountPercentage) / 100;
|
|
|
|
return Price::fromCents((int) $discountAmount);
|
|
}
|
|
|
|
public function getTotal(): Price
|
|
{
|
|
return $this->getSubtotal()->subtract($this->getDiscount());
|
|
}
|
|
|
|
public function getItemCount(): int
|
|
{
|
|
return array_reduce(
|
|
$this->items,
|
|
fn(int $sum, CartItem $item) => $sum + $item->quantity,
|
|
0
|
|
);
|
|
}
|
|
|
|
public function isEmpty(): bool
|
|
{
|
|
return empty($this->items);
|
|
}
|
|
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'items' => array_map(fn(CartItem $item) => $item->toArray(), $this->items),
|
|
'coupon_code' => $this->couponCode,
|
|
'discount_percentage' => $this->discountPercentage,
|
|
'subtotal' => $this->getSubtotal()->toDecimal(),
|
|
'discount' => $this->getDiscount()->toDecimal(),
|
|
'total' => $this->getTotal()->toDecimal(),
|
|
'item_count' => $this->getItemCount(),
|
|
'is_empty' => $this->isEmpty()
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 3: Create Component
|
|
|
|
```php
|
|
<?php
|
|
// src/Application/LiveComponents/ShoppingCart/ShoppingCartComponent.php
|
|
|
|
namespace App\Application\LiveComponents\ShoppingCart;
|
|
|
|
use App\Framework\LiveComponents\Attributes\Action;
|
|
use App\Framework\LiveComponents\Attributes\LiveComponent;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentEventDispatcher;
|
|
use App\Framework\LiveComponents\ValueObjects\EventPayload;
|
|
use App\Framework\Core\ValueObjects\Price;
|
|
|
|
#[LiveComponent('shopping-cart')]
|
|
final readonly class ShoppingCartComponent implements LiveComponentContract
|
|
{
|
|
public function __construct(
|
|
public ComponentId $id,
|
|
public ShoppingCartState $state,
|
|
private CouponService $couponService // Injected service
|
|
) {}
|
|
|
|
#[Action(rateLimit: 30, rateLimitWindow: 60)] // 30 updates per minute
|
|
public function addItem(
|
|
string $product_id,
|
|
string $name,
|
|
int $price_cents,
|
|
int $quantity = 1,
|
|
?ComponentEventDispatcher $events = null
|
|
): ShoppingCartState {
|
|
$item = new CartItem(
|
|
productId: $product_id,
|
|
name: $name,
|
|
price: Price::fromCents($price_cents),
|
|
quantity: $quantity
|
|
);
|
|
|
|
$newState = $this->state->addItem($item);
|
|
|
|
// Dispatch event for analytics
|
|
$events?->dispatch('cart:item-added', EventPayload::fromArray([
|
|
'product_id' => $product_id,
|
|
'quantity' => $quantity,
|
|
'total_items' => $newState->getItemCount()
|
|
]));
|
|
|
|
return $newState;
|
|
}
|
|
|
|
#[Action]
|
|
public function removeItem(
|
|
string $product_id,
|
|
?ComponentEventDispatcher $events = null
|
|
): ShoppingCartState {
|
|
$newState = $this->state->removeItem($product_id);
|
|
|
|
$events?->dispatch('cart:item-removed', EventPayload::fromArray([
|
|
'product_id' => $product_id,
|
|
'total_items' => $newState->getItemCount()
|
|
]));
|
|
|
|
return $newState;
|
|
}
|
|
|
|
#[Action]
|
|
public function updateQuantity(
|
|
string $product_id,
|
|
int $quantity
|
|
): ShoppingCartState {
|
|
return $this->state->updateQuantity($product_id, $quantity);
|
|
}
|
|
|
|
#[Action(rateLimit: 5, rateLimitWindow: 300)] // 5 coupon attempts per 5 min
|
|
public function applyCoupon(
|
|
string $coupon_code,
|
|
?ComponentEventDispatcher $events = null
|
|
): ShoppingCartState {
|
|
// Validate coupon via service
|
|
$coupon = $this->couponService->validate($coupon_code);
|
|
|
|
if (!$coupon->isValid()) {
|
|
$events?->dispatch('cart:coupon-invalid', EventPayload::fromArray([
|
|
'coupon_code' => $coupon_code,
|
|
'reason' => $coupon->getInvalidReason()
|
|
]));
|
|
|
|
// Return current state unchanged
|
|
return $this->state;
|
|
}
|
|
|
|
$newState = $this->state->applyCoupon(
|
|
$coupon_code,
|
|
$coupon->getDiscountPercentage()
|
|
);
|
|
|
|
$events?->dispatch('cart:coupon-applied', EventPayload::fromArray([
|
|
'coupon_code' => $coupon_code,
|
|
'discount_percentage' => $coupon->getDiscountPercentage(),
|
|
'discount_amount' => $newState->getDiscount()->toDecimal()
|
|
]));
|
|
|
|
return $newState;
|
|
}
|
|
|
|
#[Action(
|
|
rateLimit: 3,
|
|
rateLimitWindow: 300,
|
|
idempotencyTTL: 3600
|
|
)]
|
|
public function checkout(?ComponentEventDispatcher $events = null): ShoppingCartState
|
|
{
|
|
if ($this->state->isEmpty()) {
|
|
$events?->dispatch('cart:checkout-failed', EventPayload::fromArray([
|
|
'reason' => 'empty_cart'
|
|
]));
|
|
|
|
return $this->state;
|
|
}
|
|
|
|
// Process checkout (would integrate with payment service)
|
|
$events?->dispatch('cart:checkout-initiated', EventPayload::fromArray([
|
|
'total' => $this->state->getTotal()->toDecimal(),
|
|
'item_count' => $this->state->getItemCount()
|
|
]));
|
|
|
|
return $this->state;
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return 'components/shopping-cart';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 4: Create Template
|
|
|
|
```html
|
|
<!-- resources/views/components/shopping-cart.view.php -->
|
|
<div
|
|
data-lc-component="shopping-cart"
|
|
data-lc-id="{component_id}"
|
|
class="shopping-cart"
|
|
>
|
|
<h2>Shopping Cart ({item_count} items)</h2>
|
|
|
|
<if condition="is_empty">
|
|
<p class="empty-message">Your cart is empty</p>
|
|
</if>
|
|
|
|
<if condition="!is_empty">
|
|
<!-- Cart Items (Fragment for partial updates) -->
|
|
<div data-lc-fragment="cart-items">
|
|
<for items="items" as="item">
|
|
<div class="cart-item">
|
|
<h3>{item.name}</h3>
|
|
<p>Price: €{item.price}</p>
|
|
|
|
<div class="quantity-control">
|
|
<button
|
|
data-lc-action="updateQuantity"
|
|
data-lc-params='{"product_id": "{item.product_id}", "quantity": {item.quantity - 1}}'
|
|
>-</button>
|
|
|
|
<span>{item.quantity}</span>
|
|
|
|
<button
|
|
data-lc-action="updateQuantity"
|
|
data-lc-params='{"product_id": "{item.product_id}", "quantity": {item.quantity + 1}}'
|
|
>+</button>
|
|
</div>
|
|
|
|
<p>Total: €{item.total}</p>
|
|
|
|
<button
|
|
data-lc-action="removeItem"
|
|
data-lc-params='{"product_id": "{item.product_id}"}'
|
|
class="remove-btn"
|
|
>Remove</button>
|
|
</div>
|
|
</for>
|
|
</div>
|
|
|
|
<!-- Cart Summary (Fragment for partial updates) -->
|
|
<div data-lc-fragment="cart-summary" class="cart-summary">
|
|
<div>
|
|
<span>Subtotal:</span>
|
|
<span>€{subtotal}</span>
|
|
</div>
|
|
|
|
<if condition="discount_percentage > 0">
|
|
<div class="discount">
|
|
<span>Discount ({discount_percentage}%):</span>
|
|
<span>-€{discount}</span>
|
|
</div>
|
|
</if>
|
|
|
|
<div class="total">
|
|
<span>Total:</span>
|
|
<span>€{total}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Coupon Form -->
|
|
<form data-lc-action="applyCoupon" class="coupon-form">
|
|
<input type="text" name="coupon_code" placeholder="Coupon code" />
|
|
<button type="submit">Apply</button>
|
|
</form>
|
|
|
|
<!-- Checkout Button -->
|
|
<button
|
|
data-lc-action="checkout"
|
|
class="checkout-btn"
|
|
data-lc-confirm="Proceed to checkout?"
|
|
>
|
|
Checkout (€{total})
|
|
</button>
|
|
</if>
|
|
</div>
|
|
```
|
|
|
|
### Step 5: JavaScript Integration (Optional)
|
|
|
|
```javascript
|
|
// resources/js/modules/shopping-cart.js
|
|
import { LiveComponent } from './live-component.js';
|
|
|
|
const cartComponent = new LiveComponent('shopping-cart:main');
|
|
|
|
// Listen to events
|
|
cartComponent.on('cart:item-added', (data) => {
|
|
console.log('Item added:', data.product_id);
|
|
|
|
// Show notification
|
|
showNotification(`Added ${data.quantity} item(s) to cart`);
|
|
|
|
// Update cart badge
|
|
updateCartBadge(data.total_items);
|
|
});
|
|
|
|
cartComponent.on('cart:coupon-applied', (data) => {
|
|
showNotification(
|
|
`Coupon applied! You save ${data.discount_percentage}%`,
|
|
'success'
|
|
);
|
|
});
|
|
|
|
cartComponent.on('cart:coupon-invalid', (data) => {
|
|
showNotification(
|
|
`Invalid coupon: ${data.reason}`,
|
|
'error'
|
|
);
|
|
});
|
|
|
|
cartComponent.on('cart:checkout-initiated', (data) => {
|
|
// Redirect to checkout page
|
|
window.location.href = `/checkout?total=${data.total}`;
|
|
});
|
|
|
|
// Fragment updates for better performance
|
|
cartComponent.on('action:updateQuantity', async (event) => {
|
|
// Only update cart-items and cart-summary fragments
|
|
await cartComponent.executeAction(
|
|
'updateQuantity',
|
|
event.params,
|
|
{ fragments: ['cart-items', 'cart-summary'] }
|
|
);
|
|
});
|
|
```
|
|
|
|
### Step 6: Testing
|
|
|
|
```php
|
|
<?php
|
|
// tests/Feature/LiveComponents/ShoppingCartComponentTest.php
|
|
|
|
use function Pest\LiveComponents\mountComponent;
|
|
use function Pest\LiveComponents\callAction;
|
|
|
|
describe('ShoppingCartComponent', function () {
|
|
it('starts with empty cart', function () {
|
|
$component = mountComponent('shopping-cart:test');
|
|
|
|
expect($component['state'])->toHaveStateKey('is_empty', true);
|
|
expect($component['state'])->toHaveStateKey('item_count', 0);
|
|
});
|
|
|
|
it('adds items to cart', function () {
|
|
$component = mountComponent('shopping-cart:test');
|
|
|
|
$result = callAction($component, 'addItem', [
|
|
'product_id' => 'product-123',
|
|
'name' => 'Test Product',
|
|
'price_cents' => 1999,
|
|
'quantity' => 2
|
|
]);
|
|
|
|
expect($result['state'])->toHaveStateKey('item_count', 2);
|
|
expect($result['state'])->toHaveStateKey('subtotal', '39.98');
|
|
expect($result['events'])->toHaveDispatchedEvent('cart:item-added');
|
|
});
|
|
|
|
it('applies valid coupon', function () {
|
|
$component = mountComponent('shopping-cart:test');
|
|
|
|
// Add item first
|
|
$component = callAction($component, 'addItem', [
|
|
'product_id' => 'product-123',
|
|
'name' => 'Test Product',
|
|
'price_cents' => 10000,
|
|
'quantity' => 1
|
|
]);
|
|
|
|
// Apply coupon
|
|
$result = callAction($component, 'applyCoupon', [
|
|
'coupon_code' => 'SAVE20'
|
|
]);
|
|
|
|
expect($result['state'])->toHaveStateKey('discount_percentage', 20);
|
|
expect($result['state'])->toHaveStateKey('discount', '20.00');
|
|
expect($result['state'])->toHaveStateKey('total', '80.00');
|
|
expect($result['events'])->toHaveDispatchedEvent('cart:coupon-applied');
|
|
});
|
|
|
|
it('enforces rate limiting on checkout', function () {
|
|
$component = mountComponent('shopping-cart:test');
|
|
|
|
// Add item
|
|
$component = callAction($component, 'addItem', [
|
|
'product_id' => 'product-123',
|
|
'name' => 'Test Product',
|
|
'price_cents' => 10000
|
|
]);
|
|
|
|
// Try to checkout 4 times (limit is 3)
|
|
callAction($component, 'checkout');
|
|
callAction($component, 'checkout');
|
|
callAction($component, 'checkout');
|
|
|
|
// 4th attempt should be rate limited
|
|
expect(fn() => callAction($component, 'checkout'))
|
|
->toThrow(RateLimitExceededException::class);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Component Architecture
|
|
|
|
### Required Elements
|
|
|
|
Every LiveComponent must have:
|
|
|
|
1. **#[LiveComponent] attribute** with unique name
|
|
2. **LiveComponentContract implementation**
|
|
3. **ComponentId + State in constructor**
|
|
4. **State as ComponentData Value Object**
|
|
5. **render() method returning template path**
|
|
|
|
### Optional Elements
|
|
|
|
- **#[Action] methods** for interactivity
|
|
- **Service injection** via constructor
|
|
- **Event dispatching** for side effects
|
|
- **Pollable interface** for auto-refresh
|
|
- **Fragment markers** for partial updates
|
|
|
|
### Component Lifecycle
|
|
|
|
```
|
|
1. Resolve (ComponentRegistry)
|
|
├─ Create instance via DI Container
|
|
├─ Inject ComponentId
|
|
├─ Inject initial State
|
|
└─ Inject Services
|
|
|
|
2. Render (LiveComponentRenderer)
|
|
├─ Call render() for template path
|
|
├─ Pass state data to template
|
|
├─ Process template (placeholders, conditionals, loops)
|
|
└─ Return HTML
|
|
|
|
3. Action Execution (LiveComponentHandler)
|
|
├─ Validate CSRF token
|
|
├─ Check rate limits
|
|
├─ Check idempotency
|
|
├─ Bind parameters
|
|
├─ Execute action method
|
|
├─ Create new State from return value
|
|
├─ Dispatch events
|
|
└─ Return ComponentUpdate
|
|
|
|
4. Re-render
|
|
├─ Create new component instance with new State
|
|
├─ Render updated HTML
|
|
├─ Extract fragments (if requested)
|
|
└─ Return response (html/fragments + state + events)
|
|
```
|
|
|
|
---
|
|
|
|
## Advanced Patterns
|
|
|
|
### Service Injection
|
|
|
|
```php
|
|
#[LiveComponent('user-profile')]
|
|
final readonly class UserProfileComponent implements LiveComponentContract
|
|
{
|
|
public function __construct(
|
|
public ComponentId $id,
|
|
public UserProfileState $state,
|
|
// Inject services via constructor
|
|
private UserRepository $userRepository,
|
|
private AvatarService $avatarService,
|
|
private PermissionChecker $permissions
|
|
) {}
|
|
|
|
#[Action]
|
|
public function updateAvatar(UploadedFile $avatar): UserProfileState
|
|
{
|
|
$url = $this->avatarService->upload($avatar);
|
|
|
|
return $this->state->withAvatarUrl($url);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Event Dispatching
|
|
|
|
```php
|
|
#[Action]
|
|
public function completeOrder(
|
|
?ComponentEventDispatcher $events = null
|
|
): OrderState {
|
|
$order = $this->orderService->complete($this->state->orderId);
|
|
|
|
// Dispatch domain event
|
|
$events?->dispatch('order:completed', EventPayload::fromArray([
|
|
'order_id' => $order->id,
|
|
'total' => $order->total->toDecimal(),
|
|
'customer_id' => $order->customerId
|
|
]));
|
|
|
|
// Other systems can listen to this event
|
|
// - Send confirmation email
|
|
// - Update inventory
|
|
// - Notify warehouse
|
|
// - Track analytics
|
|
|
|
return $this->state->withOrder($order);
|
|
}
|
|
```
|
|
|
|
### Polling for Real-Time Updates
|
|
|
|
```php
|
|
use App\Framework\LiveComponents\Contracts\Pollable;
|
|
|
|
#[LiveComponent('order-status')]
|
|
final readonly class OrderStatusComponent implements
|
|
LiveComponentContract,
|
|
Pollable
|
|
{
|
|
#[Action]
|
|
public function poll(): OrderStatusState
|
|
{
|
|
// Fetch latest order status
|
|
$order = $this->orderRepository->find($this->state->orderId);
|
|
|
|
return $this->state->withStatus($order->status);
|
|
}
|
|
|
|
public function getPollInterval(): int
|
|
{
|
|
return 5000; // Poll every 5 seconds
|
|
}
|
|
}
|
|
```
|
|
|
|
### Fragment-Based Updates
|
|
|
|
```php
|
|
// Template with fragments
|
|
<div data-lc-component="product-list">
|
|
<!-- Only update this part -->
|
|
<div data-lc-fragment="product-grid">
|
|
<for items="products" as="product">
|
|
<!-- Product cards -->
|
|
</for>
|
|
</div>
|
|
|
|
<!-- Only update this part -->
|
|
<div data-lc-fragment="pagination">
|
|
<!-- Pagination controls -->
|
|
</div>
|
|
</div>
|
|
|
|
// JavaScript
|
|
liveComponent.executeAction('changePage', { page: 2 }, {
|
|
fragments: ['product-grid', 'pagination'] // Only update these
|
|
});
|
|
```
|
|
|
|
### DTOs for Complex Parameters
|
|
|
|
```php
|
|
// Define DTO
|
|
final readonly class UpdateProfileRequest
|
|
{
|
|
public function __construct(
|
|
public string $name,
|
|
public Email $email,
|
|
public ?string $bio = null,
|
|
public bool $newsletter = false
|
|
) {}
|
|
}
|
|
|
|
// Use in action
|
|
#[Action]
|
|
public function updateProfile(UpdateProfileRequest $request): UserProfileState
|
|
{
|
|
// $request is automatically instantiated from action parameters
|
|
return $this->state->update(
|
|
name: $request->name,
|
|
email: $request->email,
|
|
bio: $request->bio,
|
|
newsletter: $request->newsletter
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
See `tests/Feature/LiveComponents/CounterComponentTest.php` for examples.
|
|
|
|
### Testing Helpers
|
|
|
|
```php
|
|
use function Pest\LiveComponents\mountComponent;
|
|
use function Pest\LiveComponents\callAction;
|
|
use function Pest\LiveComponents\callActionWithFragments;
|
|
|
|
// Mount component
|
|
$component = mountComponent('counter:test', ['count' => 0]);
|
|
|
|
// Execute action
|
|
$result = callAction($component, 'increment');
|
|
|
|
// Execute action with fragments
|
|
$result = callActionWithFragments(
|
|
$component,
|
|
'updateGrid',
|
|
params: ['page' => 2],
|
|
fragments: ['product-grid', 'pagination']
|
|
);
|
|
```
|
|
|
|
### Custom Expectations
|
|
|
|
```php
|
|
// HTML assertions
|
|
expect($component['html'])->toContainHtml('Count: 5');
|
|
|
|
// State assertions
|
|
expect($component['state'])->toHaveState(['count' => 5]);
|
|
expect($component['state'])->toHaveStateKey('count', 5);
|
|
|
|
// Event assertions
|
|
expect($result['events'])->toHaveDispatchedEvent('counter:changed');
|
|
expect($result['events'])->toHaveDispatchedEventWithData('counter:changed', [
|
|
'old_value' => 4,
|
|
'new_value' => 5
|
|
]);
|
|
|
|
// Fragment assertions
|
|
expect($result['html'])->toHaveFragment('cart-summary', '€99.99');
|
|
```
|
|
|
|
---
|
|
|
|
## Deployment
|
|
|
|
### Production Checklist
|
|
|
|
- [ ] All actions have `#[Action]` attribute
|
|
- [ ] Rate limits configured for critical actions
|
|
- [ ] Idempotency TTL set for state-changing actions
|
|
- [ ] CSRF protection enabled (default)
|
|
- [ ] Events logged for analytics
|
|
- [ ] Error handling implemented
|
|
- [ ] Tests passing
|
|
- [ ] Templates optimized (fragments for large updates)
|
|
- [ ] JavaScript client configured
|
|
- [ ] Monitoring/alerting set up
|
|
|
|
### Performance Tips
|
|
|
|
1. **Use fragments** for partial updates
|
|
2. **Configure polling interval** based on data freshness needs
|
|
3. **Add caching** via `#[Action(cache: true)]`
|
|
4. **Optimize templates** - minimize DOM size
|
|
5. **Lazy load** components not immediately visible
|
|
|
|
### Security Tips
|
|
|
|
1. **Never trust client input** - validate in actions
|
|
2. **Use DTOs** for complex parameters
|
|
3. **Set rate limits** on all actions
|
|
4. **Enable idempotency** for critical operations
|
|
5. **Log security events** for monitoring
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Component Not Found
|
|
|
|
**Error**: "Component 'my-component' not found"
|
|
|
|
**Causes**:
|
|
- Missing `#[LiveComponent]` attribute
|
|
- Typo in component name
|
|
- Component class not in `Application/LiveComponents/` directory
|
|
- Discovery cache stale
|
|
|
|
**Solutions**:
|
|
```bash
|
|
# Clear discovery cache
|
|
php console.php discovery:clear
|
|
|
|
# Verify component is registered
|
|
php console.php mcp:server
|
|
# Then use analyze_codebase to find LiveComponents
|
|
```
|
|
|
|
### Action Not Callable
|
|
|
|
**Error**: "Action 'myAction' not found"
|
|
|
|
**Causes**:
|
|
- Missing `#[Action]` attribute
|
|
- Method is private/protected
|
|
- Method is static
|
|
- Reserved method name (render, toArray, etc.)
|
|
|
|
**Solutions**:
|
|
```php
|
|
// ✅ Correct
|
|
#[Action]
|
|
public function myAction(): StateType
|
|
|
|
// ❌ Wrong - missing attribute
|
|
public function myAction(): StateType
|
|
|
|
// ❌ Wrong - static
|
|
#[Action]
|
|
public static function myAction(): StateType
|
|
|
|
// ❌ Wrong - private
|
|
#[Action]
|
|
private function myAction(): StateType
|
|
```
|
|
|
|
### State Not Updating
|
|
|
|
**Causes**:
|
|
- Action not returning new state
|
|
- State mutation instead of new instance
|
|
- Caching issues
|
|
|
|
**Solutions**:
|
|
```php
|
|
// ❌ Wrong - mutation (doesn't work with readonly)
|
|
public function increment(): void
|
|
{
|
|
$this->state->count++; // Can't mutate readonly
|
|
}
|
|
|
|
// ✅ Correct - return new state
|
|
public function increment(): CounterState
|
|
{
|
|
return $this->state->increment(); // Returns new instance
|
|
}
|
|
```
|
|
|
|
### Events Not Dispatching
|
|
|
|
**Causes**:
|
|
- Missing `ComponentEventDispatcher` parameter
|
|
- Not calling `dispatch()` method
|
|
- Events not handled in JavaScript
|
|
|
|
**Solutions**:
|
|
```php
|
|
// ✅ Correct
|
|
#[Action]
|
|
public function save(?ComponentEventDispatcher $events = null): StateType
|
|
{
|
|
$events?->dispatch('saved', EventPayload::fromArray(['id' => 123]));
|
|
return $newState;
|
|
}
|
|
|
|
// JavaScript
|
|
liveComponent.on('saved', (data) => {
|
|
console.log('Saved:', data.id);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
- Read [Security Guide](SECURITY-GUIDE.md) for CSRF/Rate Limiting/Idempotency
|
|
- Read [Performance Guide](PERFORMANCE-GUIDE.md) for optimization strategies
|
|
- Read [API Reference](API-REFERENCE.md) for complete attribute/contract documentation
|
|
- Explore example components in `src/Application/LiveComponents/`
|
|
- Run tests: `./vendor/bin/pest tests/Feature/LiveComponents/`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
LiveComponents provide:
|
|
|
|
✅ **Server-rendered interactivity** without JavaScript complexity
|
|
✅ **Type-safe state management** with Value Objects
|
|
✅ **Built-in security** (CSRF, Rate Limiting, Idempotency)
|
|
✅ **Framework-compliant** (Readonly, Immutable, Composition)
|
|
✅ **Easy testing** with Pest helpers
|
|
✅ **Production-ready** with monitoring and error handling
|
|
|
|
**Start simple** with a counter, then build more complex components as you learn the patterns.
|