Laravel Framework

Building a Complete CRUD Application

20 min Lesson 35 of 45

Introduction to CRUD Applications

CRUD stands for Create, Read, Update, Delete - the four basic operations for data management in any application. In this comprehensive lesson, we'll build a complete blog post management system that demonstrates best practices for Laravel development, including models, migrations, controllers, views, validation, file uploads, relationships, search, filtering, and pagination.

What We'll Build:

  • Blog post management system with full CRUD operations
  • Featured image uploads with validation
  • Category and tag relationships (many-to-many)
  • Rich text editor for post content
  • Search and filter functionality
  • Pagination with sorting options
  • Form validation and error handling
  • Authorization with policies

Step 1: Database Design and Migrations

First, let's create the database structure for our blog system:

# Create migrations
php artisan make:migration create_posts_table
php artisan make:migration create_categories_table
php artisan make:migration create_tags_table
php artisan make:migration create_post_tag_table
<?php
// database/migrations/xxxx_create_posts_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()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('excerpt')->nullable();
            $table->longText('content');
            $table->string('featured_image')->nullable();
            $table->enum('status', ['draft', 'published', 'archived'])->default('draft');
            $table->timestamp('published_at')->nullable();
            $table->integer('views')->default(0);
            $table->timestamps();

            // Indexes for performance
            $table->index('status');
            $table->index('published_at');
            $table->index(['status', 'published_at']);
            $table->fullText(['title', 'content']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('posts');
    }
};
<?php
// database/migrations/xxxx_create_categories_table.php
public function up()
{
    Schema::create('categories', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('slug')->unique();
        $table->text('description')->nullable();
        $table->timestamps();
    });
}

// database/migrations/xxxx_create_tags_table.php
public function up()
{
    Schema::create('tags', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('slug')->unique();
        $table->timestamps();
    });
}

// database/migrations/xxxx_create_post_tag_table.php
public function up()
{
    Schema::create('post_tag', function (Blueprint $table) {
        $table->foreignId('post_id')->constrained()->onDelete('cascade');
        $table->foreignId('tag_id')->constrained()->onDelete('cascade');
        $table->primary(['post_id', 'tag_id']);
    });
}
# Run migrations
php artisan migrate

Step 2: Creating Models with Relationships

# Create models
php artisan make:model Post
php artisan make:model Category
php artisan make:model Tag
<?php
// app/Models/Post.php
namespace App\Models;

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

class Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id', 'category_id', 'title', 'slug', 'excerpt',
        'content', 'featured_image', 'status', 'published_at', 'views'
    ];

    protected $casts = [
        'published_at' => 'datetime',
        'views' => 'integer',
    ];

    // Relationships
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function category()
    {
        return $this->belongsTo(Category::class);
    }

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    // Scopes
    public function scopePublished($query)
    {
        return $query->where('status', 'published')
            ->where('published_at', '<=', now());
    }

    public function scopeSearch($query, $search)
    {
        return $query->where(function($q) use ($search) {
            $q->where('title', 'like', "%{$search}%")
              ->orWhere('content', 'like', "%{$search}%")
              ->orWhereHas('tags', function($tagQuery) use ($search) {
                  $tagQuery->where('name', 'like', "%{$search}%");
              });
        });
    }

    public function scopeByCategory($query, $categoryId)
    {
        return $query->where('category_id', $categoryId);
    }

    // Accessors & Mutators
    public function getExcerptAttribute($value)
    {
        return $value ?: Str::limit(strip_tags($this->content), 150);
    }

    public function setTitleAttribute($value)
    {
        $this->attributes['title'] = $value;
        $this->attributes['slug'] = Str::slug($value);
    }

    // Helper methods
    public function isPublished()
    {
        return $this->status === 'published' && $this->published_at <= now();
    }

    public function incrementViews()
    {
        $this->increment('views');
    }
}
<?php
// app/Models/Category.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    protected $fillable = ['name', 'slug', 'description'];

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

// app/Models/Tag.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    protected $fillable = ['name', 'slug'];

    public function posts()
    {
        return $this->belongsToMany(Post::class);
    }
}

Step 3: Form Requests for Validation

# Create form request classes
php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequest
<?php
// app/Http/Requests/StorePostRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StorePostRequest extends FormRequest
{
    public function authorize()
    {
        return true; // Handle with policy
    }

    public function rules()
    {
        return [
            'title' => 'required|string|max:255|unique:posts,title',
            'category_id' => 'required|exists:categories,id',
            'excerpt' => 'nullable|string|max:500',
            'content' => 'required|string|min:100',
            'featured_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
            'tags' => 'nullable|array',
            'tags.*' => 'exists:tags,id',
            'status' => 'required|in:draft,published,archived',
            'published_at' => 'nullable|date|after_or_equal:today',
        ];
    }

    public function messages()
    {
        return [
            'title.required' => 'Please enter a title for your post',
            'title.unique' => 'A post with this title already exists',
            'content.min' => 'Post content must be at least 100 characters',
            'featured_image.max' => 'Image size must not exceed 2MB',
            'category_id.exists' => 'Please select a valid category',
        ];
    }
}

// app/Http/Requests/UpdatePostRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdatePostRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'title' => [
                'required',
                'string',
                'max:255',
                Rule::unique('posts')->ignore($this->post->id)
            ],
            'category_id' => 'required|exists:categories,id',
            'excerpt' => 'nullable|string|max:500',
            'content' => 'required|string|min:100',
            'featured_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
            'tags' => 'nullable|array',
            'tags.*' => 'exists:tags,id',
            'status' => 'required|in:draft,published,archived',
            'published_at' => 'nullable|date',
        ];
    }
}

Step 4: Controller with CRUD Operations

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

use App\Models\Post;
use App\Models\Category;
use App\Models\Tag;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class PostController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except(['index', 'show']);
    }

    // Display listing with search, filter, sort
    public function index(Request $request)
    {
        $query = Post::with(['user', 'category', 'tags'])
            ->withCount('tags');

        // Search
        if ($search = $request->input('search')) {
            $query->search($search);
        }

        // Filter by category
        if ($categoryId = $request->input('category')) {
            $query->byCategory($categoryId);
        }

        // Filter by status
        if ($status = $request->input('status')) {
            $query->where('status', $status);
        } else {
            // Default: show only published posts for guests
            if (!auth()->check()) {
                $query->published();
            }
        }

        // Sort
        $sortBy = $request->input('sort', 'created_at');
        $sortOrder = $request->input('order', 'desc');
        $query->orderBy($sortBy, $sortOrder);

        $posts = $query->paginate(15)->withQueryString();

        $categories = Category::withCount('posts')->get();

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

    // Show create form
    public function create()
    {
        $categories = Category::all();
        $tags = Tag::all();

        return view('posts.create', compact('categories', 'tags'));
    }

    // Store new post
    public function store(StorePostRequest $request)
    {
        $data = $request->validated();
        $data['user_id'] = auth()->id();

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

        // Set published_at if status is published
        if ($data['status'] === 'published' && !isset($data['published_at'])) {
            $data['published_at'] = now();
        }

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

        // Attach tags
        if ($request->has('tags')) {
            $post->tags()->attach($request->tags);
        }

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

    // Display single post
    public function show(Post $post)
    {
        // Eager load relationships
        $post->load(['user', 'category', 'tags']);

        // Increment views
        $post->incrementViews();

        // Get related posts
        $relatedPosts = Post::published()
            ->where('category_id', $post->category_id)
            ->where('id', '!=', $post->id)
            ->limit(4)
            ->get();

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

    // Show edit form
    public function edit(Post $post)
    {
        $this->authorize('update', $post);

        $categories = Category::all();
        $tags = Tag::all();

        return view('posts.edit', compact('post', 'categories', 'tags'));
    }

    // Update post
    public function update(UpdatePostRequest $request, Post $post)
    {
        $this->authorize('update', $post);

        $data = $request->validated();

        // Handle file upload
        if ($request->hasFile('featured_image')) {
            // Delete old image
            if ($post->featured_image) {
                Storage::disk('public')->delete($post->featured_image);
            }

            $data['featured_image'] = $request->file('featured_image')
                ->store('posts', 'public');
        }

        // Set published_at if status changed to published
        if ($data['status'] === 'published' && !$post->published_at) {
            $data['published_at'] = now();
        }

        $post->update($data);

        // Sync tags
        if ($request->has('tags')) {
            $post->tags()->sync($request->tags);
        } else {
            $post->tags()->detach();
        }

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

    // Delete post
    public function destroy(Post $post)
    {
        $this->authorize('delete', $post);

        // Delete featured image
        if ($post->featured_image) {
            Storage::disk('public')->delete($post->featured_image);
        }

        $post->delete();

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

Step 5: Authorization with Policy

# Create policy
php artisan make:policy PostPolicy --model=Post
<?php
// app/Policies/PostPolicy.php
namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function viewAny(?User $user)
    {
        return true;
    }

    public function view(?User $user, Post $post)
    {
        return $post->isPublished() || $user?->id === $post->user_id;
    }

    public function create(User $user)
    {
        return true; // All authenticated users can create
    }

    public function update(User $user, Post $post)
    {
        return $user->id === $post->user_id;
    }

    public function delete(User $user, Post $post)
    {
        return $user->id === $post->user_id;
    }
}

Step 6: Routes Configuration

<?php
// routes/web.php
use App\Http\Controllers\PostController;

Route::resource('posts', PostController::class);

// Additional custom routes if needed
Route::get('/posts/category/{category:slug}', [PostController::class, 'byCategory'])
    ->name('posts.by-category');

Route::get('/posts/tag/{tag:slug}', [PostController::class, 'byTag'])
    ->name('posts.by-tag');

Step 7: Views - Index Page

<!-- resources/views/posts/index.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8">
            <h1>Blog Posts</h1>

            <!-- Search and Filter Form -->
            <form method="GET" class="mb-4">
                <div class="row">
                    <div class="col-md-6">
                        <input type="text" name="search" class="form-control"
                               placeholder="Search posts..." value="{{ request('search') }}">
                    </div>
                    <div class="col-md-4">
                        <select name="category" class="form-control">
                            <option value="">All Categories</option>
                            @foreach($categories as $category)
                                <option value="{{ $category->id }}"
                                    {{ request('category') == $category->id ? 'selected' : '' }}>
                                    {{ $category->name }} ({{ $category->posts_count }})
                                </option>
                            @endforeach
                        </select>
                    </div>
                    <div class="col-md-2">
                        <button type="submit" class="btn btn-primary w-100">Filter</button>
                    </div>
                </div>
            </form>

            <!-- Posts List -->
            @forelse($posts as $post)
                <article class="post-card mb-4">
                    @if($post->featured_image)
                        <img src="{{ asset('storage/' . $post->featured_image) }}"
                             alt="{{ $post->title }}" class="img-fluid">
                    @endif

                    <h2>
                        <a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
                    </h2>

                    <div class="post-meta">
                        By {{ $post->user->name }}
                        | {{ $post->published_at->format('M d, Y') }}
                        | {{ $post->category->name }}
                        | {{ $post->views }} views
                    </div>

                    <p>{{ $post->excerpt }}</p>

                    <div class="post-tags">
                        @foreach($post->tags as $tag)
                            <span class="badge bg-secondary">{{ $tag->name }}</span>
                        @endforeach
                    </div>

                    @can('update', $post)
                        <a href="{{ route('posts.edit', $post) }}" class="btn btn-sm btn-warning">Edit</a>
                    @endcan
                </article>
            @empty
                <p>No posts found.</p>
            @endforelse

            <!-- Pagination -->
            {{ $posts->links() }}
        </div>

        <div class="col-md-4">
            <!-- Sidebar with categories -->
            <h3>Categories</h3>
            <ul class="list-group">
                @foreach($categories as $category)
                    <li class="list-group-item d-flex justify-content-between">
                        <a href="?category={{ $category->id }}">{{ $category->name }}</a>
                        <span class="badge bg-primary">{{ $category->posts_count }}</span>
                    </li>
                @endforeach
            </ul>
        </div>
    </div>
</div>
@endsection

Step 8: Create/Edit Form

<!-- resources/views/posts/form.blade.php (shared by create & edit) -->
<div class="row">
    <div class="col-md-8">
        <div class="mb-3">
            <label for="title" class="form-label">Title</label>
            <input type="text" name="title" id="title" class="form-control @error('title') is-invalid @enderror"
                   value="{{ old('title', $post->title ?? '') }}" required>
            @error('title')
                <div class="invalid-feedback">{{ $message }}</div>
            @enderror
        </div>

        <div class="mb-3">
            <label for="excerpt" class="form-label">Excerpt</label>
            <textarea name="excerpt" id="excerpt" rows="3"
                      class="form-control @error('excerpt') is-invalid @enderror">{{ old('excerpt', $post->excerpt ?? '') }}</textarea>
            @error('excerpt')
                <div class="invalid-feedback">{{ $message }}</div>
            @enderror
        </div>

        <div class="mb-3">
            <label for="content" class="form-label">Content</label>
            <textarea name="content" id="content" rows="15"
                      class="form-control @error('content') is-invalid @enderror" required>{{ old('content', $post->content ?? '') }}</textarea>
            @error('content')
                <div class="invalid-feedback">{{ $message }}</div>
            @enderror
        </div>
    </div>

    <div class="col-md-4">
        <div class="mb-3">
            <label for="status" class="form-label">Status</label>
            <select name="status" id="status" class="form-control" required>
                <option value="draft" {{ old('status', $post->status ?? 'draft') === 'draft' ? 'selected' : '' }}>Draft</option>
                <option value="published" {{ old('status', $post->status ?? '') === 'published' ? 'selected' : '' }}>Published</option>
                <option value="archived" {{ old('status', $post->status ?? '') === 'archived' ? 'selected' : '' }}>Archived</option>
            </select>
        </div>

        <div class="mb-3">
            <label for="category_id" class="form-label">Category</label>
            <select name="category_id" id="category_id" class="form-control @error('category_id') is-invalid @enderror" required>
                <option value="">Select Category</option>
                @foreach($categories as $category)
                    <option value="{{ $category->id }}"
                        {{ old('category_id', $post->category_id ?? '') == $category->id ? 'selected' : '' }}>
                        {{ $category->name }}
                    </option>
                @endforeach
            </select>
            @error('category_id')
                <div class="invalid-feedback">{{ $message }}</div>
            @enderror
        </div>

        <div class="mb-3">
            <label for="tags" class="form-label">Tags</label>
            <select name="tags[]" id="tags" class="form-control" multiple>
                @foreach($tags as $tag)
                    <option value="{{ $tag->id }}"
                        {{ in_array($tag->id, old('tags', $post->tags->pluck('id')->toArray() ?? [])) ? 'selected' : '' }}>
                        {{ $tag->name }}
                    </option>
                @endforeach
            </select>
        </div>

        <div class="mb-3">
            <label for="featured_image" class="form-label">Featured Image</label>
            @if(isset($post) && $post->featured_image)
                <img src="{{ asset('storage/' . $post->featured_image) }}" class="img-fluid mb-2">
            @endif
            <input type="file" name="featured_image" id="featured_image"
                   class="form-control @error('featured_image') is-invalid @enderror">
            @error('featured_image')
                <div class="invalid-feedback">{{ $message }}</div>
            @enderror
        </div>

        <div class="mb-3">
            <label for="published_at" class="form-label">Publish Date</label>
            <input type="datetime-local" name="published_at" id="published_at"
                   class="form-control"
                   value="{{ old('published_at', $post->published_at?->format('Y-m-d\TH:i') ?? '') }}">
        </div>

        <button type="submit" class="btn btn-primary w-100">
            {{ isset($post) ? 'Update Post' : 'Create Post' }}
        </button>
    </div>
</div>

Rich Text Editor: For a better content editing experience, integrate a WYSIWYG editor like TinyMCE, CKEditor, or Trix. Add the editor's JavaScript library and initialize it on the content textarea.

Practice Exercise 1: Add Comment System

Extend the blog application with a comment system:

  • Create Comment model with migration (post_id, user_id, content, parent_id for replies)
  • Set up relationships (post hasMany comments, comment belongsTo post/user)
  • Create CommentController with store/update/destroy methods
  • Add comment form to post show page
  • Display comments with nested replies
  • Implement comment approval system (approved column)
  • Add policies for comment management

Practice Exercise 2: Advanced Search with Filters

Enhance the search functionality:

  • Add date range filter (published between X and Y)
  • Add author filter dropdown
  • Implement tag filtering (show posts with selected tags)
  • Add sorting options (newest, oldest, most viewed, most commented)
  • Save filter preferences in session
  • Add "Clear Filters" button
  • Show active filters as removable badges

Practice Exercise 3: Post Analytics Dashboard

Create an analytics dashboard for post authors:

  • Total views per post (chart)
  • Views over time (line chart - last 30 days)
  • Most popular posts (top 10)
  • Category performance comparison
  • Engagement metrics (comments, likes if implemented)
  • Export analytics to CSV
  • Cache expensive analytics queries

Bonus: Use Chart.js or ApexCharts for visualizations.

Summary

Congratulations! You've built a complete CRUD application with Laravel. This lesson covered:

  • Database design with proper relationships and indexes
  • Creating models with Eloquent relationships and scopes
  • Form request validation with custom messages
  • Resource controller with full CRUD operations
  • File upload handling with validation
  • Authorization using policies
  • Search, filtering, and sorting functionality
  • Pagination with query string preservation
  • Blade views with forms and error handling
  • Many-to-many relationships with tags
  • Image storage and deletion
  • Best practices for Laravel development

This comprehensive application demonstrates production-ready patterns you can use in real-world projects. You now have the skills to build any CRUD-based application with Laravel, handling complex relationships, validation, authorization, and user interactions.