Building a Complete CRUD Application
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.