Hey there! I’m excited to share my journey of building a CRUD (Create, Read, Update, Delete) application with image upload using Laravel 12, the latest version of this amazing PHP framework. If you’re new to Laravel or want to learn how to handle database operations and image uploads, this guide is for you.
I’ll walk you through setting up a simple blog post system where you can create, view, edit, and delete posts, complete with image uploads and SEO-friendly meta tags. My goal is to keep things simple, clear, and beginner-friendly, so let’s get started!
First, I need to set up a fresh Laravel 12 project. Make sure you have PHP 8.2 or higher, Composer, and a database like MySQL installed. Open your terminal and run:
composer create-project laravel/laravel laravel-crud-app
cd laravel-crud-app
This creates a new Laravel project called laravel-crud-app
. To check if it’s working, I start the development server:
php artisan serve
I visit http://localhost:8000
in my browser and see the Laravel welcome page—success!
Next, I set up a MySQL database. I create a database named laravel_crud
using a tool like phpMyAdmin. Then, I update the .env
file in the project root with my database details:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_crud
DB_USERNAME=root
DB_PASSWORD=
I replace DB_USERNAME
and DB_PASSWORD
with my actual MySQL credentials.
To manage blog posts, I create a Post
model with a migration and controller. Laravel’s Artisan command makes this easy:
php artisan make:model Post -mcr
This generates:
Post
model in app/Models/Post.php
database/migrations
PostController
in app/Http/Controllers
I open the migration file (e.g., database/migrations/2025_07_13_XXXXXX_create_posts_table.php
) and define the table structure:
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->text('content');
$table->string('image')->nullable();
$table->string('meta_keywords')->nullable();
$table->string('meta_description')->nullable();
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('posts');
}
};
I run the migration to create the posts
table:
php artisan migrate
Then, I update the Post
model (app/Models/Post.php
) to allow mass assignment:
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model {
use HasFactory;
protected $fillable = ['title', 'content', 'image', 'meta_keywords', 'meta_description'];
}
I define routes for the CRUD operations in routes/web.php
:
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::resource('posts', PostController::class);
This sets up routes for listing (index
), creating (create
, store
), viewing (show
), editing (edit
, update
), and deleting (destroy
) posts.
I need views to display and manage posts. First, I create a layout file at resources/views/layouts/app.blade.php
with dynamic meta tags for SEO:
<!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 CRUD App')</title>
<meta name="keywords" content="@yield('meta_keywords', 'Laravel, CRUD, blog, image upload')">
<meta name="description" content="@yield('meta_description', 'A simple Laravel 12 CRUD application with image upload')">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
@yield('content')
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Now, I create views in resources/views/posts/
:
@extends('layouts.app')
@section('title', 'All Posts')
@section('content')
<h1>All Posts</h1>
<a href="{{ route('posts.create') }}" class="btn btn-primary mb-3">Create New Post</a>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Image</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->title }}</td>
<td>
@if($post->image)
<img src="{{ asset('storage/' . $post->image) }}" alt="{{ $post->title }}" width="100">
@endif
</td>
<td>
<a href="{{ route('posts.show', $post) }}" class="btn btn-info btn-sm">View</a>
<a href="{{ route('posts.edit', $post) }}" class="btn btn-warning btn-sm">Edit</a>
<form action="{{ route('posts.destroy', $post) }}" method="POST" style="display:inline;">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure?')">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
@endsection
@extends('layouts.app')
@section('title', 'Create Post')
@section('content')
<h1>Create Post</h1>
<form action="{{ route('posts.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" name="title" class="form-control" required>
@error('title') <span class="text-danger">{{ $message }}</span> @enderror
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<textarea name="content" class="form-control" required></textarea>
@error('content') <span class="text-danger">{{ $message }}</span> @enderror
</div>
<div class="mb-3">
<label for="image" class="form-label">Image</label>
<input type="file" name="image" class="form-control">
@error('image') <span class="text-danger">{{ $message }}</span> @enderror
</div>
<div class="mb-3">
<label for="meta_keywords" class="form-label">Meta Keywords</label>
<input type="text" name="meta_keywords" class="form-control">
</div>
<div class="mb-3">
<label for="meta_description" class="form-label">Meta Description</label>
<input type="text" name="meta_description" class="form-control">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
@endsection
@extends('layouts.app')
@section('title', 'Edit Post')
@section('content')
<h1>Edit Post</h1>
<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">Title</label>
<input type="text" name="title" class="form-control" value="{{ $post->title }}" required>
@error('title') <span class="text-danger">{{ $message }}</span> @enderror
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<textarea name="content" class="form-control" required>{{ $post->content }}</textarea>
@error('content') <span class="text-danger">{{ $message }}</span> @enderror
</div>
<div class="mb-3">
<label for="image" class="form-label">Image</label>
<input type="file" name="image" class="form-control">
@if($post->image)
<img src="{{ asset('storage/' . $post->image) }}" alt="{{ $post->title }}" width="100" class="mt-2">
@endif
@error('image') <span class="text-danger">{{ $message }}</span> @enderror
</div>
<div class="mb-3">
<label for="meta_keywords" class="form-label">Meta Keywords</label>
<input type="text" name="meta_keywords" class="form-control" value="{{ $post->meta_keywords }}">
</div>
<div class="mb-3">
<label for="meta_description" class="form-label">Meta Description</label>
<input type="text" name="meta_description" class="form-control" value="{{ $post->meta_description }}">
</div>
<button type="submit" class="btn btn-primary">Update</button>
</form>
@endsection
@extends('layouts.app')
@section('title', $post->title)
@section('meta_keywords', $post->meta_keywords)
@section('meta_description', $post->meta_description)
@section('content')
<h1>{{ $post->title }}</h1>
@if($post->image)
<img src="{{ asset('storage/' . $post->image) }}" alt="{{ $post->title }}" class="img-fluid mb-3">
@endif
<p>{{ $post->content }}</p>
<a href="{{ route('posts.index') }}" class="btn btn-secondary">Back to Posts</a>
@endsection
I update app/Http/Controllers/PostController.php
to handle CRUD operations and image uploads:
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller {
public function index() {
$posts = Post::all();
return view('posts.index', compact('posts'));
}
public function create() {
return view('posts.create');
}
public function store(Request $request) {
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required',
'image' => 'nullable|image|max:2048',
'meta_keywords' => 'nullable|string',
'meta_description' => 'nullable|string',
]);
$data = $request->all();
if ($request->hasFile('image')) {
$data['image'] = $request->file('image')->store('images', 'public');
}
Post::create($data);
return redirect()->route('posts.index')->with('success', 'Post created successfully!');
}
public function show(Post $post) {
return view('posts.show', compact('post'));
}
public function edit(Post $post) {
return view('posts.edit', compact('post'));
}
public function update(Request $request, Post $post) {
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required',
'image' => 'nullable|image|max:2048',
'meta_keywords' => 'nullable|string',
'meta_description' => 'nullable|string',
]);
$data = $request->all();
if ($request->hasFile('image')) {
if ($post->image) {
Storage::disk('public')->delete($post->image);
}
$data['image'] = $request->file('image')->store('images', 'public');
}
$post->update($data);
return redirect()->route('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('posts.index')->with('success', 'Post deleted successfully!');
}
}
To store images, I link the storage directory for public access:
php artisan storage:link
This creates a symlink from public/storage
to storage/app/public
.
I restart the server (php artisan serve
) and visit http://localhost:8000/posts
. I can now:
I’ve built a fully functional Laravel 12 CRUD application with image upload and SEO-friendly meta tags. From setting up the project to creating models, views, and controllers, I’ve learned how Laravel simplifies web development. The dynamic meta tags ensure each post is search engine-friendly, boosting visibility. This project is a great foundation—you can extend it with features like user authentication, pagination, or advanced image processing. I hope you found this guide as exciting to follow as I did to write!
Q1: What is a CRUD application in Laravel?
A CRUD application allows you to Create, Read, Update, and Delete data in a database. In this guide, we built one for managing blog posts with images.
Q2: How does Laravel handle image uploads?
Laravel uses the Storage
facade to store uploaded files. We validated images, stored them in the public
disk, and linked the storage directory for access.
Q3: Why include meta tags in a Laravel app?
Meta tags like keywords and descriptions help search engines understand your content, improving your site’s ranking and click-through rates.
Q4: Can I add authentication to this app?
Absolutely! Use Laravel Breeze or Jetstream. Run php artisan breeze:install
to add login and registration features easily.
Q5: How do I improve SEO in Laravel further?
Add unique slugs to URLs, use a sitemap, optimize page load speed with caching, and ensure mobile-friendliness with responsive design.
You might also like :