TALL Components
Tailwind, Alpine, Laravel, and Livewire patterns in the SDK.
This guide covers TALL patterns (Tailwind CSS, Alpine.js, Laravel, and Livewire) around the components streams:livewire writes.
Overview
streams:livewire writes a Livewire class and a Blade view. It does not generate controllers or middleware, and it does not install routes. It prints the route lines to add to routes/web.php. Tailwind CSS and Alpine.js on this page are patterns for those views. They are not files the command writes.
Component patterns
Livewire Components
Index Component (List View)
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Streams\Core\Support\Facades\Streams;
class BlogPostIndex extends Component
{
use WithPagination;
public $search = '';
public $sortField = 'created_at';
public $sortDirection = 'desc';
public $perPage = 15;
protected $queryString = [
'search' => ['except' => ''],
'sortField' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortDirection = 'asc';
}
$this->sortField = $field;
}
public function render()
{
$stream = Streams::make('blog_posts');
$entries = $stream->entries()
->when($this->search, function ($query) {
$query->where('title', 'like', '%' . $this->search . '%')
->orWhere('content', 'like', '%' . $this->search . '%');
})
->orderBy($this->sortField, $this->sortDirection)
->paginate($this->perPage);
return view('livewire.blog-post-index', [
'entries' => $entries,
'stream' => $stream,
]);
}
}
Form Component (Create/Edit)
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Streams\Core\Support\Facades\Streams;
class BlogPostForm extends Component
{
use WithFileUploads;
public $entryId;
public $title = '';
public $slug = '';
public $content = '';
public $featured_image;
public $status = 'draft';
public $published_at;
protected $rules = [
'title' => 'required|min:3|max:255',
'slug' => 'required|unique:blog_posts,slug',
'content' => 'required|min:10',
'featured_image' => 'nullable|image|max:2048',
'status' => 'required|in:draft,published,archived',
'published_at' => 'nullable|date',
];
public function mount($entryId = null)
{
if ($entryId) {
$this->entryId = $entryId;
$this->loadEntry();
}
}
public function loadEntry()
{
$stream = Streams::make('blog_posts');
$entry = $stream->repository()->find($this->entryId);
if ($entry) {
$this->fill($entry->toArray());
}
}
public function updatedTitle()
{
if (!$this->entryId) {
$this->slug = str()->slug($this->title);
}
}
public function save()
{
$this->validate();
$stream = Streams::make('blog_posts');
$data = [
'title' => $this->title,
'slug' => $this->slug,
'content' => $this->content,
'status' => $this->status,
'published_at' => $this->published_at,
];
if ($this->featured_image) {
$data['featured_image'] = $this->featured_image->store('blog-images', 'public');
}
if ($this->entryId) {
$entry = $stream->repository()->find($this->entryId);
$entry->update($data);
} else {
$entry = $stream->repository()->create($data);
}
session()->flash('message', 'Blog post saved successfully!');
return redirect()->route('blog-posts.index');
}
public function render()
{
return view('livewire.blog-post-form');
}
}
Blade Templates
Index View
{{-- resources/views/livewire/blog-post-index.blade.php --}}
<div class="space-y-6">
{{-- Header --}}
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Blog Posts</h1>
<p class="mt-1 text-sm text-gray-500">Manage your blog content</p>
</div>
<div class="mt-4 sm:mt-0">
<a href="{{ route('blog-posts.create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
New Post
</a>
</div>
</div>
{{-- Filters --}}
<div class="bg-white shadow rounded-lg p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
<input wire:model.debounce.300ms="search"
type="text"
id="search"
placeholder="Search posts..."
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div>
<label for="perPage" class="block text-sm font-medium text-gray-700">Per Page</label>
<select wire:model="perPage"
id="perPage"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="10">10</option>
<option value="15">15</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
</div>
</div>
{{-- Table --}}
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th wire:click="sortBy('title')"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
<div class="flex items-center space-x-1">
<span>Title</span>
@if($sortField === 'title')
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@if($sortDirection === 'asc')
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
@else
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@endif
</svg>
@endif
</div>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th wire:click="sortBy('published_at')"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
Published At
</th>
<th class="relative px-6 py-3"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($entries as $entry)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
@if($entry->featured_image)
<img class="h-10 w-10 rounded-lg object-cover mr-3"
src="{{ Storage::url($entry->featured_image) }}"
alt="{{ $entry->title }}">
@endif
<div>
<div class="text-sm font-medium text-gray-900">{{ $entry->title }}</div>
<div class="text-sm text-gray-500">{{ $entry->slug }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full
@if($entry->status === 'published') bg-green-100 text-green-800
@elseif($entry->status === 'draft') bg-yellow-100 text-yellow-800
@else bg-gray-100 text-gray-800 @endif">
{{ ucfirst($entry->status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $entry->published_at ? $entry->published_at->format('M j, Y') : 'Not published' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center space-x-2">
<a href="{{ route('blog-posts.show', $entry) }}"
class="text-indigo-600 hover:text-indigo-900">View</a>
<a href="{{ route('blog-posts.edit', $entry) }}"
class="text-indigo-600 hover:text-indigo-900">Edit</a>
<button wire:click="delete({{ $entry->id }})"
wire:confirm="Are you sure you want to delete this post?"
class="text-red-600 hover:text-red-900">Delete</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No blog posts</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new blog post.</p>
<div class="mt-6">
<a href="{{ route('blog-posts.create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700">
New Post
</a>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
<div class="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
{{ $entries->links() }}
</div>
</div>
Form View
{{-- resources/views/livewire/blog-post-form.blade.php --}}
<div class="max-w-4xl mx-auto">
<form wire:submit.prevent="save" class="space-y-6">
{{-- Header --}}
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">
{{ $entryId ? 'Edit Blog Post' : 'Create Blog Post' }}
</h1>
<p class="mt-1 text-sm text-gray-500">
{{ $entryId ? 'Update your blog post information' : 'Create a new blog post' }}
</p>
</div>
<div class="flex space-x-3">
<button type="button"
onclick="history.back()"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700">
<svg wire:loading wire:target="save" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ $entryId ? 'Update' : 'Create' }}
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main Content --}}
<div class="lg:col-span-2 space-y-6">
{{-- Basic Information --}}
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Basic Information</h3>
<div class="space-y-4">
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input wire:model.lazy="title"
type="text"
id="title"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('title') border-red-300 @enderror">
@error('title') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug</label>
<input wire:model="slug"
type="text"
id="slug"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('slug') border-red-300 @enderror">
@error('slug') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="content" class="block text-sm font-medium text-gray-700">Content</label>
<div wire:ignore>
<textarea wire:model.defer="content"
id="content"
rows="10"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('content') border-red-300 @enderror"
x-data="{
init() {
tinymce.init({
selector: '#content',
height: 400,
plugins: 'lists link image code',
toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright | bullist numlist | link image | code',
setup: function (editor) {
editor.on('change', function () {
@this.set('content', editor.getContent());
});
}
});
}
}"></textarea>
</div>
@error('content') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- Sidebar --}}
<div class="space-y-6">
{{-- Status --}}
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Publishing</h3>
<div class="space-y-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
<select wire:model="status"
id="status"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</div>
<div>
<label for="published_at" class="block text-sm font-medium text-gray-700">Publish Date</label>
<input wire:model="published_at"
type="datetime-local"
id="published_at"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
</div>
</div>
{{-- Featured Image --}}
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Featured Image</h3>
<div class="space-y-4">
@if($featured_image)
<div class="relative">
<img src="{{ $featured_image->temporaryUrl() }}"
class="w-full h-32 object-cover rounded-lg">
<button type="button"
wire:click="$set('featured_image', null)"
class="absolute top-2 right-2 bg-red-600 text-white rounded-full p-1 hover:bg-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
@endif
<div>
<label for="featured_image" class="block text-sm font-medium text-gray-700">Upload Image</label>
<input wire:model="featured_image"
type="file"
id="featured_image"
accept="image/*"
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('featured_image') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
</div>
</form>
</div>
Alpine.js Enhancements
Interactive Components
<!-- Dropdown Menu -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900">
Actions
<svg class="ml-1 h-4 w-4" :class="{ 'rotate-180': open }"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="open"
@click.away="open = false"
x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-10">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Edit</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete</a>
</div>
</div>
<!-- Modal -->
<div x-data="{ open: false }">
<button @click="open = true" class="btn-primary">Open Modal</button>
<div x-show="open"
x-transition.opacity
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div @click.away="open = false"
x-transition
class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-medium mb-4">Confirm Action</h3>
<p class="text-gray-600 mb-4">Are you sure you want to proceed?</p>
<div class="flex justify-end space-x-3">
<button @click="open = false" class="btn-secondary">Cancel</button>
<button @click="open = false" class="btn-danger">Confirm</button>
</div>
</div>
</div>
</div>
Form Enhancements
<!-- Auto-save -->
<div x-data="{
saving: false,
saved: false,
autoSave() {
if (this.saving) return;
this.saving = true;
// Livewire save call
setTimeout(() => {
this.saving = false;
this.saved = true;
setTimeout(() => this.saved = false, 2000);
}, 1000);
}
}">
<input @input.debounce.500ms="autoSave()"
class="form-input">
<div class="flex items-center space-x-2">
<span x-show="saving" class="text-yellow-600">Saving...</span>
<span x-show="saved" x-transition class="text-green-600">Saved!</span>
</div>
</div>
<!-- Character Counter -->
<div x-data="{ content: '', maxLength: 255 }">
<textarea x-model="content"
:maxlength="maxLength"
class="form-textarea"></textarea>
<div class="flex justify-between text-sm">
<span :class="{ 'text-red-500': content.length > maxLength * 0.9 }">
<span x-text="content.length"></span>/<span x-text="maxLength"></span> characters
</span>
</div>
</div>
Tailwind CSS Utilities
Component Classes
/* Custom component classes for streams */
.stream-card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow;
}
.stream-table {
@apply min-w-full divide-y divide-gray-200;
}
.stream-table th {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50;
}
.stream-table td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
}
.stream-form {
@apply space-y-6;
}
.stream-form .form-group {
@apply space-y-1;
}
.stream-form label {
@apply block text-sm font-medium text-gray-700;
}
.stream-form input[type="text"],
.stream-form input[type="email"],
.stream-form input[type="password"],
.stream-form textarea,
.stream-form select {
@apply mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm;
}
.btn-primary {
@apply inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500;
}
.btn-secondary {
@apply inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500;
}
.btn-danger {
@apply inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500;
}
Usage with SDK Commands
streams:livewire is the only SDK command that generates components. It writes Livewire 3 classes and Blade views you then own and restyle with the patterns above:
# Index, form, and show components for a stream
php artisan streams:livewire blog_posts
# Or one component at a time
php artisan streams:livewire blog_posts --type=index
php artisan streams:livewire blog_posts --type=form
php artisan streams:livewire blog_posts --type=show
The command prints the routes to add to routes/web.php. There is no streams:tall, streams:blade, or streams:admin command. See the command reference.