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
- Quick Start (5 Minutes)
- Component Basics
- State Management
- Actions & Events
- Advanced Features
- Performance Optimization
- 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:
- Component Class - Implements
LiveComponentContract - State Value Object - Extends
ComponentState - Template - HTML template with placeholders
- 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
- Security Guide - CSRF, Rate Limiting, Authorization
- Performance Guide - Caching, Batching, Optimization
- Upload Guide - File Uploads, Validation, Chunking
- API Reference - Complete API documentation