Build a Reactive Laravel Blog with Livewire: A Step-by-Step Tutorial
Blazing-fast updates, minimal JavaScript
Originally published: September 14th, 2023. Updated on: April 29th, 2025.
In this tutorial, we’ll build a basic Laravel blog application using Livewire to achieve seriously snappy front-end responsiveness without writing complex JavaScript[cite: 1225].
Laravel & Livewire Make a Powerful Team
As a freelance web developer, I frequently use Laravel for projects[cite: 1226]. It's an awesome PHP framework capable of building a wide range of applications[cite: 1227]. Livewire makes it even more powerful[cite: 1227, 1228].
Livewire is a full-stack framework for Laravel that simplifies creating dynamic, reactive front-end interfaces[cite: 1228]. Normally, achieving highly interactive UIs requires JavaScript frameworks like React, Vue, or AlpineJS[cite: 1229]. Livewire, however, lets you build these experiences using familiar Laravel syntax (PHP and Blade templates) without stepping outside the core framework[cite: 1230, 1233].
Created by Caleb Porzio, Livewire has gained significant traction[cite: 1232]. Its coolest feature? Reflecting server state changes on the front-end without a full page reload[cite: 1234]. This enables near-instant responses to user interactions[cite: 1235].
We'll use this capability to add two key responsive features to our blog:
- Pagination: Clicking through article pages without reloading the entire view[cite: 1236].
- Navigation: Moving between the article index and individual posts seamlessly, again without full page reloads[cite: 1236].
Setting Up a Simple Laravel Blog with Livewire
Let's dive in! We'll build a basic blog showing an index of articles and a view for single articles[cite: 1236].
Our simple blog application will feature:
- A basic Article model and database structure[cite: 1246].
- Dummy blog content with titles, text, and featured images[cite: 1246].
- Routing for the article index and single post pages[cite: 1237].
- Livewire integration for dynamic interactions[cite: 1237].
- Pagination for Browse articles[cite: 1237].
- Super-fast navigation using Livewire's
wire:navigate
[cite: 1237].
Prerequisites:
- A suitable local development environment (PHP, Composer)[cite: 1238]. Using WSL on Windows can simplify things[cite: 1239].
- Composer installed[cite: 1239].
- A code editor (VS Code is a great choice)[cite: 1239].
- Basic command-line familiarity[cite: 1021].
- We'll use
php artisan serve
for the local server, no complex build setup needed[cite: 1239].
Let's Get Going
-
Create a New Laravel Project: If you don't have one, run:
composer create-project --prefer-dist laravel/laravel your-project-name
(This might take a moment)[cite: 1239]. If you encounter PHP version errors, ensure you have the correct version and required extensions (like
pdo_sqlite
) installed[cite: 1241]. Then, navigate into the project directory:cd your-project-name
[cite: 1241]
-
Install Livewire:
composer require livewire/livewire
[cite: 1241] Optionally, publish the config file (nice for review/updates, but not essential):
php artisan livewire:publish --config
This copies settings to
config/livewire.php
[cite: 1242]. -
Set Up the Database (SQLite): SQLite is a simple file-based database, great for local development[cite: 1243, 1244].
- Make sure SQLite3 is installed (e.g.,
sudo apt-get install sqlite3
on Debian/Ubuntu)[cite: 1244]. - Create the database file:
[cite: 1245]touch database/database.sqlite
- Update your
.env
file:
(Make sure to use the correct absolute path to yourDB_CONNECTION=sqlite # DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD can usually be removed or commented out for SQLite DB_DATABASE=/absolute/path/to/your-project-name/database/database.sqlite
database.sqlite
file)[cite: 1245].
- Make sure SQLite3 is installed (e.g.,
-
Run Your App: Start the development server:
php artisan serve
[cite: 1245] You should now be able to view the default Laravel welcome page in your browser (usually at
http://127.0.0.1:8000
)[cite: 1245].
Setting Up the Laravel Blog & Articles Structure
We need a way to store and manage our blog articles.
-
Create the Migration:
php artisan make:migration create_articles_table
[cite: 1247] This creates a migration file in
database/migrations
. Open it and modify theup()
method:// database/migrations/xxxx_xx_xx_xxxxxx_create_articles_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('articles', function (Blueprint $table) { $table->id(); // Auto-incremental primary key $table->string('title'); // Title of the article $table->text('content'); // Content of the article $table->string('image')->nullable(); // Adding the image column $table->timestamps(); // created_at and updated_at timestamps }); } public function down(): void { Schema::dropIfExists('articles'); } };
[cite: 1248]
-
Run the Migration:
php artisan migrate
[cite: 1249] This creates the
articles
table in your SQLite database[cite: 1249]. -
Create the Model and Controller:
php artisan make:model Article -c -m
This command is slightly different from the source (
--controller
) but achieves the same goal by creating the Model (Article.php
inapp/Models
), the Controller (ArticleController.php
inapp/Http/Controllers
), and references the migration we just created (-m
)[cite: 1260]. The-c
flag creates the controller. -
Populate Dummy Data (Seeding): Let's add some sample articles.
- Create a Seeder:
[cite: 1251]php artisan make:seeder ArticlesTableSeeder
- Install Faker if you don't have it (often included with Laravel dev dependencies):
(Note: The original source usedcomposer require fakerphp/faker --dev
fzaninotto/faker
which is abandoned.fakerphp/faker
is the community replacement.) [cite: 1254] - Edit
database/seeders/ArticlesTableSeeder.php
:<?php namespace Database\Seeders; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; // Import DB facade use Faker\Factory as Faker; // Import Faker class ArticlesTableSeeder extends Seeder { /** * Run the database seeds. */ public function run(): void { $faker = Faker::create(); [cite: 1252] foreach (range(1, 10) as $index) { // Create 10 articles DB::table('articles')->insert([ 'title' => $faker->sentence(6, true), 'content' => $faker->paragraphs(3, true), 'image' => "[https://placekitten.com/500/500?image=](https://placekitten.com/500/500?image=)" . $index, // Use unique kitten images 'created_at' => now(), 'updated_at' => now(), ]); } [cite: 1253, 1254] } }
- Call the seeder from
database/seeders/DatabaseSeeder.php
:<?php namespace Database\Seeders; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { /** * Seed the application's database. */ public function run(): void { $this->call([ ArticlesTableSeeder::class, // Add other seeders here if needed ]); } }
- Run the seeder:
[cite: 1256] Or to refresh the database and seed:php artisan db:seed --class=ArticlesTableSeeder
(Usingphp artisan migrate:fresh --seed
--seed
withmigrate:fresh
runs the mainDatabaseSeeder
, which now calls ourArticlesTableSeeder
) [cite: 1256]
- Create a Seeder:
Setting up the Views and Routing (Initial Non-Livewire Setup)
Before integrating Livewire, let's set up the basic Laravel views and routes.
-
Update Article Controller (
app/Http/Controllers/ArticleController.php
): Add methods to show the index and a single article.<?php namespace App\Http\Controllers; use App\Models\Article; // Import the Article model use Illuminate\Http\Request; class ArticleController extends Controller { /** * Display a listing of the resource. */ public function index() { $articles = Article::latest()->get(); // Get latest articles // We'll replace this view later with Livewire return view('articles.index', compact('articles')); [cite: 1263] } /** * Display the specified resource. */ public function show(Article $article) // Use route model binding { // We'll replace this view later with Livewire return view('articles.show', compact('article')); } // Add other methods (create, store, edit, update, destroy) later if needed }
Make sure
use App\Models\Article;
is included at the top[cite: 1265]. -
Create View Files: If they don't exist, create these directories and files:
resources/views/layouts/app.blade.php
resources/views/articles/index.blade.php
resources/views/articles/show.blade.php
You can usephp artisan make:view articles.index
etc., if preferred[cite: 1267, 1271].
-
Create Base Layout (
resources/views/layouts/app.blade.php
): This is our main HTML structure. We'll include Tailwind via CDN for simplicity[cite: 1276, 1277].<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Laravel Livewire Blog</title> <script src="[https://cdn.tailwindcss.com?plugins=typography](https://cdn.tailwindcss.com?plugins=typography)"></script> @livewireStyles </head> <body class="bg-gray-100 text-gray-800 font-sans"> <nav class="bg-blue-600 text-white p-4"> <div class="container mx-auto flex justify-between items-center"> <a href="/articles" class="text-2xl font-bold hover:text-blue-200">Laravel Livewire Blog</a> </div> </nav> <main class="container mx-auto mt-8 p-4"> @yield('content') </main> <footer class="bg-gray-700 text-white p-4 mt-8"> <div class="container mx-auto text-center"> © {{ date('Y') }} Laravel Livewire Blog. All rights reserved. </div> </footer> @livewireScripts </body> </html>
(Note: Added
@livewireStyles
and@livewireScripts
here already, as per later steps in the source)[cite: 1295]. -
Create Initial Index View (
resources/views/articles/index.blade.php
): This will display the list of articles (we'll replace its core content with a Livewire component soon).@extends('layouts.app') {{-- Use the main layout --}} @section('content') <div class="container mx-auto"> <h2 class='my-4 text-3xl font-bold'>Articles</h2> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {{-- We will replace this loop with a Livewire component --}} @forelse($articles as $article) <div class="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300"> @if($article->image) <img src="{{ $article->image }}" alt="{{ $article->title }}" class="w-full h-48 object-cover mb-4 rounded"> @endif <h3 class="text-xl font-semibold mb-2">{{ $article->title }}</h3> <p class="text-gray-600 mb-4">{{ Str::limit($article->content, 120) }}</p> {{-- [cite: 1273] --}} <a href="{{ route('articles.show', $article) }}" class="text-blue-600 hover:underline font-bold">Read More →</a> {{-- [cite: 1274] --}} </div> @empty <p class="text-gray-500">No articles found.</p> @endforelse </div> {{-- Pagination links will go here later --}} </div> @endsection
-
Create Initial Show View (
resources/views/articles/show.blade.php
): Displays a single article.@extends('layouts.app') @section('content') <div class="bg-white p-8 rounded-lg shadow-md max-w-4xl mx-auto"> @if($article->image) <img src="{{ $article->image }}" alt="{{ $article->title }}" class="w-full h-64 object-cover mb-6 rounded"> @endif <h1 class="text-4xl font-bold mb-4">{{ $article->title }}</h1> <div class="prose lg:prose-xl max-w-none"> {{-- Use Tailwind Typography --}} {!! nl2br(e($article->content)) !!} {{-- Display content with line breaks --}} </div> <div class='mt-8'> <a href="{{ route('articles.index') }}" class="text-blue-600 hover:underline font-bold">← Back to Articles</a> </div> </div> @endsection
-
Define Routes (
routes/web.php
): Tell Laravel how to handle URLs for articles.<?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\ArticleController; // Import controller Route::get('/', function () { // Maybe redirect to articles index? return redirect()->route('articles.index'); // return view('welcome'); }); // Use resourceful routing for articles Route::resource('articles', ArticleController::class)->only([ 'index', 'show' // Only enable index and show routes for now ]);
Using
Route::resource
withonly()
is a concise way to define standard CRUD routes[cite: 1289]. This sets up:GET /articles
->ArticleController@index
(namedarticles.index
)GET /articles/{article}
->ArticleController@show
(namedarticles.show
)
-
Preview: Visit
http://127.0.0.1:8000/articles
. You should see your index page with the dummy articles[cite: 1291, 1292]. Clicking "Read More" should take you to the single article view. Okay, basic structure done!
Integrating Livewire
Now for the magic! We'll replace the static parts with dynamic Livewire components. Remember, we already added @livewireStyles
and @livewireScripts
to our main layout (layouts/app.blade.php
)[cite: 1295].
-
Create the Articles Index Livewire Component:
php artisan make:livewire ArticlesIndex
[cite: 1295] This creates:
app/Livewire/ArticlesIndex.php
[cite: 1296]resources/views/livewire/articles-index.blade.php
[cite: 1296]
-
Implement Pagination in the Component Class (
app/Livewire/ArticlesIndex.php
): Modify the class to fetch articles with pagination.<?php namespace App\Livewire; use Livewire\Component; use App\Models\Article; use Livewire\WithPagination; // Import pagination trait class ArticlesIndex extends Component { use WithPagination; // Use the trait // Optional: Use Bootstrap theme for pagination links if needed // protected $paginationTheme = 'bootstrap'; public function render() { return view('livewire.articles-index', [ // Fetch articles, order by latest, paginate 6 per page 'articles' => Article::latest()->paginate(6) [cite: 1301] ]); } }
We added
use Livewire\WithPagination;
anduse WithPagination;
[cite: 1300] and changed the query to usepaginate(6)
[cite: 1301]. -
Update the Component View (
resources/views/livewire/articles-index.blade.php
): Move the article listing loop and add pagination links here. Crucially, addwire:navigate
to the "Read More" links for seamless navigation[cite: 1303].<div> {{-- Livewire components must have a single root element --}} <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> @forelse($articles as $article) <div class="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300"> @if($article->image) <img src="{{ $article->image }}" alt="{{ $article->title }}" class="w-full h-48 object-cover mb-4 rounded"> @endif <h3 class="text-xl font-semibold mb-2">{{ $article->title }}</h3> <p class="text-gray-600 mb-4">{{ Str::limit($article->content, 120) }}</p> {{-- Use wire:navigate for SPA-like navigation --}} <a wire:navigate href="{{ route('articles.show', $article) }}" class="text-blue-600 hover:underline font-bold">Read More →</a> {{-- [cite: 1303] --}} </div> @empty <p class="text-gray-500">No articles found.</p> @endforelse </div> <div class="mt-8"> {{-- Render pagination links --}} {{ $articles->links() }} {{-- [cite: 1304] --}} </div> </div>
-
Update the Main Index View (
resources/views/articles/index.blade.php
): Replace the static article loop with the Livewire component tag.@extends('layouts.app') @section('content') <h2 class='my-4 text-3xl font-bold'>Articles</h2> {{-- Render the Livewire component --}} @livewire('articles-index') {{-- Or: <livewire:articles-index /> --}} @endsection
[cite: 1305]
-
Try It Out (Pagination): Refresh
/articles
. You should see the articles and pagination links[cite: 1306, 1307]. Clicking the pagination links (e.g., "2", "Next") should update the article list without a full page reload[cite: 1308]. Check your browser's network tools; you'll see background AJAX requests fetching the next set of articles[cite: 1309]. Cool! [cite: 1310]
Showing the Single Blog Post with Livewire
Let's apply the same approach to the single article view for seamless navigation.
-
Create the Article Show Livewire Component:
php artisan make:livewire ArticleShow
[cite: 1312] This creates:
app/Livewire/ArticleShow.php
[cite: 1312]resources/views/livewire/article-show.blade.php
[cite: 1312]
-
Update the Component Class (
app/Livewire/ArticleShow.php
): We need to accept theArticle
model, likely passed via the route. Livewire can often automatically resolve this (Route Model Binding), but usingmount()
makes it explicit.<?php namespace App\Livewire; use Livewire\Component; use App\Models\Article; // Import model class ArticleShow extends Component { public Article $article; // Declare public property to hold the article // Use mount() to receive the article from the route public function mount(Article $article) { $this->article = $article; [cite: 1314] } public function render() { // Render the view, Livewire makes $article available automatically return view('livewire.article-show'); [cite: 1315] } }
(Note: The source uses
public $article;
without type hinting, but modern PHP/Laravel encourages type hinting likepublic Article $article;
)[cite: 1313]. Themount
method explicitly assigns the model passed from the route to the component's public property[cite: 1313, 1314]. -
Update the Component View (
resources/views/livewire/article-show.blade.php
): Display the article content and add awire:navigate
link back to the index.<div class="bg-white p-8 rounded-lg shadow-md max-w-4xl mx-auto"> @if($article->image) <img src="{{ $article->image }}" alt="{{ $article->title }}" class="w-full h-64 object-cover mb-6 rounded"> @endif <h1 class="text-4xl font-bold mb-4">{{ $article->title }}</h1> <div class="prose lg:prose-xl max-w-none"> {!! nl2br(e($article->content)) !!} </div> <div class='mt-8'> {{-- Use wire:navigate for the back link --}} <a wire:navigate href="{{ route('articles.index') }}" class="text-blue-600 hover:underline font-bold">← Back to Articles</a> </div> </div>
[cite: 1316]
-
Update Routes for Livewire Components (
routes/web.php
): Instead of routing to theArticleController
, we now route directly to our Livewire components. Livewire components routed this way are called "Full Page Components".<?php use Illuminate\Support\Facades\Route; // Import Livewire components use App\Livewire\ArticlesIndex; use App\Livewire\ArticleShow; Route::get('/', function () { return redirect()->route('articles.index'); }); // Route directly to Livewire components Route::get('/articles', ArticlesIndex::class)->name('articles.index'); // [cite: 1317, 1318] Route::get('/articles/{article}', ArticleShow::class)->name('articles.show'); // [cite: 1317, 1318]
(Note: We removed the
ArticleController
import and usage here, replacing it with direct routing to the Livewire components. Added names for clarity). -
Create Livewire Layout (
resources/views/components/layouts/app.blade.php
): Full Page Livewire components need a specific layout file located inresources/views/components/layouts/
. By default, they look forapp.blade.php
. This layout uses{{ $slot }}
to render the component's content. Let's copy our existinglayouts/app.blade.php
and modify it.<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> {{-- Use title from component or default --}} <title>{{ $title ?? 'Laravel Livewire Blog' }}</title> <script src="[https://cdn.tailwindcss.com?plugins=typography](https://cdn.tailwindcss.com?plugins=typography)"></script> @livewireStyles </head> <body class="bg-gray-100 text-gray-800 font-sans"> <nav class="bg-blue-600 text-white p-4"> <div class="container mx-auto flex justify-between items-center"> <a wire:navigate href="/articles" class="text-2xl font-bold hover:text-blue-200">Laravel Livewire Blog</a> </div> </nav> <main class="container mx-auto mt-8 p-4"> {{ $slot }} {{-- Livewire component content goes here --}} </main> <footer class="bg-gray-700 text-white p-4 mt-8"> <div class="container mx-auto text-center"> © {{ date('Y') }} Laravel Livewire Blog. </div> </footer> @livewireScripts </body> </html>
(The main change is replacing
@yield('content')
with{{ $slot }}
)[cite: 1322]. Also ensured the nav link useswire:navigate
. -
Try It Out (Full Navigation): Go back to
/articles
. Click "Read More" on an article. The page should transition instantly to the single article view without a full browser reload. Click "Back to Articles". Again, instant transition back to the index. Pretty neat, eh? [cite: 1323]
That’s All For Now (On The Basics)
This tutorial covers the fundamentals of using Livewire to build a reactive blog index and single post view in Laravel[cite: 1324]. You've seen how wire:navigate
enables seamless transitions and how pagination works dynamically. Feel free to explore and expand on this foundation[cite: 1325]!
How Does It Work? Behind the Scenes
Full Page Components & wire:navigate
We used Livewire's "Full Page Components" feature, routing directly to our ArticlesIndex
and ArticleShow
components[cite: 1326, 1327].
- Initial Load: When you first visit
/articles
or/articles/{id}
, Laravel renders the Livewire component server-side within the component layout (components/layouts/app.blade.php
). This ensures the initial page is fully formed HTML, which is great for SEO[cite: 1327, 1328]. - Subsequent Navigation (
wire:navigate
): When you click a link marked withwire:navigate
, Livewire intercepts the click. Instead of a full page reload, it makes an AJAX request in the background to get the HTML content of the next Livewire component[cite: 1328, 1329]. It then intelligently swaps out the relevant parts of the current page's DOM with the new content, creating that smooth, SPA-like transition[cite: 1330]. - Component Interactions (Pagination): When you click a pagination link within the
ArticlesIndex
component, Livewire sends an AJAX request back to the same component (ArticlesIndex
). The server re-renders just that component with the new data (the next page of articles). Livewire receives the updated HTML fragment and morphs the existing component's DOM on the page to match, again avoiding a full reload[cite: 1330].
Future Improvements
- Finer-Grained Updates: Currently,
wire:navigate
replaces the whole component slot. For more complex UIs, you might explore techniques (potentially with tools like Alpine.js alongside Livewire, or using nested components and events) to update only specific parts of the page instead of the entire component body[cite: 1331, 1332, 1333]. - Editing & Authentication: Add user login and allow authenticated users to create/edit posts using Livewire forms and actions[cite: 1333].
- Full Tailwind Integration: Set up Tailwind CSS locally using Vite for better customization and purging unused styles, rather than relying on the CDN[cite: 1277, 1333].
Livewire Insights
Insight #1: Cached Computed Properties
For data that doesn't change often (like article content), Livewire's computed properties with caching can boost performance[cite: 1334, 1335].
// In a Livewire component class
use Illuminate\Support\Facades\Cache; // Import Cache facade
use Livewire\Attributes\Computed; // Import Computed attribute
// ...
#[Computed(persist: true, seconds: 3600)] // Cache for 1 hour
public function articles()
{
// This result will be cached
return Article::all();
}
public function render()
{
// Access the cached property in the view
return view('livewire.show-articles', [
'articles' => $this->articles // Property access triggers computed method
]);
}