Overview
Learn how to build a fast, searchable selection modal using Laravel and Alpine.js. This tutorial shows the simple approach that performs well for small to medium datasets.
Tech Stack
- Laravel - Backend framework
- Alpine.js - Lightweight JavaScript reactivity
- Tailwind CSS - Utility-first styling
The Approach
1. Pre-compute Search Data
Do the heavy work once during render:
// Pre-compute search text for each item $searchText = strtolower($item['name'] . ' ' . $item['description']);
2. Alpine.js for Search and Selection
Simple Alpine.js component:
{ search: '', hasResults: true, selectedValue: '', init() { this.$watch('search', () => this.filterItems()); }, filterItems() { const searchLower = this.search.toLowerCase().trim(); const cards = this.$el.querySelectorAll('.item-card'); let visibleCount = 0; cards.forEach(card => { const text = card.dataset.searchText || ''; const isVisible = searchLower === '' || text.includes(searchLower); card.style.display = isVisible ? '' : 'none'; if (isVisible) visibleCount++; }); this.hasResults = visibleCount > 0; } }
3. Basic HTML Structure
<!-- Search input --> <input type="search" x-model="search" placeholder="Search..." /> <!-- Items grid --> <div class="grid gap-4"> <!-- Each item has data-search-text attribute --> <div class="item-card" data-search-text="contact form simple"> <h3>Contact Form</h3> <p>Simple contact form</p> </div> </div> <!-- Empty state --> <div x-show="search !== '' && !hasResults"> <p>No items found</p> <button x-on:click="search = ''">Clear search</button> </div>
Key Benefits
Instant Search Response
- No server requests during search
- Direct DOM manipulation for speed
- Works well for up to 50 items
Progressive Enhancement
- Works without JavaScript (graceful degradation)
- Accessible by default
- Mobile-friendly
Simple Maintenance
- No complex state management
- Easy to debug and extend
- Standard Laravel patterns
Performance Tips
Pre-compute when possible:
// Do this once during render, not during search $searchText = strtolower($title . ' ' . $description);
Use direct DOM manipulation:
// Faster than virtual DOM for small datasets card.style.display = isVisible ? '' : 'none';
Auto-focus for better UX:
this.$nextTick(() => this.$refs.searchInput?.focus());
When to Use This Approach
Perfect for:
- Small to medium datasets (< 50 items)
- Real-time search requirements
- Simple filtering logic
- Laravel applications
Consider alternatives for:
- Large datasets (> 100 items)
- Complex search algorithms
- Heavy data processing
Key Lessons
- Start Simple - Basic DOM manipulation often outperforms complex solutions
- Pre-compute When Possible - Do heavy work once, not repeatedly
- Progressive Enhancement - Build a working baseline first
- Alpine.js Shines - Perfect for form interactions and simple reactivity
Complete Working Example
Here's a full implementation you can copy and adapt:
{{-- Quick test component --}} @php $items = [ 'contact' => ['name' => 'Contact Form', 'description' => 'Simple contact form', 'category' => 'Business'], 'survey' => ['name' => 'Survey Form', 'description' => 'Multi-question survey', 'category' => 'Research'], 'registration' => ['name' => 'Event Registration', 'description' => 'Event signup form', 'category' => 'Events'], 'newsletter' => ['name' => 'Newsletter Signup', 'description' => 'Email subscription form', 'category' => 'Marketing'], 'feedback' => ['name' => 'Feedback Form', 'description' => 'Customer feedback collection', 'category' => 'Support'], ]; @endphp <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Test Searchable Component</title> <script src="https://cdn.tailwindcss.com"></script> <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> </head> <body class="bg-gray-50 min-h-screen"> <div x-data="{ search: '', hasResults: true, selectedValue: '', init() { this.$watch('search', () => this.filterItems()); this.$nextTick(() => this.$refs.searchInput?.focus()); }, filterItems() { const searchLower = this.search.toLowerCase().trim(); const cards = this.$el.querySelectorAll('.item-card'); let visibleCount = 0; cards.forEach(card => { const text = card.dataset.searchText || ''; const isVisible = searchLower === '' || text.includes(searchLower); card.style.display = isVisible ? '' : 'none'; if (isVisible) visibleCount++; }); this.hasResults = visibleCount > 0; } }" class="p-6 max-w-4xl mx-auto" > <h1 class="text-3xl font-bold mb-8 text-gray-800">Test: Real-time Search Component</h1> {{-- Search Input --}} <input type="search" x-model="search" x-ref="searchInput" placeholder="Search items..." class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-lg" /> {{-- Items Grid --}} <div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-8"> @foreach ($items as $value => $item) @php $searchText = strtolower($item['name'] . ' ' . $item['description'] . ' ' . $item['category']); @endphp <label class="item-card cursor-pointer block" data-search-text="{{ $searchText }}" > <input type="radio" name="selected_item" value="{{ $value }}" x-model="selectedValue" class="sr-only" /> <div class="border rounded-xl p-6 transition-all duration-200 hover:shadow-lg" :class="selectedValue === '{{ $value }}' ? 'border-blue-600 bg-blue-50 shadow-lg ring-2 ring-blue-100' : 'border-gray-200 bg-white hover:border-gray-300'" > <h3 class="font-bold text-xl mb-3" :class="selectedValue === '{{ $value }}' ? 'text-blue-900' : 'text-gray-900'">{{ $item['name'] }}</h3> <p class="text-gray-600 mb-3 leading-relaxed">{{ $item['description'] }}</p> <span class="inline-block px-3 py-1 text-sm rounded-full font-medium" :class="selectedValue === '{{ $value }}' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-700'" >{{ $item['category'] }}</span> </div> </label> @endforeach </div> {{-- Empty State --}} <div x-show="search !== '' && !hasResults" class="text-center py-16"> <div class="text-gray-400 mb-6"> <svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> </svg> <p class="text-xl font-semibold text-gray-600 mb-2">No items found</p> <p class="text-gray-500">Try adjusting your search terms</p> </div> <button type="button" x-on:click="search = ''" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium" > Clear search </button> </div> {{-- Results Info --}} <div class="mt-8 p-4 bg-white border border-gray-200 rounded-lg"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div> <strong class="text-gray-700">Current search:</strong> <span class="text-blue-600 font-mono" x-text="search || '(none)'"></span> </div> <div> <strong class="text-gray-700">Has results:</strong> <span :class="hasResults ? 'text-green-600' : 'text-red-600'" x-text="hasResults ? 'Yes' : 'No'"></span> </div> <div> <strong class="text-gray-700">Selected:</strong> <span class="text-blue-600 font-mono" x-text="selectedValue || '(none)'"></span> </div> </div> </div> </div> </body> </html>
How to Use
- Create the component - Save the above code as a Blade component
- Include it - Use
<x-searchable-selector />
in your views - Customize data - Replace the
$items
array with your data - Style it - Adjust Tailwind classes to match your design
Key Implementation Details
Pre-computed search text:
$searchText = strtolower($item['name'] . ' ' . $item['description'] . ' ' . $item['category']);
Alpine.js filtering:
cards.forEach(card => { const text = card.dataset.searchText || ''; const isVisible = searchLower === '' || text.includes(searchLower); card.style.display = isVisible ? '' : 'none'; if (isVisible) visibleCount++; });
Visual selection feedback:
:class="selectedValue === '{{ $value }}' ? 'border-blue-600 bg-blue-50' : 'border-gray-300'"
This approach scales well for typical use cases and can be enhanced later if requirements grow.
This tutorial shows the approach used in FilaForms - Laravel form infrastructure for rapid development.
Top comments (2)
I would not call this real-time search. This is just filtering by input field.
A more user friendly way to do it is to provide categories to filter on.
Good point, David! You’re right — it’s technically filtering, not full search.