# 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 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 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

Counter: {count}

Last update: {lastUpdate}

``` ### 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 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 $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 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

Shopping Cart ({item_count} items)

Your cart is empty

{item.name}

Price: €{item.price}

{item.quantity}

Total: €{item.total}

Subtotal: €{subtotal}
Discount ({discount_percentage}%): -€{discount}
Total: €{total}
``` ### 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 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
// 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.