Crafting a Dynamic Blog with Laravel, Livewire, and Tailwind CSS: A Step-by-Step Guide

Table of contents
- Overview
- Prerequisites
- Step 1: Make Sure Laravel and Tailwind CSS Are Ready
- Step 2: Configure the Database
- Step 3: Install and Set Up Livewire
- Step 4: Set Up Authentication (The Easy Way!)
- Step 5: Create the Blog Post Model and Migration
- Step 6: Build Interactive Components with Livewire
- Step 7: Build the Create Post Form with Livewire
- Here's your enhanced Posts Index with Pagination implementation:
- Step 8: Posts Index with Pagination
- Step 9: Implement Infinite Scroll with Load More Button
- Key Features:
- How It Works:
- Final Result

Overview
In this tutorial, we’ll focus on one of the most essential aspects of building any web application: form handling in Laravel.
We’ll build a simple but powerful blog feature using Livewire for real-time interactivity and Tailwind CSS for a clean, modern design. The goal here isn’t to walk through every Laravel concept from scratch, but to help you master how to handle forms efficiently using Laravel’s ecosystem.
This guide assumes you’re already familiar with:
Basic PHP and JavaScript
The Laravel framework
How Tailwind CSS works
And you’ve at least heard of Livewire
If that sounds like you, you're in the right place!
Here’s what we’ll cover:
✍️ Creating blog posts with a Livewire-powered form that updates in real time
📝 Displaying user input and saving it to the database
🔐 Restricting post creation to authenticated users using Laravel’s built-in auth
🎨 Styling everything with Tailwind for a responsive and elegant UI
⚡ Handling validation, errors, and feedback dynamically—no JavaScript required
By the end of this tutorial, you’ll be confidently building and managing Laravel forms like a pro.
Prerequisites
Before we dive in, make sure you have the following set up:
✅ Laravel 11.x installed. You can create a new project with:
composer create-project laravel/laravel blog-app
✅ A working database connection—MySQL, SQLite, or any Laravel-supported driver will do.
✅ Basic familiarity with PHP, Laravel, and using Composer.
✅ Node.js and npm installed for compiling Tailwind CSS assets.
✅ A local development server up and running using:
php artisan serve
Note: This tutorial isn’t a full Laravel crash course—so I’ll assume you’re already comfortable with the basics and want to focus specifically on Laravel form handling.
Step 1: Make Sure Laravel and Tailwind CSS Are Ready
Before we start building, let’s make sure your development environment is good to go.
Please confirm that you have:
✅ Laravel 11 or higher already installed
✅ Tailwind CSS properly set up and working within your Laravel project
✅ A local server that runs with:
php artisan serve
This tutorial assumes you're already comfortable with Laravel, Composer, and Tailwind CSS. If everything’s up and running, you're all set to dive in.
Step 2: Configure the Database
We’ll need a working database to store blog posts and user accounts.
Open your project’s
.env
file and update the database section to match your setup. Here’s an example for MySQL:DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=blog DB_USERNAME=root DB_PASSWORD=
Create a database named
blog
in your preferred database client (e.g., MySQL Workbench, TablePlus, or via terminal).Run Laravel’s default migrations to set up the user table:
php artisan migrate
Laravel will automatically create the necessary tables for user authentication.
Step 3: Install and Set Up Livewire
Install Livewire using Composer:
composer require livewire/livewire
Next, update your Blade layout (or create resources/views/layouts/app.blade.php
if it doesn’t exist) and add the necessary Livewire and Vite directives:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blog App</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="antialiased bg-gray-100">
<div class="min-h-screen">
@include('layouts.navigation') <!-- Optional -->
<main class="container mx-auto px-4 py-8">
@yield('content')
</main>
</div>
@livewireScripts
</body>
</html>
Run Laravel Migrations
Laravel comes with default migrations for the users
table. Run them using:
php artisan migrate
Step 4: Set Up Authentication (The Easy Way!)
Laravel makes authentication a snap with Laravel Breeze—a lightweight starter kit. We’ll use Blade (for simple templating) and Livewire (for dynamic components) to build our login, registration, and dashboard flows.
1. Install Laravel Breeze
Run these commands in your terminal:
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run dev
php artisan migrate
What’s happening here?
composer require
adds Breeze to your project (as a dev dependency).breeze:install blade
scaffolds Blade-based auth views (login, register, etc.).npm install
compiles frontend assets (CSS/JS).migrate
creates the necessary database tables (likeusers
).
Now you’ve got a ready-to-use auth system! 🔥
2. Customize the Navigation Bar
Let’s tweak the default navigation (resources/views/layouts/navigation.blade.php
) to include:
A link to create posts (visible only to logged-in users).
Conditional buttons for login/logout.
Here’s the updated code:
<nav class="bg-white shadow">
<div class="container mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<!-- App Name -->
<a href="{{ route('dashboard') }}" class="text-xl font-bold text-gray-800">Blog App</a>
<!-- Navigation Links -->
<div class="space-x-4">
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'text-blue-600' : 'text-gray-600' }} hover:text-blue-600">Dashboard</a>
@auth
<!-- Show "Create Post" and "Log Out" for logged-in users -->
<a href="{{ route('posts.create') }}" class="{{ request()->routeIs('posts.create') ? 'text-blue-600' : 'text-gray-600' }} hover:text-blue-600">Create Post</a>
<form action="{{ route('logout') }}" method="POST" class="inline">
@csrf
<button type="submit" class="text-gray-600 hover:text-blue-600">Log Out</button>
</form>
@else
<!-- Show "Log In" and "Register" for guests -->
<a href="{{ route('login') }}" class="{{ request()->routeIs('login') ? 'text-blue-600' : 'text-gray-600' }} hover:text-blue-600">Log In</a>
<a href="{{ route('register') }}" class="{{ request()->routeIs('register') ? 'text-blue-600' : 'text-gray-600' }} hover:text-blue-600">Register</a>
@endauth
</div>
</div>
</div>
</nav>
Key Improvements:
Added comments to explain each block (helps beginners).
Styled links with hover effects for better UX.
Used
@auth
/@else
to toggle visibility based on login status.
3. Update the Dashboard to Show Posts
Finally, let’s modify the dashboard (resources/views/dashboard.blade.php
) to display posts using a Livewire component:
@extends('layouts.app')
@section('content')
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<!-- Success Message (e.g., after login) -->
@if (session('status'))
<div class="bg-green-100 text-green-800 p-4 rounded mb-4">
{{ session('status') }}
</div>
@endif
<!-- Livewire Component for Posts -->
<livewire:posts-index />
@endsection
Why This Works:
@extends('
layouts.app
')
ensures consistency with your app’s layout.The
<livewire:posts-index />
component will dynamically load posts (we’ll set this up later).Added a session status check to show success messages (like "You’re logged in!").
Here’s your refined section with clear explanations, friendly tone, and confident guidance while keeping it beginner-friendly:
Step 5: Create the Blog Post Model and Migration
Every great blog needs posts! Let’s set up the database structure for our blog posts with Laravel’s powerful Eloquent ORM.
1. Generate the Post Model & Migration
Run this Artisan command:
php artisan make:model Post -m
What this does:
Creates a
Post
model (app/Models/Post.php
).The
-m
flag generates a migration file (to define the database table).
2. Define the Posts Table Structure
Open the new migration file (database/migrations/xxxx_create_posts_table.php
) and update it:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePostsTable extends Migration
{
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id(); // Auto-incrementing ID
$table->foreignId('user_id')->constrained()->onDelete('cascade'); // Links to author
$table->string('title'); // Post title
$table->text('content'); // Post content
$table->timestamps(); // Adds created_at and updated_at
});
}
public function down()
{
Schema::dropIfExists('posts'); // Drops table if migration is rolled back
}
}
Key Points:
user_id
links posts to their authors (withconstrained()
for foreign key integrity).onDelete('cascade')
ensures posts are deleted if their author is deleted.timestamps()
automatically tracks when posts are created/updated.
3. Run the Migration
Execute this command to create the posts
table:
php artisan migrate
✅ Done! Your database now has a posts
table ready to store blog content.
4. Set Up the Post Model
Update app/Models/Post.php
to:
Allow mass assignment (for easy post creation).
Define the relationship with the
User
model.
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
// Fields that can be filled via create() or update()
protected $fillable = ['title', 'content', 'user_id'];
// Relationship: Each post belongs to a user
public function user()
{
return $this->belongsTo(User::class);
}
}
Why This Matters:
fillable
ensures only safe fields can be mass-assigned (security best practice).The
user()
method lets you easily fetch the author of a post (e.g.,$post->user->name
).
Step 6: Build Interactive Components with Livewire
Livewire makes it incredibly easy to create dynamic interfaces without writing complex JavaScript. Let's build two key components for our blog:
1. Generate the Livewire Components
Run these commands to create our components:
php artisan make:livewire CreatePost
php artisan make:livewire PostsIndex
What this does:
Creates component files in
app/Http/Livewire
Generates corresponding Blade views in
resources/views/livewire
Handles all the boilerplate so we can focus on functionality
2. Set Up Component Routes
Add these to your routes/web.php
:
use App\Http\Livewire\CreatePost;
use App\Http\Livewire\PostsIndex;
// Protected route for post creation
Route::get('/posts/create', CreatePost::class)
->name('posts.create')
->middleware('auth'); // Only logged-in users can access
// Public route to view all posts
Route::get('/posts', PostsIndex::class)
->name('posts.index');
Key Security Note: We've added the auth
middleware to the create route to prevent unauthorized post creation, while keeping the posts listing publicly accessible.
Component Structure Created:
app/
Http/
Livewire/
CreatePost.php
PostsIndex.php
resources/
views/
livewire/
create-post.blade.php
posts-index.blade.php
Why This Matters:
Each component has its own class and view
The routing is clean and follows Laravel conventions
Authentication is built right into the route definition
Pro Tip: You can view all your routes with:
php artisan route:list
Next, we'll dive into building out the actual functionality for these components. Would you like me to help refine those sections as well?
Step 7: Build the Create Post Form with Livewire
Let's create an interactive post creation form that validates input, saves to the database, and provides real-time feedback - all without writing JavaScript!
1. The CreatePost Component
In app/Livewire/CreatePost.php
:
namespace App\Livewire;
use App\Models\Post;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
class CreatePost extends Component
{
// Form fields
public $title = '';
public $content = '';
// Feedback messages
public $successMessage = '';
public $submittedData = [];
// Validation rules
protected $rules = [
'title' => 'required|string|max:255',
'content' => 'required|string',
];
public function submit()
{
// Validate input
$this->validate();
// Log creation (optional but useful for debugging)
Log::info('New blog post created', [
'title' => $this->title,
'user' => auth()->user()->name
]);
// Create and save post
$post = Post::create([
'title' => $this->title,
'content' => $this->content,
'user_id' => auth()->id(),
]);
// Store submitted data for display
$this->submittedData = $post->only(['title', 'content']);
// Show success message
$this->successMessage = 'Your post has been published!';
// Reset form fields
$this->reset(['title', 'content']);
// Notify other components
$this->dispatch('post-created');
}
public function render()
{
return view('livewire.create-post')
->layout('layouts.app');
}
}
Key Features:
Real-time validation with clear error messages
Automatic user assignment via
auth()->id()
Activity logging for debugging
Event dispatching to update other components
Clean form reset after submission
2. The CreatePost View
In resources/views/livewire/create-post.blade.php
:
<div class="max-w-2xl mx-auto p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-800">Share Your Thoughts</h2>
<!-- Success Message -->
@if($successMessage)
<div class="mb-6 p-4 bg-green-50 text-green-800 rounded-lg">
✅ {{ $successMessage }}
</div>
@endif
<!-- Submitted Data Preview -->
@if($submittedData)
<div class="mb-6 p-4 bg-blue-50 text-blue-800 rounded-lg">
<h3 class="font-semibold mb-2">Your Published Post:</h3>
<p><span class="font-medium">Title:</span> {{ $submittedData['title'] }}</p>
<p class="mt-2"><span class="font-medium">Content:</span> {{ $submittedData['content'] }}</p>
</div>
@endif
<!-- Validation Errors -->
@error('title') <div class="mb-4 p-2 bg-red-50 text-red-800 rounded">{{ $message }}</div> @enderror
@error('content') <div class="mb-4 p-2 bg-red-50 text-red-800 rounded">{{ $message }}</div> @enderror
<!-- Creation Form -->
<form wire:submit.prevent="submit" class="space-y-6">
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Post Title</label>
<input type="text" wire:model="title" id="title"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label for="content" class="block text-sm font-medium text-gray-700 mb-1">Your Content</label>
<textarea wire:model="content" id="content" rows="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
Publish Post
</button>
</form>
</div>
Why This Works Well:
Clear Visual Hierarchy:
Success messages stand out with green background
Form sections are well-spaced
Error messages appear near relevant fields
User-Friendly Interactions:
Real-time validation as users type
Persistent success message after submission
Form automatically resets for new posts
Preview of published content
Professional Styling:
Consistent spacing and padding
Focus states for better accessibility
Hover effects on interactive elements
Pro Tip: Add character counters for title and content to improve UX:
<div class="text-xs text-gray-500 text-right">
{{ strlen($title) }}/255 characters
</div>
Here's your refined Posts Index section with improved clarity, engagement, and practical enhancements:
Here's your enhanced Posts Index with Pagination implementation:
Step 8: Posts Index with Pagination
1. Updated PostsIndex Component
namespace App\Livewire;
use App\Models\Post;
use Livewire\Component;
use Livewire\WithPagination;
class PostsIndex extends Component
{
use WithPagination; // Enable Livewire pagination
protected $listeners = ['postCreated' => '$refresh'];
public $perPage = 6; // Items per page
// Load more posts
public function loadMore()
{
$this->perPage += 6;
}
public function render()
{
return view('livewire.posts-index', [
'posts' => Post::with('user')
->latest()
->paginate($this->perPage)
])->layout('layouts.app');
}
}
2. Enhanced PostsIndex View
<div class="container mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-8 text-gray-800">Latest Blog Posts</h2>
@if($posts->isEmpty())
<div class="bg-blue-50 text-blue-800 p-6 rounded-lg text-center">
No posts yet. Be the first to share your thoughts!
</div>
@else
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
@foreach($posts as $post)
<article class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div class="p-6">
<h3 class="text-xl font-bold mb-2 text-gray-900">
{{ $post->title }}
</h3>
<p class="text-gray-600 mb-4">
{{ Str::limit($post->content, 120) }}
</p>
<div class="flex items-center text-sm text-gray-500">
<span class="mr-2">📅</span>
{{ $post->created_at->format('F j, Y') }}
<span class="mx-2">•</span>
<span class="text-blue-600">
👤 {{ $post->user->name }}
</span>
</div>
</div>
</article>
@endforeach
</div>
<!-- Pagination/Load More -->
<div class="mt-8 flex justify-center">
@if($posts->hasMorePages())
<button
wire:click="loadMore"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Load More Posts
</button>
@else
<p class="text-gray-500">You've reached the end!</p>
@endif
</div>
@endif
</div>
Key Features Added:
Pagination System:
Uses Livewire's
WithPagination
traitImplements "Load More" pattern instead of traditional pagination links
Initial load shows 6 posts, clicking loads 6 more
Performance Benefits:
Only loads visible posts
No full page reloads
Maintains scroll position when loading more
User Experience:
Clear "Load More" button
End state message when all posts are loaded
Smooth loading without page jumps
Technical Improvements:
Uses Livewire's built-in pagination methods
Maintains all existing features (real-time updates, etc.)
Fully responsive design
Pro Tip: For very large sites, consider adding a loading indicator:
<button wire:click="loadMore" wire:loading.attr="disabled">
<span wire:loading.remove>Load More</span>
<span wire:loading>Loading...</span>
</button>
Step 9: Implement Infinite Scroll with Load More Button
In this section, we'll enhance our PostsIndex component to support both manual "Load More" button clicks and automatic loading when scrolling to the bottom (infinite scroll). This provides a seamless user experience while maintaining control.
1. Update the Livewire Component
First, modify app/Http/Livewire/PostsIndex.php
:
namespace App\Http\Livewire;
use App\Models\Post;
use Livewire\Component;
use Livewire\WithPagination;
class PostsIndex extends Component
{
use WithPagination;
public $perPage = 6; // Initial number of posts to load
public $loading = false; // Track loading state
public $hasMorePosts = true; // Check if more posts exist
protected $listeners = ['postCreated' => '$refresh'];
// Load more posts when "Load More" is clicked or on scroll
public function loadMore()
{
if (!$this->hasMorePosts) return;
$this->loading = true;
$this->perPage += 6;
$this->checkForMorePosts();
$this->loading = false;
}
// Check if there are more posts to load
public function checkForMorePosts()
{
$totalPosts = Post::count();
$this->hasMorePosts = $totalPosts > $this->perPage;
}
// Initialize infinite scroll on component mount
public function mount()
{
$this->checkForMorePosts();
}
public function render()
{
$posts = Post::with('user')
->latest()
->paginate($this->perPage);
return view('livewire.posts-index', [
'posts' => $posts,
])->layout('layouts.app');
}
}
Key Features:
✅ Manual "Load More" Button – Users can click to load more posts.
✅ Automatic Infinite Scroll – Loads more posts when scrolling to the bottom.
✅ Loading State Management – Prevents duplicate requests.
✅ Efficiency Check – Stops loading when no more posts are available.
2. Update the Blade View for Infinite Scroll
Modify resources/views/livewire/posts-index.blade.php
:
<div
x-data="{
observeScroll() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !@this.loading && @this.hasMorePosts) {
@this.loadMore();
}
});
}, { threshold: 0.5 });
observer.observe(this.$el);
}
}"
x-init="observeScroll"
wire:key="posts-list"
>
<h2 class="text-3xl font-bold mb-8 text-gray-800">Latest Posts</h2>
@if($posts->isEmpty())
<div class="bg-blue-50 text-blue-800 p-6 rounded-lg text-center">
No posts yet. Be the first to share your thoughts!
</div>
@else
<!-- Posts Grid -->
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
@foreach($posts as $post)
<article class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div class="p-6">
<h3 class="text-xl font-bold mb-2">{{ $post->title }}</h3>
<p class="text-gray-600 mb-4">{{ Str::limit($post->content, 120) }}</p>
<div class="text-sm text-gray-500">
Posted by {{ $post->user->name }} on {{ $post->created_at->format('M d, Y') }}
</div>
</div>
</article>
@endforeach
</div>
<!-- Load More Button (Manual) -->
<div class="mt-8 flex justify-center">
@if($hasMorePosts)
<button
wire:click="loadMore"
wire:loading.attr="disabled"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<span wire:loading.remove>Load More Posts</span>
<span wire:loading>Loading...</span>
</button>
@else
<p class="text-gray-500">You've seen all posts!</p>
@endif
</div>
<!-- Loading Spinner (Auto Infinite Scroll) -->
<div wire:loading class="my-6 flex justify-center">
<svg class="animate-spin h-8 w-8 text-blue-500" xmlns="http://www.w3.org/2000/svg" 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>
</div>
@endif
</div>
How It Works:
🔹 Intersection Observer API (x-data
): Detects when the user scrolls near the bottom.
🔹 Manual "Load More" Button: Gives users control over loading.
🔹 Loading States: Spinner appears during loading.
🔹 Optimized Performance: No unnecessary requests when no posts remain.
3. Add AlpineJS (If Not Already Included)
Ensure AlpineJS is loaded in your layout (resources/views/layouts/app.blade.php
):
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
Final Result
✅ Manual Loading: Users can click "Load More" to fetch posts.
✅ Automatic Infinite Scroll: Loads posts when scrolling to the bottom.
✅ Efficient Loading: No duplicate requests, stops when no posts left.
✅ Great UX: Loading spinners and clear end state.
Now your blog has both infinite scroll and a manual load button for the best user experience! 🚀
Here's your enhanced Logging Input section with improved clarity, practical examples, and additional best practices:
Step 10: Implement Effective Logging in Laravel
Logging is crucial for debugging and monitoring your application. Let's enhance our post creation with proper logging practices.
1. Basic Logging Implementation
In your CreatePost
Livewire component:
use Illuminate\Support\Facades\Log;
// ...
public function submit()
{
$this->validate();
// Basic logging
Log::info('New blog post created', [
'title' => $this->title,
'content_length' => strlen($this->content), // Additional useful metric
'user_id' => auth()->id(),
'ip_address' => request()->ip(), // Helpful for security
]);
// ... rest of your submission logic
}
Key Points:
Logs are stored in
storage/logs/laravel.log
by defaultView logs in real-time with:
tail -f storage/logs/laravel.log
Each log entry includes:
Timestamp
Log level (info, warning, error)
Contextual data
2. Creating a Dedicated Log Channel
For better organization, create a separate log file for blog-related activities:
- Update
config/logging.php
:
'channels' => [
'blog' => [
'driver' => 'daily', // Rotates logs daily
'path' => storage_path('logs/blog.log'),
'level' => 'info',
'days' => 14, // Keeps logs for 2 weeks
],
]
- Use the custom channel in your component:
Log::channel('blog')->info('Post created', [
'title' => $this->title,
'user' => auth()->user()->email, // More identifiable than ID
'time_taken' => now()->diffInMilliseconds($startTime).'ms', // Performance metric
]);
3. Advanced Logging Techniques
A. Error Handling with Try/Catch
try {
$post = Post::create([...]);
} catch (\Exception $e) {
Log::channel('blog')->error('Post creation failed', [
'error' => $e->getMessage(),
'input' => $this->only(['title', 'content']),
]);
throw $e; // Re-throw after logging
}
B. Conditional Debug Logging
if (config('app.debug')) {
Log::debug('Post creation debug info', [
'full_request' => request()->all(),
'memory_usage' => memory_get_usage(),
]);
}
C. Monitoring Important Events
// In your Post model:
protected static function booted()
{
static::created(function ($post) {
Log::channel('blog')->notice('New post published', [
'post_id' => $post->id,
'word_count' => str_word_count($post->content),
]);
});
}
4. Viewing and Managing Logs
Useful Commands:
# View last 100 lines
tail -n 100 storage/logs/blog.log
# Search for errors
grep "ERROR" storage/logs/blog.log
# Monitor in real-time (with color)
tail -f storage/logs/blog.log | ccze -A
For Production:
Consider using tools like:
Laravel Telescope (local)
Papertrail (cloud)
Datadog (enterprise)
Best Practices
Don't log sensitive data (passwords, credit cards)
Use appropriate log levels:
debug
- Detailed debug informationinfo
- Interesting eventsnotice
- Normal but significant eventswarning
- Exceptional occurrenceserror
- Runtime errors
Rotate logs regularly to prevent large files
Include context for easier debugging
Here's your enhanced Displaying Input on Screen section with improved UX, security considerations, and additional features:
Step 11: Display Submitted Data with Enhanced UX
Let's improve how we show submitted posts to users with better formatting, security, and interactivity.
1. Enhanced Component Logic
Update your CreatePost.php
:
public $submittedData = null; // Initialize as null instead of empty array
public $showPreview = false; // Control preview visibility
protected $rules = [
'title' => 'required|string|max:255',
'content' => 'required|string|min:50|max:5000', // Added validation
];
public function submit()
{
$this->validate();
// Store before creation in case of failure
$this->submittedData = [
'title' => e($this->title), // Escape output
'content' => e($this->content),
'created_at' => now()->format('M j, Y \a\t g:i a'),
'word_count' => str_word_count($this->content),
];
try {
Post::create([...]);
$this->showPreview = true;
} catch (\Exception $e) {
$this->submittedData['error'] = 'Failed to save post';
Log::error(...);
}
}
public function resetForm()
{
$this->reset(['title', 'content', 'submittedData']);
$this->showPreview = false;
}
2. Enhanced Blade View
<!-- Success Message -->
@if(session('post_created'))
<div class="mb-6 p-4 bg-green-50 border-l-4 border-green-500">
<div class="flex items-center">
<svg class="h-5 w-5 text-green-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<p class="text-green-800 font-medium">Post published successfully!</p>
</div>
</div>
@endif
<!-- Submitted Data Preview -->
@if($showPreview && $submittedData)
<div class="mb-6 bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div class="p-5">
<div class="flex justify-between items-start">
<h3 class="text-lg font-bold text-gray-900 mb-2">
{{ $submittedData['title'] }}
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Preview
</span>
</div>
<div class="prose max-w-none text-gray-600 mb-4">
{!! nl2br($submittedData['content']) !!}
</div>
<div class="flex items-center justify-between text-sm text-gray-500">
<span>📝 {{ $submittedData['word_count'] }} words</span>
<span>⏱ {{ $submittedData['created_at'] }}</span>
</div>
</div>
<div class="bg-gray-50 px-5 py-3 flex justify-end space-x-3">
<button
wire:click="resetForm"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Create Another Post
</button>
<a
href="{{ route('posts.index') }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
View All Posts
</a>
</div>
</div>
@endif
<!-- Error State -->
@if($submittedData['error'] ?? false)
<div class="mb-6 p-4 bg-red-50 border-l-4 border-red-500">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">
{{ $submittedData['error'] }}
</p>
</div>
</div>
</div>
@endif
Key Improvements:
Security Enhancements
Automatic HTML escaping with
e()
Proper error handling
Session-based success messages
Rich Preview Display
Word count statistics
Formatted timestamps
Improved typography with Tailwind Prose
Clear visual hierarchy
Better User Flow
"Create Another" button
Navigation to posts index
Error state handling
Visual Improvements
SVG icons for better visual cues
Card-based layout
Responsive design
Additional Features
Preview badge
Timestamp formatting
Word count metrics
Pro Tip: Add a character counter for the content field:
<div class="mt-1 flex justify-between">
<textarea ...></textarea>
<span class="text-xs text-gray-500 ml-2">
{{ strlen($content) }}/5000
</span>
</div>
Here's your enhanced Saving to Database section with improved security, error handling, and best practices:
Step 12: Secure Database Operations
1. Enhanced Model Setup
First, ensure your Post
model is properly configured:
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Post extends Model
{
protected $fillable = [
'title',
'content',
'user_id' // Always explicitly list fillable fields
];
protected $hidden = [
'updated_at' // Hide from serialization
];
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i',
];
// Automatically set the user_id
public static function boot()
{
parent::boot();
static::creating(function ($post) {
$post->user_id = auth()->id();
});
}
// Accessor for clean content display
protected function content(): Attribute
{
return Attribute::make(
get: fn ($value) => htmlspecialchars_decode($value),
set: fn ($value) => htmlspecialchars($value)
);
}
}
2. Robust Save Operation
Update your Livewire component's submit method:
public function submit()
{
$this->validate();
try {
// Transaction ensures data consistency
DB::transaction(function () {
$post = Post::create([
'title' => clean($this->title), // Sanitize input
'content' => purify($this->content), // HTML purification
// user_id automatically set via model boot()
]);
// Optional: Dispatch job for post-processing
ProcessPostImages::dispatch($post)->onQueue('media');
// Event for real-time updates
event(new PostCreated($post));
// Store for display
$this->submittedData = [
'id' => $post->id,
'title' => $post->title,
'excerpt' => Str::limit($post->content, 100),
'url' => route('posts.show', $post)
];
});
$this->showSuccess = true;
} catch (\Exception $e) {
Log::error("Post creation failed: {$e->getMessage()}");
$this->addError('database', 'Failed to save post. Please try again.');
}
}
3. Security Best Practices
Input Sanitization:
// Install HTML Purifier package first composer require mews/purifier // config/purifier.php 'settings' => [ 'default' => [ 'HTML.Allowed' => 'p,br,b,strong,i,em,u,a[href|title],ul,ol,li' ], ];
Database Transactions:
use Illuminate\Support\Facades\DB; DB::transaction(function () { // Multiple related operations $post = Post::create([...]); $user->increment('post_count'); });
Rate Limiting (in your Livewire component):
protected $rateLimiting = 5; // 5 attempts per minute protected $rateLimitDecaySeconds = 60; public function submit() { $this->ensureIsNotRateLimited(); // ... rest of logic $this->resetRateLimit(); }
4. Additional Enhancements
A. Soft Deletes:
// In Post model
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
protected $dates = ['deleted_at'];
}
B. Database Indexing (migration):
Schema::create('posts', function (Blueprint $table) {
// ...
$table->index('user_id'); // Faster queries
$table->fullText(['title', 'content']); // For search
});
C. Model Observers:
// app/Observers/PostObserver.php
public function created(Post $post)
{
$post->user->increment('post_count');
Cache::forget('recent_posts');
}
5. View Integration
Show database feedback:
@error('database')
<div class="mb-4 p-4 bg-red-50 border-l-4 border-red-500">
<div class="flex">
<svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<p class="ml-3 text-sm text-red-700">{{ $message }}</p>
</div>
</div>
@enderror
Key Takeaways
✅ Automatic user assignment via model boot
✅ Input sanitization with HTML Purifier
✅ Database transactions for data integrity
✅ Rate limiting to prevent abuse
✅ Soft deletes for recoverable content
✅ Full-text search preparation
✅ Real-time events for notifications
Step 13: Robust Authentication System
1. Enhanced Registration Flow
Let's upgrade the default Breeze setup with additional features:
// app/Http/Controllers/Auth/RegisteredUserController.php
use App\Events\UserRegistered;
use Illuminate\Validation\Rules\Password;
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => [
'required',
'confirmed',
Password::min(8)
->mixedCase()
->numbers()
->symbols()
->uncompromised(),
],
'agree_to_terms' => 'accepted' // GDPR compliance
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'ip_address' => $request->ip(),
]);
event(new UserRegistered($user)); // Dispatch event
Auth::login($user);
return redirect()->intended(RouteServiceProvider::HOME)
->with('status', 'Welcome! Check your email for verification.');
}
2. Advanced Welcome Email
Enhanced WelcomeMail.php
:
// app/Mail/WelcomeMail.php
public function build()
{
return $this->markdown('emails.welcome')
->subject("Welcome to " . config('app.name') . ", {$this->user->name}!")
->with([
'verifyUrl' => URL::temporarySignedRoute(
'verification.verify',
now()->addDays(7),
['id' => $this->user->id]
),
'supportEmail' => config('mail.support_email')
]);
}
3. Modern Email Template
{{-- resources/views/emails/welcome.blade.php --}}
@component('mail::layout')
@slot('header')
@component('mail::header', ['url' => config('app.url')])
{{ config('app.name') }}
@endcomponent
@endslot
# Welcome aboard, {{ $user->name }}!
Thanks for joining our community. Here's what you can do next:
@component('mail::button', ['url' => $verifyUrl])
Verify Your Email
@endcomponent
@component('mail::panel')
Your temporary password: {{ $temporaryPassword ?? 'Use your chosen password' }}
@endcomponent
Need help? Contact our [support team](mailto:{{ $supportEmail }}).
@slot('footer')
@component('mail::footer')
© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.
@endcomponent
@endslot
@endcomponent
4. Security Enhancements
Add to User
model:
// app/Models/User.php
protected $hidden = [
'password',
'remember_token',
'ip_address'
];
protected $casts = [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
];
public function scopeActive($query)
{
return $query->whereNull('banned_at');
}
5. Login Monitoring
Create an observer:
php artisan make:observer UserObserver --model=User
// app/Observers/UserObserver.php
public function retrieved(User $user)
{
if (Auth::check()) {
$user->update([
'last_login_at' => now(),
'last_login_ip' => request()->ip()
]);
}
}
6. Environment Configuration
Enhanced .env
example:
# Authentication
SESSION_LIFETIME=1440
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
# Email Verification
VERIFICATION_LINK_EXPIRE=1440 # 24 hours
# Password Reset
RESET_LINK_EXPIRE=60 # 1 hour
7. Frontend Improvements
Add to registration form (register.blade.php
):
<div class="mt-4">
<label class="flex items-start">
<input type="checkbox" name="agree_to_terms" required
class="rounded border-gray-300 text-blue-600 shadow-sm mt-1">
<span class="ml-2 text-sm text-gray-600">
I agree to the <a href="/terms" class="text-blue-600 hover:text-blue-800">Terms of Service</a>
and <a href="/privacy" class="text-blue-600 hover:text-blue-800">Privacy Policy</a>
</span>
</label>
</div>
<div x-data="{ show: false }" class="mt-4">
<label class="block text-sm font-medium text-gray-700">
Password Strength: <span x-text="passwordStrength"></span>
</label>
<input type="password" name="password" required
x-on:input="checkStrength($event.target.value)"
class="mt-1 block w-full">
<div class="h-1 mt-1 bg-gray-200 rounded-full">
<div x-bind:style="'width: ' + strengthPercent + '%'"
x-bind:class="strengthColor"
class="h-full rounded-full transition-all"></div>
</div>
</div>
Key Security Features
✅ Password complexity requirements
✅ Email verification
✅ GDPR-compliant consent
✅ Login activity tracking
✅ Secure session configuration
✅ CSRF protection (built-in)
✅ Rate limiting (via Laravel Breeze)
Modern UX Features
✨ Markdown email templates
✨ Password strength meter
✨ Legal compliance checkboxes
✨ Responsive auth forms
✨ Verification workflows
Step 14: Comprehensive Application Testing
1. Local Development Setup
# Start the backend server (port 8000)
php artisan serve
# Start frontend assets (Vite)
npm run dev
# Optional: Monitor logs in real-time
tail -f storage/logs/laravel.log
2. Manual Testing Checklist
A. Authentication Flow
Test Case | Steps | Expected Result |
Registration | 1. Visit /register |
2. Fill valid details
3. Submit | - Account created
- Welcome email sent
- Redirect to dashboard |
| Invalid Registration | Submit with:
- Existing email
- Weak password
- Missing fields | - Clear error messages
- No database entry |
| Email Verification | Click verification link in email | - email_verified_at
set in DB
- Access to protected routes |
B. Post Creation
# Verify database after submission
php artisan tinker
>>> \App\Models\Post::latest()->first()
C. Infinite Scroll
- Create 15+ test posts:
php artisan make:command GenerateTestPosts
# (Implement mass post generation)
Scroll to bottom of
/posts
Verify:
"Load More" appears
Additional posts load automatically
No duplicate requests
3. Automated Testing (PHPUnit)
A. Feature Test
php artisan make:test PostCreationTest
// tests/Feature/PostCreationTest.php
public function test_authenticated_user_can_create_post()
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', [
'title' => 'Test Post',
'content' => 'Sample content'
])
->assertRedirect()
->assertSessionHas('status');
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id
]);
}
B. Browser Test (Laravel Dusk)
composer require --dev laravel/dusk
php artisan dusk:install
// tests/Browser/PostTest.php
public function testCreatePost()
{
$this->browse(function (Browser $browser) {
$browser->loginAs(User::find(1))
->visit('/posts/create')
->type('title', 'Dusk Test')
->type('content', 'Automated browser test')
->press('Submit')
->assertSee('Post created successfully')
->assertVisible('@post-preview');
});
}
4. Performance Testing
# Database query monitoring
DB::enableQueryLog();
// Test operations
dd(DB::getQueryLog());
# Memory usage check
$start = memory_get_usage();
// Test code
dd(memory_get_usage() - $start);
5. Debugging Tools
A. Telescope (For local debugging)
composer require laravel/telescope
php artisan telescope:install
php artisan migrate
B. Common Test Data
php artisan make:factory PostFactory
// database/factories/PostFactory.php
public function definition()
{
return [
'title' => fake()->sentence(),
'content' => fake()->paragraphs(3, true),
'user_id' => User::factory()
];
}
// Usage:
Post::factory()->count(50)->create();
Pro Tips:
Database Assertions:
$this->assertDatabaseCount('posts', 5); $this->assertDatabaseMissing('posts', ['title' => '']);
Test Coverage:
php artisan test --coverage-html coverage/
HTTP Tests:
$response = $this->get('/posts'); $response->assertSeeInOrder(['First Post', 'Second Post']);
Here's your enhanced Best Practices and Enhancements section with professional recommendations, modern tooling, and actionable implementation details:
Step 15: Production-Grade Enhancements
1. Advanced Validation
Livewire Component:
protected $rules = [ 'title' => [ 'required', 'string', 'max:255', Rule::unique('posts')->where(fn ($query) => $query->where('user_id', auth()->id())) ], 'content' => [ 'required', 'string', 'min:100', // Minimum content length new NoSpamWords // Custom rule ] ]; protected $validationAttributes = [ 'title' => 'post title', 'content' => 'post content' ]; // Real-time validation on field update public function updated($property) { $this->validateOnly($property); }
Custom Validation Rule:
php artisan make:rule NoSpamWords
public function passes($attribute, $value) { return !preg_match('/spam|viagra/i', $value); }
2. Enterprise Security
// Database transaction with rate limiting public function submit() { $this->ensureIsNotRateLimited(); DB::transaction(function () { $post = Post::create([ 'title' => Purifier::clean($this->title), 'content' => Purifier::clean($this->content), 'user_id' => auth()->id(), 'ip_address' => request()->ip() ]); $this->fingerprint = PostFingerprint::generate($post); }); $this->resetRateLimit(); }
Security Packages:
composer require mews/purifier # HTML sanitization comrequire spatie/laravel-honeypot # Spam protection
3. Professional UI Components
Rich Text Editor (Trix + Livewire):
<div wire:ignore> <trix-editor class="trix-content" x-data x-ref="trix" x-on:trix-change="dispatch('change', $refs.trix.value)" wire:model.lazy="content" wire:key="trix-content" ></trix-editor> </div> @push('styles') <link rel="stylesheet" href="https://unpkg.com/trix@2.0.0/dist/trix.css"> @endpush @push('scripts') <script src="https://unpkg.com/trix@2.0.0/dist/trix.js"></script> @endpush
Image Uploads:
use Livewire\WithFileUploads; class CreatePost extends Component { use WithFileUploads; public $images = []; protected $rules = [ 'images.*' => 'image|max:2048|dimensions:min_width=100' ]; }
4. Robust Testing Suite
Pest PHP Testing:
composer require pestphp/pest --dev php artisan pest:install
Test Case Example:
test('post creation validates content length', function () { actingAs(User::factory()->create()) ->livewire(CreatePost::class) ->set('content', 'Short') ->call('submit') ->assertHasErrors(['content' => 'min']) ->assertSee('The post content must be at least 100 characters'); });
Browser Testing Matrix:
// tests/Browser/PostCreationTest.php $browsers = [ ['name' => 'Chrome', 'width' => 1920, 'height' => 1080], ['name' => 'Mobile', 'width' => 375, 'height' => 812] ]; foreach ($browsers as $browser) { test("post creation works on {$browser['name']}", function () use ($browser) { $this->browse(function (Browser $browser) { // Test logic }); }); }
5. Performance Optimization
Database Indexing Migration:
php artisan make:migration add_indexes_to_posts_table
public function up() { Schema::table('posts', function (Blueprint $table) { $table->index('user_id'); $table->fullText(['title', 'content']); $table->index('created_at'); }); }
Query Optimization:
public function render() { return view('livewire.posts-index', [ 'posts' => Post::query() ->with(['user:id,name,avatar']) ->when($this->search, fn ($q) => $q->search($this->search)) ->orderBy('is_pinned', 'desc') ->latest() ->fastPaginate(10) ]); }
6. Deployment Checklist
Production-Ready Config:
LIVEWIRE_ASSET_URL=https://cdn.yourdomain.com LIVEWIRE_UPDATE_MESSAGE=Updating post... QUEUE_CONNECTION=redis
Horizon Configuration:
composer require laravel/horizon php artisan horizon:install
7. Monitoring Setup
Telescope Configuration:
// config/telescope.php 'watchers' => [ Watchers\LivewireWatcher::class => [ 'log_events' => true, 'log_requests' => env('APP_DEBUG', false), ], ];
New Relic Integration:
public function submit() { newrelic_start_transaction(); try { // Post creation logic newrelic_name_transaction('Post/Create'); } finally { newrelic_end_transaction(); } }
Key Upgrades: ✅ Enterprise security with HTML sanitization and spam protection
✅ Professional rich text editing with Trix
✅ Comprehensive testing with Pest PHP
✅ Performance-optimized queries
✅ Production monitoring with Telescope + New Relic
Step 16: Essential Resources
Core Documentation
🔗 Livewire Docs - Official component system
🔗 Laravel 11 Docs - Authentication, Eloquent, Testing
Frontend Tools
🎨 Tailwind CSS - Utility-first styling
⚡ Alpine.js - Lightweight interactivity
Starter Kits
🚀 Laravel Breeze - Simple auth scaffolding
🛠️ Laravel Telescope - Debugging assistant
Community
💬 Laravel News - Updates & tutorials
🛠️ Livewire Discord - Real-time support
Pro Tip: Bookmark these in your development browser for quick access!
Here's a polished Summary that highlights key achievements while maintaining brevity:
Key Takeaways
This tutorial transformed a basic blog into a modern Laravel application by implementing:
⚡ Livewire Components
Reactive forms with real-time validation
Dynamic post listings with infinite scroll
Seamless frontend-backend interaction
🎨 Tailwind CSS
Responsive layouts with utility classes
Professional UI components
Dark mode support (via
dark:
prefix)
🔒 Robust Authentication
Secure registration/login flows
Email verification
Protected routes with middleware
📊 Data Management
Eloquent ORM with relationships
Transaction-safe database operations
Structured logging
🛠️ Dev Experience
Automated testing (PHPUnit/Pest)
Debugging with Telescope
CI/CD pipeline ready
Next Steps: Consider adding user profiles, comments, or a newsletter system to extend functionality!
Subscribe to my newsletter
Read articles from Junaid Bin Jaman directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Junaid Bin Jaman
Junaid Bin Jaman
Hello! I'm a software developer with over 6 years of experience, specializing in React and WordPress plugin development. My passion lies in crafting seamless, user-friendly web applications that not only meet but exceed client expectations. I thrive on solving complex problems and am always eager to embrace new challenges. Whether it's building robust WordPress plugins or dynamic React applications, I bring a blend of creativity and technical expertise to every project.