Files
michaelschiemer/docs/livecomponents/end-to-end-guide.md
2025-11-24 21:28:25 +01:00

18 KiB

LiveComponents End-to-End Guide

Complete guide to building LiveComponents from scratch to production.

This guide walks you through creating, testing, and deploying LiveComponents with all features.


Table of Contents

  1. Quick Start (5 Minutes)
  2. Component Basics
  3. State Management
  4. Actions & Events
  5. Advanced Features
  6. Performance Optimization
  7. Real-World Examples

Quick Start (5 Minutes)

Step 1: Create Component Class

<?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;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;

#[LiveComponent('counter')]
final readonly class CounterComponent implements LiveComponentContract
{
    public function __construct(
        public ComponentId $id,
        public CounterState $state
    ) {
    }

    public function getRenderData(): ComponentRenderData
    {
        return new ComponentRenderData(
            templatePath: 'livecomponent-counter',
            data: [
                'componentId' => $this->id->toString(),
                'count' => $this->state->count,
            ]
        );
    }

    #[Action]
    public function increment(): CounterState
    {
        return $this->state->increment();
    }
}

Step 2: Create State Value Object

<?php

declare(strict_types=1);

namespace App\Application\LiveComponents\Counter;

use App\Framework\LiveComponents\ValueObjects\ComponentState;

final readonly class CounterState extends ComponentState
{
    public function __construct(
        public int $count = 0
    ) {
    }

    public function increment(): self
    {
        return new self(count: $this->count + 1);
    }

    public static function empty(): self
    {
        return new self();
    }

    public static function fromArray(array $data): self
    {
        return new self(
            count: $data['count'] ?? 0
        );
    }

    public function toArray(): array
    {
        return [
            'count' => $this->count,
        ];
    }
}

Step 3: Create Template

File: resources/templates/livecomponent-counter.view.php

<div data-component-id="{componentId}">
    <h2>Counter: {count}</h2>
    <button data-live-action="increment">Increment</button>
</div>

Step 4: Use in Template

<x-counter id="demo" />

That's it! Your component is ready to use.


Component Basics

Component Structure

Every LiveComponent consists of:

  1. Component Class - Implements LiveComponentContract
  2. State Value Object - Extends ComponentState
  3. Template - HTML template with placeholders
  4. Actions - Methods marked with #[Action]

Component ID

Components are identified by a ComponentId consisting of:

  • Name: Component name from #[LiveComponent] attribute
  • Instance ID: Unique identifier (e.g., 'demo', 'user-123')
ComponentId::create('counter', 'demo')  // counter:demo
ComponentId::create('user-profile', '123')  // user-profile:123

RenderData

getRenderData() returns template path and data:

public function getRenderData(): ComponentRenderData
{
    return new ComponentRenderData(
        templatePath: 'livecomponent-counter',
        data: [
            'componentId' => $this->id->toString(),
            'count' => $this->state->count,
            'lastUpdate' => $this->state->lastUpdate,
        ]
    );
}

State Management

State Value Objects

State is managed through immutable Value Objects:

final readonly class CounterState extends ComponentState
{
    public function __construct(
        public int $count = 0,
        public ?string $lastUpdate = null
    ) {
    }

    // Factory methods
    public static function empty(): self
    {
        return new self();
    }

    public static function fromArray(array $data): self
    {
        return new self(
            count: $data['count'] ?? 0,
            lastUpdate: $data['lastUpdate'] ?? null
        );
    }

    // Transformation methods (return new instance)
    public function increment(): self
    {
        return new self(
            count: $this->count + 1,
            lastUpdate: date('H:i:s')
        );
    }

    public function reset(): self
    {
        return new self(
            count: 0,
            lastUpdate: date('H:i:s')
        );
    }

    // Serialization
    public function toArray(): array
    {
        return [
            'count' => $this->count,
            'lastUpdate' => $this->lastUpdate,
        ];
    }
}

State Updates

Actions return new State instances:

#[Action]
public function increment(): CounterState
{
    return $this->state->increment();
}

#[Action]
public function addAmount(int $amount): CounterState
{
    return $this->state->addAmount($amount);
}

Actions & Events

Actions

Actions are public methods marked with #[Action]:

#[Action]
public function increment(): CounterState
{
    return $this->state->increment();
}

#[Action(rateLimit: 10, idempotencyTTL: 60)]
public function expensiveOperation(): CounterState
{
    // Rate limited to 10 calls per minute
    // Idempotent for 60 seconds
    return $this->state->performExpensiveOperation();
}

Action Parameters

Actions can accept parameters:

#[Action]
public function addAmount(int $amount): CounterState
{
    return $this->state->addAmount($amount);
}

Client-side:

<button data-live-action="addAmount" data-amount="5">
    Add 5
</button>

Events

Dispatch events from actions:

#[Action]
public function increment(?ComponentEventDispatcher $events = null): CounterState
{
    $newState = $this->state->increment();
    
    $events?->dispatch('counter:changed', EventPayload::fromArray([
        'old_value' => $this->state->count,
        'new_value' => $newState->count,
    ]));
    
    return $newState;
}

Client-side:

document.addEventListener('livecomponent:event', (e) => {
    if (e.detail.name === 'counter:changed') {
        console.log('Counter changed:', e.detail.data);
    }
});

Advanced Features

Caching

Implement Cacheable interface:

#[LiveComponent('user-stats')]
final readonly class UserStatsComponent implements LiveComponentContract, Cacheable
{
    public function getCacheKey(): string
    {
        return 'user-stats:' . $this->state->userId;
    }

    public function getCacheTTL(): Duration
    {
        return Duration::fromMinutes(5);
    }

    public function shouldCache(): bool
    {
        return true;
    }

    public function getCacheTags(): array
    {
        return ['user-stats', 'user:' . $this->state->userId];
    }

    public function getVaryBy(): ?VaryBy
    {
        return VaryBy::userId();
    }

    public function getStaleWhileRevalidate(): ?Duration
    {
        return Duration::fromHours(1);
    }
}

Polling

Implement Pollable interface:

#[LiveComponent('live-feed')]
final readonly class LiveFeedComponent implements LiveComponentContract, Pollable
{
    public function poll(): LiveComponentState
    {
        // Fetch latest data
        $newItems = $this->feedService->getLatest();
        
        return $this->state->withItems($newItems);
    }

    public function getPollInterval(): int
    {
        return 5000; // Poll every 5 seconds
    }
}

File Uploads

Implement SupportsFileUpload interface:

#[LiveComponent('image-uploader')]
final readonly class ImageUploaderComponent implements LiveComponentContract, SupportsFileUpload
{
    public function handleUpload(
        UploadedFile $file,
        ?ComponentEventDispatcher $events = null
    ): LiveComponentState {
        // Process upload
        $path = $this->storage->store($file);
        
        $events?->dispatch('file:uploaded', EventPayload::fromArray([
            'path' => $path,
            'size' => $file->getSize(),
        ]));
        
        return $this->state->withUploadedFile($path);
    }

    public function validateUpload(UploadedFile $file): array
    {
        $errors = [];
        
        if (!in_array($file->getMimeType(), ['image/jpeg', 'image/png'])) {
            $errors[] = 'Only JPEG and PNG images are allowed';
        }
        
        if ($file->getSize() > 5 * 1024 * 1024) {
            $errors[] = 'File size must be less than 5MB';
        }
        
        return $errors;
    }

    public function getAllowedMimeTypes(): array
    {
        return ['image/jpeg', 'image/png'];
    }

    public function getMaxFileSize(): int
    {
        return 5 * 1024 * 1024; // 5MB
    }
}

Fragments

Use fragments for partial updates:

Template:

<div data-lc-fragment="counter-display">
    <h2>Count: {count}</h2>
</div>

<div data-lc-fragment="actions">
    <button data-live-action="increment" data-lc-fragments="counter-display">
        Increment
    </button>
</div>

Client-side: Only counter-display fragment is updated, not the entire component.

Slots

Implement SupportsSlots for flexible component composition:

#[LiveComponent('card')]
final readonly class CardComponent implements LiveComponentContract, SupportsSlots
{
    public function getSlotDefinitions(): array
    {
        return [
            SlotDefinition::named('header'),
            SlotDefinition::default('<p>Default content</p>'),
            SlotDefinition::named('footer'),
        ];
    }

    public function getSlotContext(string $slotName): SlotContext
    {
        return SlotContext::create([
            'card_title' => $this->state->title,
        ]);
    }

    public function processSlotContent(SlotContent $content): SlotContent
    {
        return $content; // No processing needed
    }

    public function validateSlots(array $providedSlots): array
    {
        return []; // No validation errors
    }
}

Usage:

<x-card id="demo" title="My Card">
    <slot name="header">
        <h1>Custom Header</h1>
    </slot>
    
    <slot>
        <p>Main content</p>
    </slot>
</x-card>

Islands

Use #[Island] for isolated rendering:

#[LiveComponent('heavy-widget')]
#[Island(isolated: true, lazy: true, placeholder: 'Loading widget...')]
final readonly class HeavyWidgetComponent implements LiveComponentContract
{
    // Component implementation
}

Performance Optimization

Caching Strategies

Global Cache:

public function getVaryBy(): ?VaryBy
{
    return VaryBy::none(); // Same for all users
}

Per-User Cache:

public function getVaryBy(): ?VaryBy
{
    return VaryBy::userId(); // Different cache per user
}

Per-User Per-Locale Cache:

public function getVaryBy(): ?VaryBy
{
    return VaryBy::userAndLocale(); // Different cache per user and locale
}

With Feature Flags:

public function getVaryBy(): ?VaryBy
{
    return VaryBy::userId()
        ->withFeatureFlags(['new-ui', 'dark-mode']);
}

Stale-While-Revalidate

Serve stale content while refreshing:

public function getCacheTTL(): Duration
{
    return Duration::fromMinutes(5); // Fresh for 5 minutes
}

public function getStaleWhileRevalidate(): ?Duration
{
    return Duration::fromHours(1); // Serve stale for 1 hour while refreshing
}

Request Batching

Batch multiple operations:

// Client-side
const response = await LiveComponent.executeBatch([
    {
        componentId: 'counter:demo',
        method: 'increment',
        params: { amount: 5 },
        fragments: ['counter-display']
    },
    {
        componentId: 'stats:user-123',
        method: 'refresh'
    }
]);

Lazy Loading

Load components when entering viewport:

// In template
{{ lazy_component('notification-center:user-123', [
    'priority' => 'high',
    'threshold' => '0.1',
    'placeholder' => 'Loading notifications...'
]) }}

Real-World Examples

Example 1: User Profile Component

#[LiveComponent('user-profile')]
#[Island(isolated: true, lazy: true)]
final readonly class UserProfileComponent implements LiveComponentContract, Cacheable
{
    public function __construct(
        public ComponentId $id,
        public UserProfileState $state
    ) {
    }

    public function getRenderData(): ComponentRenderData
    {
        return new ComponentRenderData(
            templatePath: 'livecomponent-user-profile',
            data: [
                'componentId' => $this->id->toString(),
                'user' => $this->state->user,
                'stats' => $this->state->stats,
            ]
        );
    }

    #[Action]
    public function updateProfile(array $data, ?ComponentEventDispatcher $events = null): UserProfileState
    {
        $updatedUser = $this->userService->update($this->state->user->id, $data);
        
        $events?->dispatch('profile:updated', EventPayload::fromArray([
            'user_id' => $updatedUser->id,
        ]));
        
        return $this->state->withUser($updatedUser);
    }

    // Cacheable implementation
    public function getCacheKey(): string
    {
        return 'user-profile:' . $this->state->user->id;
    }

    public function getCacheTTL(): Duration
    {
        return Duration::fromMinutes(10);
    }

    public function shouldCache(): bool
    {
        return true;
    }

    public function getCacheTags(): array
    {
        return ['user-profile', 'user:' . $this->state->user->id];
    }

    public function getVaryBy(): ?VaryBy
    {
        return VaryBy::userId();
    }

    public function getStaleWhileRevalidate(): ?Duration
    {
        return null; // No SWR for user profiles
    }
}

Example 2: Search Component with Debouncing

#[LiveComponent('search')]
final readonly class SearchComponent implements LiveComponentContract
{
    #[Action(rateLimit: 20)] // Allow 20 searches per minute
    public function search(string $query, ?ComponentEventDispatcher $events = null): SearchState
    {
        if (strlen($query) < 3) {
            return $this->state->withResults([]);
        }
        
        $results = $this->searchService->search($query);
        
        $events?->dispatch('search:completed', EventPayload::fromArray([
            'query' => $query,
            'result_count' => count($results),
        ]));
        
        return $this->state->withQuery($query)->withResults($results);
    }
}

Client-side debouncing:

let searchTimeout;
const searchInput = document.querySelector('[data-live-action="search"]');

searchInput.addEventListener('input', (e) => {
    clearTimeout(searchTimeout);
    searchTimeout = setTimeout(() => {
        LiveComponent.executeAction('search:demo', 'search', {
            query: e.target.value
        });
    }, 300); // 300ms debounce
});

Example 3: Dashboard with Multiple Components

// Dashboard page template
<div class="dashboard">
    <!-- Heavy widget - lazy loaded -->
    <x-analytics-dashboard id="main" />
    
    <!-- User stats - cached -->
    <x-user-stats id="current-user" />
    
    <!-- Live feed - polled -->
    <x-live-feed id="feed" />
</div>

Testing

Using LiveComponentTestCase

class CounterComponentTest extends LiveComponentTestCase
{
    public function test_increments_counter(): void
    {
        $this->mount('counter:test', ['count' => 5])
            ->call('increment')
            ->seeStateKey('count', 6)
            ->seeHtmlHas('Count: 6');
    }

    public function test_resets_counter(): void
    {
        $this->mount('counter:test', ['count' => 10])
            ->call('reset')
            ->seeStateKey('count', 0)
            ->seeHtmlHas('Count: 0');
    }

    public function test_dispatches_event_on_increment(): void
    {
        $this->mount('counter:test', ['count' => 5])
            ->call('increment')
            ->seeEventDispatched('counter:changed', [
                'old_value' => 5,
                'new_value' => 6,
            ]);
    }
}

Snapshot Testing

public function test_renders_counter_correctly(): void
{
    $component = mountComponent('counter:test', ['count' => 5]);
    
    expect($component['html'])->toMatchSnapshot('counter-initial-state');
}

Best Practices

1. Immutable State

Always return new State instances:

// ✅ CORRECT
public function increment(): CounterState
{
    return new CounterState(count: $this->state->count + 1);
}

// ❌ WRONG (won't work with readonly)
public function increment(): void
{
    $this->state->count++; // Error: Cannot modify readonly property
}

2. Type-Safe State

Use Value Objects instead of arrays:

// ✅ CORRECT
public function __construct(
    public CounterState $state
) {}

// ❌ WRONG
public function __construct(
    public array $state
) {}

3. Action Validation

Validate inputs in actions:

#[Action]
public function addAmount(int $amount): CounterState
{
    if ($amount < 0) {
        throw new \InvalidArgumentException('Amount must be positive');
    }
    
    return $this->state->addAmount($amount);
}

4. Error Handling

Handle errors gracefully:

#[Action]
public function performOperation(): CounterState
{
    try {
        return $this->state->performOperation();
    } catch (\Exception $e) {
        // Log error
        error_log("Operation failed: " . $e->getMessage());
        
        // Return unchanged state or error state
        return $this->state->withError($e->getMessage());
    }
}

5. Performance

  • Use caching for expensive operations
  • Use fragments for partial updates
  • Use lazy loading for below-the-fold content
  • Use Islands for heavy components
  • Batch multiple operations

Next Steps