LessonsBuild a Blog → Part 5

Admin Panel — Posts CRUD

Laravel
⏱ 30 min read🏗️ ProjectNot completed

Now we'll build the full posts management system in the admin panel — list all posts, create new ones, edit existing ones, and delete them.

Admin Post Controller

Terminal
php artisan make:controller Admin/PostController --resource
app/Http/Controllers/Admin/PostController.php
<?php
namespace App\Http\Controllers\Admin;

use App\Models\Post;
use App\Models\Category;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::with(['user', 'category'])
            ->latest()
            ->paginate(15);

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

    public function create()
    {
        $categories = Category::all();
        $tags = Tag::all();
        return view('admin.posts.create', compact('categories', 'tags'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title'       => 'required|string|max:255',
            'body'        => 'required|string',
            'category_id' => 'nullable|exists:categories,id',
            'tags'        => 'array',
            'tags.*'      => 'exists:tags,id',
            'image'       => 'nullable|image|max:2048',
            'published'   => 'boolean',
            'excerpt'     => 'nullable|string|max:500',
        ]);

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

        $validated['user_id']  = auth()->id();
        $validated['slug']     = Str::slug($validated['title']);
        $validated['published_at'] = $request->published ? now() : null;

        $post = Post::create($validated);

        // Sync tags
        if ($request->tags) {
            $post->tags()->sync($request->tags);
        }

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

    public function edit(Post $post)
    {
        $categories = Category::all();
        $tags = Tag::all();
        return view('admin.posts.edit', compact('post', 'categories', 'tags'));
    }

    public function update(Request $request, Post $post)
    {
        $validated = $request->validate([
            'title'       => 'required|string|max:255',
            'body'        => 'required|string',
            'category_id' => 'nullable|exists:categories,id',
            'tags'        => 'array',
            'image'       => 'nullable|image|max:2048',
            'published'   => 'boolean',
            'excerpt'     => 'nullable|string|max:500',
        ]);

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

        // Set published_at when publishing for first time
        if ($request->published && !$post->published_at) {
            $validated['published_at'] = now();
        }

        $post->update($validated);
        $post->tags()->sync($request->tags ?? []);

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

    public function destroy(Post $post)
    {
        if ($post->image) {
            Storage::disk('public')->delete($post->image);
        }
        $post->delete();

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

Posts List View

resources/views/admin/posts/index.blade.php
@extends('layouts.admin')
@section('title', 'All Posts')

@section('content')
<div class="flex justify-between items-center mb-6">
    <h2 class="text-lg font-semibold">All Posts ({{ $posts->total() }})</h2>
    <a href="{{ route('admin.posts.create') }}"
       class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
        + New Post
    </a>
</div>

<div class="bg-white rounded shadow overflow-hidden">
    <table class="w-full text-sm">
        <thead class="bg-gray-50 border-b">
            <tr>
                <th class="p-4 text-left">Title</th>
                <th class="p-4 text-left">Category</th>
                <th class="p-4 text-left">Status</th>
                <th class="p-4 text-left">Views</th>
                <th class="p-4 text-left">Date</th>
                <th class="p-4 text-left">Actions</th>
            </tr>
        </thead>
        <tbody class="divide-y">
            @forelse($posts as $post)
            <tr class="hover:bg-gray-50">
                <td class="p-4">
                    <div class="font-medium">{{ $post->title }}</div>
                    <div class="text-gray-400 text-xs">{{ $post->slug }}</div>
                </td>
                <td class="p-4">{{ $post->category?->name ?? '—' }}</td>
                <td class="p-4">
                    @if($post->published)
                        <span class="bg-green-100 text-green-700 px-2 py-1 rounded text-xs">Published</span>
                    @else
                        <span class="bg-yellow-100 text-yellow-700 px-2 py-1 rounded text-xs">Draft</span>
                    @endif
                </td>
                <td class="p-4">{{ number_format($post->views) }}</td>
                <td class="p-4 text-gray-500">{{ $post->created_at->format('M d, Y') }}</td>
                <td class="p-4">
                    <div class="flex gap-2">
                        <a href="{{ route('admin.posts.edit', $post) }}"
                           class="text-blue-600 hover:underline">Edit</a>
                        <form method="POST" action="{{ route('admin.posts.destroy', $post) }}"
                              onsubmit="return confirm('Delete this post?')">
                            @csrf @method('DELETE')
                            <button class="text-red-600 hover:underline">Delete</button>
                        </form>
                    </div>
                </td>
            </tr>
            @empty
            <tr><td colspan="6" class="p-8 text-center text-gray-400">No posts yet.</td></tr>
            @endforelse
        </tbody>
    </table>
</div>

<div class="mt-4">{{ $posts->links() }}</div>
@endsection
💡
Always use Storage::disk('public')->delete() when deleting a post with an image. If you don't, the image file stays on disk forever wasting storage space.
💡
@forelse is better than @foreach for lists! It has a built-in @empty block for when there's no data — no need for a separate @if(count($posts) > 0) check.
← Previous Next Lesson →
💡

Stuck? Need help?

Review the previous lessons or check the Laravel documentation.

Laravel Docs →