Why Build a Blog with Laravel 12?


Laravel 12 is the latest release of the most popular PHP framework, offering improved performance, streamlined syntax, and powerful tools for rapid web development. Building a blog is the perfect starter project because it covers all the fundamentals:
  • Database migrations and Eloquent ORM models
  • Resourceful controllers with full CRUD operations
  • Blade templating engine with layouts and components
  • Form validation and CSRF protection
  • Image upload and storage
  • SEO-friendly URLs using slugs
  • Pagination and clean Bootstrap 5 UI
By the end of this tutorial, you will have a working blog with create, read, update, and delete functionality — ready to customize and extend.

Creating a Simple Blog in Laravel 12: Full Tutorial with Source Code

Table Of Content

1 Prerequisites

  • PHP 8.2+
  • Composer
  • MySQL / MariaDB
  • Node.js & NPM (optional, for frontend assets)
  • Laravel 12 (latest stable)

2 Introduction

In this hands-on tutorial, you will build a complete blog application from scratch using Laravel 12. We will cover creating database tables with migrations, building an Eloquent model, writing a resourceful controller for all CRUD operations, designing clean Blade views with Bootstrap 5, handling image uploads, generating SEO-friendly slug URLs, and adding pagination. No packages or third-party CMS — just pure Laravel code that is easy to understand and extend.

3 Install Laravel 12 Project

3.1 Create Laravel 12 Project

Make sure you have Composer installed on your machine.
Run the following command to create a new Laravel 12 project:

composer create-project laravel/laravel laravel-blog

Then navigate to your project directory:

cd laravel-blog

3.2 Configure Database (.env)

Open the .env file in the project root and update the database configuration:

APP_NAME="Laravel Blog"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_blog
DB_USERNAME=root
DB_PASSWORD=

Create the database laravel_blog in MySQL before running migrations.

4 Create Migration & Model

Run the following Artisan command to generate a model, migration, and controller all at once:

php artisan make:model Post -mc

This creates:
  • app/Models/Post.php — Eloquent model
  • database/migrations/xxxx_xx_xx_create_posts_table.php — Migration file
  • app/Http/Controllers/PostController.php — Controller

Update the Migration File
Open the generated migration file in database/migrations/ and update the up() method:

<?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('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('short_description')->nullable();
            $table->longText('content');
            $table->string('image')->nullable();
            $table->string('meta_title')->nullable();
            $table->string('meta_description')->nullable();
            $table->enum('status', ['draft', 'published'])->default('draft');
            $table->integer('view_count')->default(0);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};
?>

Now run the migration:

php artisan migrate


Update the Post Model
Open app/Models/Post.php and add fillable fields and the slug helper:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class Post extends Model
{
    protected $fillable = [
        'title',
        'slug',
        'short_description',
        'content',
        'image',
        'meta_title',
        'meta_description',
        'status',
    ];

    /**
     * Auto-generate a unique slug from the title.
     */
    public static function createSlug($title)
    {
        $slug = Str::slug($title);
        $count = static::where('slug', 'LIKE', "{$slug}%")->count();
        return $count ? "{$slug}-{$count}" : $slug;
    }
}
?>

5 Create Blog Controller

Open app/Http/Controllers/PostController.php and add all CRUD methods:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class PostController extends Controller
{
    /**
     * Display a listing of published posts (public blog page).
     */
    public function index()
    {
        $posts = Post::where('status', 'published')
                     ->latest()
                     ->paginate(6);

        return view('posts.index', compact('posts'));
    }

    /**
     * Display all posts for admin management.
     */
    public function manage()
    {
        $posts = Post::latest()->paginate(10);
        return view('posts.manage', compact('posts'));
    }

    /**
     * Show the form for creating a new post.
     */
    public function create()
    {
        return view('posts.create');
    }

    /**
     * Store a newly created post.
     */
    public function store(Request $request)
    {
        $request->validate([
            'title'             => 'required|string|max:255',
            'short_description' => 'nullable|string|max:500',
            'content'           => 'required|string',
            'image'             => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
            'meta_title'        => 'nullable|string|max:255',
            'meta_description'  => 'nullable|string|max:255',
            'status'            => 'required|in:draft,published',
        ]);

        $data = $request->only([
            'title', 'short_description', 'content',
            'meta_title', 'meta_description', 'status',
        ]);

        $data['slug'] = Post::createSlug($request->title);

        // Handle image upload
        if ($request->hasFile('image')) {
            $data['image'] = $request->file('image')
                                     ->store('posts', 'public');
        }

        Post::create($data);

        return redirect()->route('posts.manage')
                         ->with('success', 'Post created successfully!');
    }

    /**
     * Display the specified post (public view by slug).
     */
    public function show($slug)
    {
        $post = Post::where('slug', $slug)
                    ->where('status', 'published')
                    ->firstOrFail();

        // Increment view count
        $post->increment('view_count');

        return view('posts.show', compact('post'));
    }

    /**
     * Show the form for editing the specified post.
     */
    public function edit(Post $post)
    {
        return view('posts.edit', compact('post'));
    }

    /**
     * Update the specified post.
     */
    public function update(Request $request, Post $post)
    {
        $request->validate([
            'title'             => 'required|string|max:255',
            'short_description' => 'nullable|string|max:500',
            'content'           => 'required|string',
            'image'             => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
            'meta_title'        => 'nullable|string|max:255',
            'meta_description'  => 'nullable|string|max:255',
            'status'            => 'required|in:draft,published',
        ]);

        $data = $request->only([
            'title', 'short_description', 'content',
            'meta_title', 'meta_description', 'status',
        ]);

        // Only regenerate slug if title changed
        if ($post->title !== $request->title) {
            $data['slug'] = Post::createSlug($request->title);
        }

        // Handle image upload
        if ($request->hasFile('image')) {
            // Delete old image if exists
            if ($post->image && \Storage::disk('public')->exists($post->image)) {
                \Storage::disk('public')->delete($post->image);
            }
            $data['image'] = $request->file('image')
                                     ->store('posts', 'public');
        }

        $post->update($data);

        return redirect()->route('posts.manage')
                         ->with('success', 'Post updated successfully!');
    }

    /**
     * Remove the specified post.
     */
    public function destroy(Post $post)
    {
        // Delete image if exists
        if ($post->image && \Storage::disk('public')->exists($post->image)) {
            \Storage::disk('public')->delete($post->image);
        }

        $post->delete();

        return redirect()->route('posts.manage')
                         ->with('success', 'Post deleted successfully!');
    }
}
?>

6 Define Routes

Open routes/web.php and add the following routes:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PostController;

// Public blog routes
Route::get('/', [PostController::class, 'index'])->name('posts.index');
Route::get('/blog/{slug}', [PostController::class, 'show'])->name('posts.show');

// Admin / management routes
Route::get('/admin/posts', [PostController::class, 'manage'])->name('posts.manage');
Route::get('/admin/posts/create', [PostController::class, 'create'])->name('posts.create');
Route::post('/admin/posts', [PostController::class, 'store'])->name('posts.store');
Route::get('/admin/posts/{post}/edit', [PostController::class, 'edit'])->name('posts.edit');
Route::put('/admin/posts/{post}', [PostController::class, 'update'])->name('posts.update');
Route::delete('/admin/posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
?>

Note: In a real application, you would protect the /admin/* routes with authentication middleware. For this tutorial, we keep it simple to focus on the CRUD logic.


Also, create the storage symlink so uploaded images are publicly accessible:

php artisan storage:link

7 Create Blade Views (Bootstrap 5)

resources/views/layouts/app.blade.php (Master Layout)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@yield('title', 'Laravel 12 Blog')</title>
    <meta name="description" content="@yield('meta_description', 'A simple blog built with Laravel 12')">

    <!-- Bootstrap 5 CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">

    <style>
        body { background-color: #f4f6f9; }
        .card { border: none; border-radius: 0.75rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
        .navbar-brand { font-weight: 700; letter-spacing: -0.5px; }
        .blog-thumb { height: 200px; object-fit: cover; border-radius: 0.75rem 0.75rem 0 0; }
        footer { background: #212529; color: #adb5bd; }
    </style>
</head>
<body>

    <!-- Navbar -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="{{ route('posts.index') }}">
                <i class="fas fa-pen-nib me-2"></i>Laravel Blog
            </a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{{ route('posts.index') }}">Blog</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ route('posts.manage') }}">Manage Posts</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link btn btn-outline-light btn-sm ms-2" href="{{ route('posts.create') }}">
                            <i class="fas fa-plus me-1"></i> New Post
                        </a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>

    <!-- Flash Messages -->
    <div class="container mt-4">
        @if(session('success'))
            <div class="alert alert-success alert-dismissible fade show">
                {{ session('success') }}
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        @endif

        @if($errors->any())
            <div class="alert alert-danger alert-dismissible fade show">
                <ul class="mb-0">
                    @foreach($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        @endif
    </div>

    <!-- Main Content -->
    <main class="container my-5">
        @yield('content')
    </main>

    <!-- Footer -->
    <footer class="text-center py-4">
        <p class="mb-0">&copy; {{ date('Y') }} Laravel 12 Blog Tutorial. All rights reserved.</p>
    </footer>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

resources/views/posts/index.blade.php (Blog Listing Page)

@extends('layouts.app')

@section('title', 'Blog - Laravel 12')

@section('content')
<div class="row">
    <div class="col-12 mb-4">
        <h2 class="fw-bold">Latest Blog Posts</h2>
        <p class="text-muted">Explore our latest articles and tutorials.</p>
    </div>

    @forelse($posts as $post)
        <div class="col-md-6 col-lg-4 mb-4">
            <div class="card h-100">
                @if($post->image)
                    <img src="{{ asset('storage/' . $post->image) }}" alt="{{ $post->title }}" class="blog-thumb">
                @else
                    <img src="https://via.placeholder.com/600x300?text=No+Image" alt="No Image" class="blog-thumb">
                @endif
                <div class="card-body d-flex flex-column">
                    <h5 class="card-title">{{ $post->title }}</h5>
                    <p class="card-text text-muted flex-grow-1">
                        {{ Str::limit($post->short_description ?? strip_tags($post->content), 120) }}
                    </p>
                    <div class="d-flex justify-content-between align-items-center mt-3">
                        <small class="text-muted">{{ $post->created_at->format('M d, Y') }}</small>
                        <a href="{{ route('posts.show', $post->slug) }}" class="btn btn-sm btn-primary">Read More</a>
                    </div>
                </div>
            </div>
        </div>
    @empty
        <div class="col-12">
            <div class="alert alert-info">No blog posts found. <a href="{{ route('posts.create') }}">Create your first post!</a></div>
        </div>
    @endforelse
</div>

<div class="d-flex justify-content-center mt-4">
    {{ $posts->links() }}
</div>
@endsection

resources/views/posts/show.blade.php (Single Post View)

@extends('layouts.app')

@section('title', $post->meta_title ?? $post->title)
@section('meta_description', $post->meta_description ?? Str::limit(strip_tags($post->content), 160))

@section('content')
<div class="row justify-content-center">
    <div class="col-lg-8">
        <article class="card">
            @if($post->image)
                <img src="{{ asset('storage/' . $post->image) }}" alt="{{ $post->title }}" class="card-img-top" style="border-radius: 0.75rem 0.75rem 0 0;">
            @endif
            <div class="card-body p-4 p-md-5">
                <h1 class="fw-bold mb-3">{{ $post->title }}</h1>
                <div class="d-flex align-items-center text-muted mb-4">
                    <i class="fas fa-calendar-alt me-2"></i>
                    <span>{{ $post->created_at->format('F d, Y') }}</span>
                    <span class="mx-2">|</span>
                    <i class="fas fa-eye me-2"></i>
                    <span>{{ $post->view_count }} views</span>
                </div>

                <div class="blog-content fs-5 lh-lg">
                    {!! $post->content !!}
                </div>
            </div>
        </article>

        <a href="{{ route('posts.index') }}" class="btn btn-outline-secondary mt-4">
            <i class="fas fa-arrow-left me-1"></i> Back to Blog
        </a>
    </div>
</div>
@endsection

resources/views/posts/create.blade.php (Create Post Form)

@extends('layouts.app')

@section('title', 'Create New Post')

@section('content')
<div class="row justify-content-center">
    <div class="col-lg-8">
        <div class="card">
            <div class="card-header bg-primary text-white">
                <h4 class="mb-0"><i class="fas fa-plus-circle me-2"></i>Create New Post</h4>
            </div>
            <div class="card-body p-4">
                <form action="{{ route('posts.store') }}" method="POST" enctype="multipart/form-data">
                    @csrf

                    <div class="mb-3">
                        <label for="title" class="form-label fw-bold">Title *</label>
                        <input type="text" name="title" id="title" class="form-control form-control-lg"
                               value="{{ old('title') }}" required>
                    </div>

                    <div class="mb-3">
                        <label for="short_description" class="form-label fw-bold">Short Description</label>
                        <textarea name="short_description" id="short_description" class="form-control" rows="2">{{ old('short_description') }}</textarea>
                    </div>

                    <div class="mb-3">
                        <label for="content" class="form-label fw-bold">Content *</label>
                        <textarea name="content" id="content" class="form-control" rows="10" required>{{ old('content') }}</textarea>
                    </div>

                    <div class="mb-3">
                        <label for="image" class="form-label fw-bold">Featured Image</label>
                        <input type="file" name="image" id="image" class="form-control" accept="image/*">
                    </div>

                    <div class="row">
                        <div class="col-md-6 mb-3">
                            <label for="meta_title" class="form-label fw-bold">Meta Title (SEO)</label>
                            <input type="text" name="meta_title" id="meta_title" class="form-control"
                                   value="{{ old('meta_title') }}">
                        </div>
                        <div class="col-md-6 mb-3">
                            <label for="status" class="form-label fw-bold">Status *</label>
                            <select name="status" id="status" class="form-select">
                                <option value="draft" {{ old('status') == 'draft' ? 'selected' : '' }}>Draft</option>
                                <option value="published" {{ old('status') == 'published' ? 'selected' : '' }}>Published</option>
                            </select>
                        </div>
                    </div>

                    <div class="mb-4">
                        <label for="meta_description" class="form-label fw-bold">Meta Description (SEO)</label>
                        <textarea name="meta_description" id="meta_description" class="form-control" rows="2">{{ old('meta_description') }}</textarea>
                    </div>

                    <button type="submit" class="btn btn-primary btn-lg">
                        <i class="fas fa-save me-1"></i> Publish Post
                    </button>
                    <a href="{{ route('posts.manage') }}" class="btn btn-outline-secondary btn-lg ms-2">Cancel</a>
                </form>
            </div>
        </div>
    </div>
</div>
@endsection

resources/views/posts/edit.blade.php (Edit Post Form)

@extends('layouts.app')

@section('title', 'Edit Post: ' . $post->title)

@section('content')
<div class="row justify-content-center">
    <div class="col-lg-8">
        <div class="card">
            <div class="card-header bg-warning text-dark">
                <h4 class="mb-0"><i class="fas fa-edit me-2"></i>Edit Post</h4>
            </div>
            <div class="card-body p-4">
                <form action="{{ route('posts.update', $post) }}" method="POST" enctype="multipart/form-data">
                    @csrf
                    @method('PUT')

                    <div class="mb-3">
                        <label for="title" class="form-label fw-bold">Title *</label>
                        <input type="text" name="title" id="title" class="form-control form-control-lg"
                               value="{{ old('title', $post->title) }}" required>
                    </div>

                    <div class="mb-3">
                        <label for="short_description" class="form-label fw-bold">Short Description</label>
                        <textarea name="short_description" id="short_description" class="form-control" rows="2">{{ old('short_description', $post->short_description) }}</textarea>
                    </div>

                    <div class="mb-3">
                        <label for="content" class="form-label fw-bold">Content *</label>
                        <textarea name="content" id="content" class="form-control" rows="10" required>{{ old('content', $post->content) }}</textarea>
                    </div>

                    <div class="mb-3">
                        <label for="image" class="form-label fw-bold">Featured Image</label>
                        @if($post->image)
                            <div class="mb-2">
                                <img src="{{ asset('storage/' . $post->image) }}" alt="Current" class="img-thumbnail" style="max-height: 150px;">
                            </div>
                        @endif
                        <input type="file" name="image" id="image" class="form-control" accept="image/*">
                        <small class="text-muted">Leave empty to keep the current image.</small>
                    </div>

                    <div class="row">
                        <div class="col-md-6 mb-3">
                            <label for="meta_title" class="form-label fw-bold">Meta Title (SEO)</label>
                            <input type="text" name="meta_title" id="meta_title" class="form-control"
                                   value="{{ old('meta_title', $post->meta_title) }}">
                        </div>
                        <div class="col-md-6 mb-3">
                            <label for="status" class="form-label fw-bold">Status *</label>
                            <select name="status" id="status" class="form-select">
                                <option value="draft" {{ old('status', $post->status) == 'draft' ? 'selected' : '' }}>Draft</option>
                                <option value="published" {{ old('status', $post->status) == 'published' ? 'selected' : '' }}>Published</option>
                            </select>
                        </div>
                    </div>

                    <div class="mb-4">
                        <label for="meta_description" class="form-label fw-bold">Meta Description (SEO)</label>
                        <textarea name="meta_description" id="meta_description" class="form-control" rows="2">{{ old('meta_description', $post->meta_description) }}</textarea>
                    </div>

                    <button type="submit" class="btn btn-warning btn-lg">
                        <i class="fas fa-save me-1"></i> Update Post
                    </button>
                    <a href="{{ route('posts.manage') }}" class="btn btn-outline-secondary btn-lg ms-2">Cancel</a>
                </form>
            </div>
        </div>
    </div>
</div>
@endsection

resources/views/posts/manage.blade.php (Admin Post Management)

@extends('layouts.app')

@section('title', 'Manage Posts')

@section('content')
<div class="d-flex justify-content-between align-items-center mb-4">
    <h2 class="fw-bold">Manage Posts</h2>
    <a href="{{ route('posts.create') }}" class="btn btn-primary">
        <i class="fas fa-plus me-1"></i> New Post
    </a>
</div>

<div class="card">
    <div class="card-body p-0">
        <table class="table table-hover mb-0">
            <thead class="table-dark">
                <tr>
                    <th>#</th>
                    <th>Title</th>
                    <th>Status</th>
                    <th>Views</th>
                    <th>Created</th>
                    <th class="text-end">Actions</th>
                </tr>
            </thead>
            <tbody>
                @forelse($posts as $post)
                    <tr>
                        <td>{{ $post->id }}</td>
                        <td>
                            <a href="{{ route('posts.show', $post->slug) }}" target="_blank">{{ Str::limit($post->title, 50) }}</a>
                        </td>
                        <td>
                            <span class="badge bg-{{ $post->status === 'published' ? 'success' : 'secondary' }}">
                                {{ ucfirst($post->status) }}
                            </span>
                        </td>
                        <td>{{ $post->view_count }}</td>
                        <td>{{ $post->created_at->format('M d, Y') }}</td>
                        <td class="text-end">
                            <a href="{{ route('posts.edit', $post) }}" class="btn btn-sm btn-warning">
                                <i class="fas fa-edit"></i>
                            </a>
                            <form action="{{ route('posts.destroy', $post) }}" method="POST" class="d-inline"
                                  onsubmit="return confirm('Are you sure you want to delete this post?')">
                                @csrf
                                @method('DELETE')
                                <button class="btn btn-sm btn-danger">
                                    <i class="fas fa-trash"></i>
                                </button>
                            </form>
                        </td>
                    </tr>
                @empty
                    <tr>
                        <td colspan="6" class="text-center py-4">No posts yet. <a href="{{ route('posts.create') }}">Create one!</a></td>
                    </tr>
                @endforelse
            </tbody>
        </table>
    </div>
</div>

<div class="d-flex justify-content-center mt-4">
    {{ $posts->links() }}
</div>
@endsection

8 Handle Image Upload

Laravel makes file uploads simple with the Storage facade. In this project, we store images in the storage/app/public/posts directory and access them via the public symlink.

Key points about the image upload logic:
  • We validate that the file is an image with allowed extensions: jpg, jpeg, png, webp
  • Maximum file size is set to 2MB (2048 KB)
  • Files are stored using $request->file('image')->store('posts', 'public')
  • On update, the old image is deleted before saving the new one
  • On delete, the associated image is also removed from storage

Make sure you have run the storage link command:

php artisan storage:link

This creates a symbolic link from public/storage to storage/app/public, making uploaded files accessible via the browser at /storage/posts/filename.jpg.

9 Folder Structure

10 Run and Test

Start the Laravel development server:

php artisan serve

After starting the server, verify that everything works correctly:

1. Open your browser and go to:
`http://localhost:8000`
→ You should see the blog listing page (empty at first)

2. Create a new post
- Navigate to `http://localhost:8000/admin/posts/create`
- Fill in the title, content, upload an image, set status to "Published"
- Click "Publish Post"
→ You should be redirected to the manage page with a success message

3. View the blog listing
- Go to `http://localhost:8000`
→ Your new post should appear with its image and excerpt

4. Read a single post
- Click "Read More" on any post
→ You should see the full post with the view count incrementing

5. Edit a post
- Go to Manage Posts → Click the edit icon
- Change the title or content → Click "Update Post"
→ Changes should reflect immediately

6. Delete a post
- Go to Manage Posts → Click the delete icon
- Confirm deletion
→ Post and its image should be removed

7. Test form validation
- Try to create a post without a title or content
→ You should see validation error messages

8. Test SEO-friendly URLs
- Create a post titled "My First Laravel Blog Post"
→ The URL should be `http://localhost:8000/blog/my-first-laravel-blog-post`

11 Conclusion

Congratulations! You have successfully built a complete blog application in Laravel 12 from scratch — with full CRUD functionality, image uploads, SEO-friendly slugs, pagination, and a clean Bootstrap 5 UI.

Here is a summary of what you learned:

  • Setting up a Laravel 12 project and configuring the database
  • Creating migrations, models, and controllers using Artisan commands
  • Building a resourceful controller with create, read, update, and delete operations
  • Designing responsive Blade views with Bootstrap 5
  • Handling image uploads with Laravel Storage facade
  • Generating unique SEO-friendly slug URLs automatically
  • Adding pagination for the blog listing
  • Form validation with error feedback


Next Steps to Extend This Project:

  • Add user authentication using Laravel Breeze or Fortify to protect admin routes
  • Add categories and tags to organize blog posts
  • Integrate a rich text editor like TinyMCE or CKEditor for the content field
  • Add comments functionality with moderation
  • Implement full-text search across blog posts
  • Deploy to production with proper environment configuration


Laravel 12 makes building web applications fast, enjoyable, and secure — and a blog project is the perfect foundation to master the framework.
Revathi M - PHP and CodeIgniter Developer

Written by Revathi M

PHP Developer & Technical Writer · 10+ years building web applications with CodeIgniter and Laravel

Revathi specializes in PHP backend development, authentication systems, and REST API design. She writes practical, production-tested tutorials at Get Sample Code to help developers build secure applications faster.

Frequently Asked Questions

Laravel 12 requires PHP 8.2 or higher. Make sure your local development environment and production server meet this requirement before starting.

You can use the Str::slug() helper to convert a title into a URL-friendly slug. In this tutorial, we created a createSlug() method on the Post model that also handles duplicate slugs by appending a number.

Laravel provides a simple file upload API. Use $request->file("image")->store("folder", "public") to save uploaded files. Run php artisan storage:link to make uploaded files publicly accessible via the browser.

Yes. Laravel offers several authentication scaffolding options including Breeze, Jetstream, and Fortify. You can wrap the admin routes with the auth middleware to require login before accessing post management features.

Laravel makes pagination effortless. Simply use ->paginate(6) on your Eloquent query, then call {{ $posts->links() }} in your Blade view to render the pagination controls automatically.