Some checks failed
Deploy Application / deploy (push) Has been cancelled
878 lines
18 KiB
Markdown
878 lines
18 KiB
Markdown
# 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)](#quick-start-5-minutes)
|
|
2. [Component Basics](#component-basics)
|
|
3. [State Management](#state-management)
|
|
4. [Actions & Events](#actions--events)
|
|
5. [Advanced Features](#advanced-features)
|
|
6. [Performance Optimization](#performance-optimization)
|
|
7. [Real-World Examples](#real-world-examples)
|
|
|
|
---
|
|
|
|
## Quick Start (5 Minutes)
|
|
|
|
### Step 1: Create Component Class
|
|
|
|
```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;
|
|
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
|
|
<?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`
|
|
|
|
```html
|
|
<div data-component-id="{componentId}">
|
|
<h2>Counter: {count}</h2>
|
|
<button data-live-action="increment">Increment</button>
|
|
</div>
|
|
```
|
|
|
|
### Step 4: Use in Template
|
|
|
|
```html
|
|
<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')
|
|
|
|
```php
|
|
ComponentId::create('counter', 'demo') // counter:demo
|
|
ComponentId::create('user-profile', '123') // user-profile:123
|
|
```
|
|
|
|
### RenderData
|
|
|
|
`getRenderData()` returns template path and data:
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
#[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]`:
|
|
|
|
```php
|
|
#[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:
|
|
|
|
```php
|
|
#[Action]
|
|
public function addAmount(int $amount): CounterState
|
|
{
|
|
return $this->state->addAmount($amount);
|
|
}
|
|
```
|
|
|
|
**Client-side**:
|
|
```html
|
|
<button data-live-action="addAmount" data-amount="5">
|
|
Add 5
|
|
</button>
|
|
```
|
|
|
|
### Events
|
|
|
|
Dispatch events from actions:
|
|
|
|
```php
|
|
#[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**:
|
|
```javascript
|
|
document.addEventListener('livecomponent:event', (e) => {
|
|
if (e.detail.name === 'counter:changed') {
|
|
console.log('Counter changed:', e.detail.data);
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Advanced Features
|
|
|
|
### Caching
|
|
|
|
Implement `Cacheable` interface:
|
|
|
|
```php
|
|
#[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:
|
|
|
|
```php
|
|
#[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:
|
|
|
|
```php
|
|
#[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**:
|
|
```html
|
|
<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:
|
|
|
|
```php
|
|
#[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**:
|
|
```html
|
|
<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:
|
|
|
|
```php
|
|
#[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**:
|
|
```php
|
|
public function getVaryBy(): ?VaryBy
|
|
{
|
|
return VaryBy::none(); // Same for all users
|
|
}
|
|
```
|
|
|
|
**Per-User Cache**:
|
|
```php
|
|
public function getVaryBy(): ?VaryBy
|
|
{
|
|
return VaryBy::userId(); // Different cache per user
|
|
}
|
|
```
|
|
|
|
**Per-User Per-Locale Cache**:
|
|
```php
|
|
public function getVaryBy(): ?VaryBy
|
|
{
|
|
return VaryBy::userAndLocale(); // Different cache per user and locale
|
|
}
|
|
```
|
|
|
|
**With Feature Flags**:
|
|
```php
|
|
public function getVaryBy(): ?VaryBy
|
|
{
|
|
return VaryBy::userId()
|
|
->withFeatureFlags(['new-ui', 'dark-mode']);
|
|
}
|
|
```
|
|
|
|
### Stale-While-Revalidate
|
|
|
|
Serve stale content while refreshing:
|
|
|
|
```php
|
|
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:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```php
|
|
// In template
|
|
{{ lazy_component('notification-center:user-123', [
|
|
'priority' => 'high',
|
|
'threshold' => '0.1',
|
|
'placeholder' => 'Loading notifications...'
|
|
]) }}
|
|
```
|
|
|
|
---
|
|
|
|
## Real-World Examples
|
|
|
|
### Example 1: User Profile Component
|
|
|
|
```php
|
|
#[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
|
|
|
|
```php
|
|
#[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**:
|
|
```javascript
|
|
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
|
|
|
|
```php
|
|
// 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
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
// ✅ 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:
|
|
|
|
```php
|
|
// ✅ CORRECT
|
|
public function __construct(
|
|
public CounterState $state
|
|
) {}
|
|
|
|
// ❌ WRONG
|
|
public function __construct(
|
|
public array $state
|
|
) {}
|
|
```
|
|
|
|
### 3. Action Validation
|
|
|
|
Validate inputs in actions:
|
|
|
|
```php
|
|
#[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:
|
|
|
|
```php
|
|
#[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](security-guide-complete.md) - CSRF, Rate Limiting, Authorization
|
|
- [Performance Guide](performance-guide-complete.md) - Caching, Batching, Optimization
|
|
- [Upload Guide](upload-guide-complete.md) - File Uploads, Validation, Chunking
|
|
- [API Reference](api-reference-complete.md) - Complete API documentation
|
|
|
|
|
|
|
|
|
|
|