HTMX and Yoyo Components
Flute CMS integrates HTMX and Yoyo to create dynamic web applications without writing JavaScript code. HTMX allows updating parts of the page without reloading, while Yoyo provides PHP classes for managing component state.
Core Nuances:
- Yoyo route is registered automatically (
/liveor/admin/live) with middlewareweb, csrf HtmxMiddleware(aliashtmx) will return 404 if request is not HX — use it only on HTMX endpointsModuleServiceProvider::loadComponents()automatically registers classes fromComponents/into Yoyo (kebab-case withoutComponentsuffix)- In admin panel Yoyo is used via
router()->screen()(see admin integration)
HTMX Architecture
Key HTMX Features
<!-- Basic HTMX usage -->
<button hx-get="/api/data"
hx-target="#result"
hx-swap="innerHTML">
Load Data
</button>
<div id="result"></div>HTMX Attributes
<!-- GET request -->
<button hx-get="/articles">Load Articles</button>
<!-- POST request -->
<form hx-post="/articles" hx-target="#articles-list">
<input name="title" type="text">
<button type="submit">Create Article</button>
</form>
<!-- PUT request -->
<button hx-put="/articles/1"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
Update Article
</button>
<!-- DELETE request -->
<button hx-delete="/articles/1"
hx-confirm="Are you sure?">
Delete Article
</button>Targets and Swapping
<!-- Replace entire element -->
<div hx-get="/content" hx-target="#container" hx-swap="innerHTML">
<!-- Content -->
</div>
<!-- Add to end -->
<div hx-get="/items" hx-target="#list" hx-swap="beforeend">
<ul id="list">
<li>Existing Item</li>
</ul>
</div>
<!-- Add to beginning -->
<div hx-get="/messages" hx-target="#chat" hx-swap="afterbegin">
<div id="chat">
<div class="message">Old Message</div>
</div>
</div>
<!-- Replace text only -->
<span hx-get="/time" hx-target="#time" hx-swap="text">
<span id="time">Loading...</span>
</span>For HTMX handlers, add htmx middleware to protect them from direct access.
Yoyo Components
Creating a Yoyo Component
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ArticleLikeComponent extends FluteComponent
{
public int $articleId;
public int $likesCount = 0;
public bool $isLiked = false;
public function mount(int $articleId)
{
$this->articleId = $articleId;
$this->loadLikeData();
}
public function like()
{
$userId = user()->id;
if ($this->isLiked) {
// Remove like
rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class)
->select()
->where('article_id', $this->articleId)
->where('user_id', $userId)
->delete();
$this->likesCount--;
$this->isLiked = false;
} else {
// Add like
$like = new \Flute\Modules\Blog\Database\Entities\ArticleLike();
$like->article_id = $this->articleId;
$like->user_id = $userId;
$like->created_at = now();
transaction($like)->run();
$this->likesCount++;
$this->isLiked = true;
}
// Emit event
$this->emitEvent('article-liked', [
'article_id' => $this->articleId,
'liked' => $this->isLiked,
'likes_count' => $this->likesCount
]);
}
protected function loadLikeData()
{
$userId = user()->id;
if ($userId) {
$like = rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class)
->select()
->where('article_id', $this->articleId)
->where('user_id', $userId)
->fetchOne();
$this->isLiked = $like !== null;
}
$this->likesCount = rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class)
->select()
->where('article_id', $this->articleId)
->count();
}
public function render()
{
return $this->view('blog::components.article-like', [
'article_id' => $this->articleId,
'likes_count' => $this->likesCount,
'is_liked' => $this->isLiked
]);
}
}FluteComponent adds validator integration ($this->validate(...), $this->getValidatorErrors()), throttling via TooManyRequestsException, and convenient methods confirm()/emit().
Yoyo Component Template
{{-- resources/views/components/article-like.blade.php --}}
@php
$buttonClass = $is_liked ? 'btn-danger' : 'btn-outline-danger';
$iconClass = $is_liked ? 'heart-filled' : 'heart';
@endphp
<div class="article-like-component">
@auth
<form method="POST" class="d-inline">
@csrf
<button type="submit"
class="btn {{ $buttonClass }} btn-sm like-button"
yoyo:post="like()"
yoyo:vals="{}"
data-article-id="{{ $article_id }}">
<i class="{{ $iconClass }}"></i>
<span class="likes-count">{{ $likes_count }}</span>
</button>
</form>
@else
<span class="text-muted">
<i class="far fa-heart"></i>
<span class="likes-count">{{ $likes_count }}</span>
</span>
@endauth
</div>
@push('scripts')
<script>
// Component event handling
document.addEventListener('article-liked', function(event) {
const data = event.detail;
console.log('Article liked:', data);
});
</script>
@endpushAdvanced HTMX Features
Headers and Query Parameters
<!-- Sending headers -->
<button hx-get="/api/user/profile"
hx-headers='{"Authorization": "Bearer {{ auth()->token() }}"}'>
Load Profile
</button>
<!-- Sending additional data -->
<button hx-post="/api/articles/search"
hx-vals='{"category": "tech", "sort": "newest"}'>
Search Articles
</button>
<!-- Dynamic headers -->
<div hx-get="/api/notifications"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
</div>Handling Responses
<!-- Handling successful response -->
<button hx-post="/api/contact"
hx-target="#contact-form"
hx-swap="innerHTML"
hx-on:htmx:after-request="handleContactResponse(event)">
Send
</button>
<!-- Handling errors -->
<form hx-post="/api/login"
hx-target="#login-result"
hx-on:htmx:response-error="handleLoginError(event)">
<!-- Form fields -->
</form>
<script>
function handleContactResponse(event) {
if (event.detail.success) {
showNotification('Message sent!', 'success');
}
}
function handleLoginError(event) {
const error = event.detail.error;
showNotification(error, 'error');
}
</script>Synchronization and Queues
<!-- Synchronizing multiple requests -->
<button hx-get="/api/data1" hx-sync="this:replace">Load 1</button>
<button hx-get="/api/data2" hx-sync="this:replace">Load 2</button>
<!-- Request queues -->
<div hx-get="/api/slow-operation"
hx-queue="first"
hx-target="#result">
Slow Operation
</div>
<div hx-get="/api/fast-operation"
hx-queue="last"
hx-target="#result">
Fast Operation
</div>Components with Forms
Article Creation Form
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ArticleFormComponent extends FluteComponent
{
public ?int $articleId = null;
public string $title = '';
public string $content = '';
public ?int $categoryId = null;
public array $tags = [];
public bool $published = false;
public function mount(?int $articleId = null)
{
$this->articleId = $articleId;
if ($articleId) {
$this->loadArticle();
}
$this->loadCategories();
}
public function save()
{
$this->validateForm();
try {
if ($this->articleId) {
$this->updateArticle();
$message = __('blog.article_updated');
} else {
$this->createArticle();
$message = __('blog.article_created');
}
$this->flashMessage($message, 'success');
// Redirect or update component
$this->emitEvent('article-saved', [
'article_id' => $this->articleId
]);
} catch (\Exception $e) {
$this->flashMessage(__('blog.save_failed'), 'error');
}
}
public function addTag($tagName)
{
if (!in_array($tagName, $this->tags)) {
$this->tags[] = $tagName;
}
}
public function removeTag($tagIndex)
{
if (isset($this->tags[$tagIndex])) {
unset($this->tags[$tagIndex]);
$this->tags = array_values($this->tags); // Reindex
}
}
protected function validateForm()
{
$rules = [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10',
'category_id' => 'nullable|integer|exists:blog_categories,id',
'tags' => 'array|max:10',
'tags.*' => 'string|max-str-len:50'
];
$isValid = $this->validate($rules);
if (!$isValid) {
throw new \Exception('Validation failed');
}
}
protected function createArticle()
{
$article = new \Flute\Modules\Blog\Database\Entities\Article();
$article->title = $this->title;
$article->content = $this->content;
$article->category_id = $this->categoryId;
$article->published = $this->published;
$article->author_id = user()->id;
$article->created_at = now();
$article->updated_at = now();
transaction($article)->run();
// Save tags
$this->saveTags($article->id);
$this->articleId = $article->id;
}
protected function updateArticle()
{
$article = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->findByPK($this->articleId);
if (!$article) {
throw new \Exception('Article not found');
}
$article->title = $this->title;
$article->content = $this->content;
$article->category_id = $this->categoryId;
$article->published = $this->published;
$article->updated_at = now();
transaction($article)->run();
// Update tags
$this->saveTags($this->articleId);
}
protected function saveTags($articleId)
{
// Delete old tags
rep(\Flute\Modules\Blog\Database\Entities\ArticleTag::class)
->select()
->where('article_id', $articleId)
->delete();
// Add new tags
foreach ($this->tags as $tagName) {
$tag = rep(\Flute\Modules\Blog\Database\Entities\Tag::class)
->select()
->where('name', $tagName)
->fetchOne();
if (!$tag) {
$tag = new \Flute\Modules\Blog\Database\Entities\Tag();
$tag->name = $tagName;
$tag->slug = Str::slug($tagName);
transaction($tag)->run();
}
$articleTag = new \Flute\Modules\Blog\Database\Entities\ArticleTag();
$articleTag->article_id = $articleId;
$articleTag->tag_id = $tag->id;
transaction($articleTag)->run();
}
}
protected function loadArticle()
{
$article = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->findByPK($this->articleId);
if ($article) {
$this->title = $article->title;
$this->content = $article->content;
$this->categoryId = $article->category_id;
$this->published = $article->published;
// Load tags
$this->tags = $article->tags->pluck('name')->toArray();
}
}
protected function loadCategories()
{
// Categories are loaded in template via service
}
public function render()
{
$categories = app(\Flute\Modules\Blog\Services\CategoryService::class)->getAllCategories();
return $this->view('blog::components.article-form', [
'article_id' => $this->articleId,
'title' => $this->title,
'content' => $this->content,
'category_id' => $this->categoryId,
'tags' => $this->tags,
'published' => $this->published,
'categories' => $categories,
'is_edit' => $this->articleId !== null
]);
}
}Article Form Template
{{-- resources/views/components/article-form.blade.php --}}
<x-card class="article-form-component">
<form class="article-form">
@csrf
{{-- Title --}}
<x-forms.field class="mb-3">
<x-forms.label for="title" required>{{ __('blog.title') }}</x-forms.label>
<x-input name="title" id="title" :value="old('title', $title)" yoyo yoyo:trigger="blur" required />
@error('title')
<div class="text-danger">{{ $message }}</div>
@enderror
</x-forms.field>
{{-- Category --}}
<x-forms.field class="mb-3">
<x-forms.label for="category_id">{{ __('blog.category') }}</x-forms.label>
<select id="category_id" name="category_id" class="form-select" yoyo yoyo:trigger="change">
<option value="">{{ __('blog.select_category') }}</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ $category_id == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</x-forms.field>
{{-- Tags --}}
<x-forms.field class="mb-3">
<x-forms.label>{{ __('blog.tags') }}</x-forms.label>
<div class="tags-input">
<div class="tags-list">
@foreach($tags as $index => $tag)
<span class="badge bg-primary me-1">
{{ $tag }}
<button type="button" class="btn-close btn-close-white ms-1"
yoyo:click="removeTag({{ $index }})" aria-label="Remove tag"></button>
</span>
@endforeach
</div>
<x-input id="tag-input" type="text" class="mt-2"
:placeholder="__('blog.add_tag')" onkeypress="handleTagInput(event)" />
</div>
</x-forms.field>
{{-- Content --}}
<x-forms.field class="mb-3">
<x-forms.label for="content" required>{{ __('blog.content') }}</x-forms.label>
<textarea id="content" name="content" class="form-control" rows="10"
yoyo yoyo:trigger="blur" required>{{ old('content', $content) }}</textarea>
@error('content')
<div class="text-danger">{{ $message }}</div>
@enderror
</x-forms.field>
{{-- Published --}}
<x-forms.field class="mb-3">
<div class="form-check">
<input type="checkbox" id="published" name="published" class="form-check-input"
{{ $published ? 'checked' : '' }} yoyo yoyo:trigger="change">
<label for="published" class="form-check-label">{{ __('blog.publish_immediately') }}</label>
</div>
</x-forms.field>
{{-- Actions --}}
<div class="form-actions">
<button type="submit" class="btn btn-primary" yoyo:click="save()">
<x-icon path="save" />
{{ $is_edit ? __('blog.update_article') : __('blog.create_article') }}
</button>
<button type="button" class="btn btn-secondary ms-2" onclick="window.history.back()">
{{ __('blog.cancel') }}
</button>
</div>
</form>
</x-card>
@push('scripts')
<script>
function handleTagInput(event) {
if (event.key === 'Enter') {
event.preventDefault();
const tagName = event.target.value.trim();
if (tagName) {
// Send event to add tag
htmx.ajax('POST', window.location.href, {
values: { action: 'add_tag', tag_name: tagName },
target: '#tag-input',
swap: 'none'
});
event.target.value = '';
}
}
}
// Initialize editor (if used)
document.addEventListener('DOMContentLoaded', function() {
if (typeof ClassicEditor !== 'undefined') {
ClassicEditor
.create(document.querySelector('#content'))
.catch(error => {
console.error(error);
});
}
});
</script>
@endpushComponents with File Upload
Image Upload Component
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ImageUploadComponent extends FluteComponent
{
public array $images = [];
public string $uploadPath = 'uploads/blog/images/';
public array $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
public int $maxFileSize = 5 * 1024 * 1024; // 5MB
public int $maxFiles = 10;
public function upload($files)
{
$uploadedFiles = [];
foreach ($files as $file) {
try {
$uploadedFile = $this->processFile($file);
$uploadedFiles[] = $uploadedFile;
} catch (\Exception $e) {
$this->flashMessage($e->getMessage(), 'error');
}
}
if (!empty($uploadedFiles)) {
$this->images = array_merge($this->images, $uploadedFiles);
$this->emitEvent('images-uploaded', [
'uploaded' => $uploadedFiles,
'total' => count($this->images)
]);
}
}
public function removeImage($index)
{
if (isset($this->images[$index])) {
$image = $this->images[$index];
// Delete file
if (file_exists(public_path($image['path']))) {
unlink(public_path($image['path']));
}
// Remove from array
unset($this->images[$index]);
$this->images = array_values($this->images);
$this->emitEvent('image-removed', [
'removed' => $image,
'total' => count($this->images)
]);
}
}
protected function processFile($file)
{
// Validate file type
if (!in_array($file['type'], $this->allowedTypes)) {
throw new \Exception(__('blog.invalid_file_type'));
}
// Validate file size
if ($file['size'] > $this->maxFileSize) {
throw new \Exception(__('blog.file_too_large'));
}
// Check file count
if (count($this->images) >= $this->maxFiles) {
throw new \Exception(__('blog.too_many_files'));
}
// Generate filename
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = uniqid('blog_') . '.' . $extension;
$fullPath = $this->uploadPath . $filename;
// Create directory
$directory = dirname(public_path($fullPath));
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
// Move file
if (!move_uploaded_file($file['tmp_name'], public_path($fullPath))) {
throw new \Exception(__('blog.upload_failed'));
}
// Get image dimensions
$imageInfo = getimagesize(public_path($fullPath));
$dimensions = $imageInfo ? $imageInfo[0] . 'x' . $imageInfo[1] : null;
return [
'name' => $file['name'],
'filename' => $filename,
'path' => $fullPath,
'size' => $file['size'],
'type' => $file['type'],
'dimensions' => $dimensions,
'uploaded_at' => now()->toISOString()
];
}
public function render()
{
return $this->view('blog::components.image-upload', [
'images' => $this->images,
'max_files' => $this->maxFiles,
'allowed_types' => implode(', ', array_map(function($type) {
return str_replace('image/', '.', $type);
}, $this->allowedTypes)),
'max_file_size' => $this->formatFileSize($this->maxFileSize)
]);
}
protected function formatFileSize($bytes)
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
}Image Upload Template
{{-- resources/views/components/image-upload.blade.php --}}
<div class="image-upload-component">
{{-- Uploaded Images --}}
@if(count($images) > 0)
<div class="uploaded-images mb-3">
<h6>{{ __('blog.uploaded_images') }} ({{ count($images) }}/{{ $max_files }})</h6>
<div class="images-grid">
@foreach($images as $index => $image)
<div class="image-item">
<div class="image-preview">
<img src="{{ asset($image['path']) }}"
alt="{{ $image['name'] }}"
class="img-thumbnail">
<button type="button"
class="btn btn-danger btn-sm remove-btn"
yoyo:click="removeImage({{ $index }})"
title="{{ __('blog.remove_image') }}">
<x-icon path="times" />
</button>
</div>
<div class="image-info small text-muted">
<div>{{ Str::limit($image['name'], 20) }}</div>
@if($image['dimensions'])
<div>{{ $image['dimensions'] }}</div>
@endif
<div>{{ number_format($image['size'] / 1024, 1) }} KB</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
{{-- Upload Zone --}}
<div class="upload-zone"
hx-post="{{ route('blog.images.upload') }}"
hx-target="#upload-result"
hx-swap="innerHTML">
<input type="file"
name="images[]"
id="image-input"
multiple
accept="{{ $allowed_types }}"
style="display: none;">
<div class="upload-area" onclick="document.getElementById('image-input').click()">
<div class="upload-icon">
<x-icon path="cloud-upload-alt" class="fa-3x text-muted" />
</div>
<div class="upload-text">
<h6>{{ __('blog.drop_images_here') }}</h6>
<p class="text-muted small">
{{ __('blog.or_click_to_select') }}<br>
{{ __('blog.max_files') }}: {{ $max_files }}<br>
{{ __('blog.allowed_types') }}: {{ $allowed_types }}<br>
{{ __('blog.max_size') }}: {{ $max_file_size }}
</p>
</div>
</div>
<div id="upload-result"></div>
</div>
</div>
@push('styles')
<style>
.upload-zone {
border: 2px dashed #dee2e6;
border-radius: 0.375rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-zone:hover {
border-color: #0d6efd;
background-color: #f8f9fa;
}
.upload-area {
pointer-events: none;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.image-item {
position: relative;
}
.image-preview {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 0.375rem;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-btn {
position: absolute;
top: 0.25rem;
right: 0.25rem;
padding: 0.125rem 0.25rem;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@endpush
@push('scripts')
<script>
// Drag and drop functionality
document.addEventListener('DOMContentLoaded', function() {
const uploadZone = document.querySelector('.upload-zone');
const fileInput = document.getElementById('image-input');
// Drag and drop events
uploadZone.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('dragover');
});
uploadZone.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
});
// File input change
fileInput.addEventListener('change', function(e) {
const files = Array.from(e.target.files);
handleFiles(files);
});
function handleFiles(files) {
// Filter image files
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length === 0) {
showNotification('{{ __('blog.no_image_files_selected') }}', 'warning');
return;
}
// Upload files using HTMX
const formData = new FormData();
imageFiles.forEach(file => {
formData.append('images[]', file);
});
htmx.ajax('POST', '{{ route('blog.images.upload') }}', {
body: formData,
target: '#upload-result',
swap: 'innerHTML'
});
}
});
// Component event handling
document.addEventListener('images-uploaded', function(event) {
const data = event.detail;
showNotification(`{{ __('blog.images_uploaded') }}: ${data.uploaded.length}`, 'success');
});
document.addEventListener('image-removed', function(event) {
const data = event.detail;
showNotification('{{ __('blog.image_removed') }}', 'info');
});
</script>
@endpushIntegration with Modal Windows
Modal Component
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ArticleQuickViewComponent extends FluteComponent
{
public ?int $articleId = null;
protected ?\Flute\Modules\Blog\Database\Entities\Article $article = null;
public function mount(int $articleId)
{
$this->articleId = $articleId;
$this->loadArticle();
}
public function like()
{
if (!user()->isLoggedIn()) {
$this->flashMessage(__('blog.login_required'), 'warning');
return;
}
// Like logic
$this->toggleLike();
}
public function addToBookmarks()
{
if (!user()->isLoggedIn()) {
$this->flashMessage(__('blog.login_required'), 'warning');
return;
}
// Bookmark logic
$this->toggleBookmark();
}
public function share($platform)
{
$url = route('blog.show', ['slug' => $this->article->slug]);
$title = $this->article->title;
$shareUrl = match($platform) {
'twitter' => "https://twitter.com/intent/tweet?url={$url}&text={$title}",
'facebook' => "https://www.facebook.com/sharer/sharer.php?u={$url}",
'telegram' => "https://t.me/share/url?url={$url}&text={$title}",
default => $url
};
$this->emitEvent('article-shared', [
'platform' => $platform,
'url' => $shareUrl
]);
}
protected function loadArticle()
{
$this->article = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->select()
->where('id', $this->articleId)
->load('author')
->load('category')
->load('tags')
->fetchOne();
}
protected function toggleLike()
{
// Like/dislike logic
}
protected function toggleBookmark()
{
// Bookmark logic
}
public function render()
{
if (!$this->article) {
return $this->view('blog::components.article-not-found');
}
return $this->view('blog::components.article-quick-view', [
'article' => $this->article
]);
}
}Best Practices
Performance
- Use caching for frequently requested data
- Minify HTML, CSS, and JavaScript
- Optimize images before upload
- Use lazy loading for large lists
Security
- Validate all input on server
- Use CSRF tokens for forms
- Check permissions before performing actions
- Sanitize user input from malicious code
Accessibility
- Add ARIA attributes for screen readers
- Ensure keyboard navigation
- Add alt text for images
- Use semantic tags HTML
Maintainability
- Separate logic into small components
- Use events for communication between components
- Document component API
- Write tests for critical functionality
HTMX and Yoyo components allow creating modern, interactive web applications with minimal JavaScript code, ensuring high performance and excellent user experience.