Skip to Content
ModulesHTMX & Yoyo

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 (/live or /admin/live) with middleware web, csrf
  • HtmxMiddleware (alias htmx) will return 404 if request is not HX — use it only on HTMX endpoints
  • ModuleServiceProvider::loadComponents() automatically registers classes from Components/ into Yoyo (kebab-case without Component suffix)
  • 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> @endpush

Advanced 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> @endpush

Components 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> @endpush

Integration with Modal Windows

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

  1. Use caching for frequently requested data
  2. Minify HTML, CSS, and JavaScript
  3. Optimize images before upload
  4. Use lazy loading for large lists

Security

  1. Validate all input on server
  2. Use CSRF tokens for forms
  3. Check permissions before performing actions
  4. Sanitize user input from malicious code

Accessibility

  1. Add ARIA attributes for screen readers
  2. Ensure keyboard navigation
  3. Add alt text for images
  4. Use semantic tags HTML

Maintainability

  1. Separate logic into small components
  2. Use events for communication between components
  3. Document component API
  4. 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.