Laravel 12 ile Blog Sitesi Kurulumu: Auth, Yorum ve Captcha
Laravel 12 ile sıfırdan blog sitesi kurmayı; authentication, yazı modeli, kategori, yorum sistemi, captcha, route, controller, Blade ve test adımlarıyla anlatıyorum.
Bu yazıda Laravel 12 ile sıfırdan çalışır bir blog sitesi kuracağız: kullanıcı kayıt/giriş işlemleri olacak, yazılar kategoriye bağlanacak, giriş yapan kullanıcılar blog yazılarına yorum yazabilecek ve yorum formuna basit bir captcha ekleyeceğiz. Amacımız sadece “ekranda yazı listelemek” değil; gerçek projede lazım olan temel iskeleti kurmak: migration, model, ilişki, route, controller, validation, Blade, auth middleware, yorum onayı ve spam kontrolü.
Bu anlatım yeni başlayan biri terminali açıp adımları takip edebilsin diye yazıldı. Kodları sade tuttum; ama mantığı eksik bırakmadım. Captcha tarafında dış paket kullanmayacağız. Session tabanlı basit matematik captcha kuracağız. Production ortamında Cloudflare Turnstile veya Google reCAPTCHA gibi servisler tercih edilebilir; fakat öğrenme ve temel spam koruması için bu yöntem gayet anlaşılırdır.
Proje Hedefi
- Laravel 12 projesi kurulacak.
- Authentication starter kit ile kayıt, giriş ve çıkış işlemleri hazır olacak.
- Blog kategorileri ve blog yazıları veritabanında tutulacak.
- Yazı detay sayfasında yorumlar listelenecek.
- Yorum yazmak için kullanıcı girişi gerekecek.
- Yorum formunda captcha doğrulaması olacak.
- Yorumlar varsayılan olarak onay bekleyecek.
- Route, controller, Blade ve test dosyalarıyla proje tamamlanacak.
1. Laravel 12 Projesini Oluştur
Terminali aç ve projelerini tuttuğun klasöre git:
cd ~/Sites
Yeni Laravel projesini oluştur:
laravel new laravel-blog
Installer starter kit sorarsa bu proje için Livewire seçebilirsin. Livewire starter kit, Laravel tarafına yakın kalmak isteyenler için rahat bir auth başlangıcı verir. React veya Vue seçersen de blog mantığı aynı kalır; sadece frontend dosyaları farklılaşır.
Which starter kit would you like to install?
none
react
vue
livewire
Seçim: livewire
Veritabanı için ilk aşamada SQLite seçmek hızlıdır:
Which database will your application use?
sqlite
mysql
pgsql
Seçim: sqlite
Proje klasörüne gir:
cd laravel-blog
2. İlk Kurulum Kontrolleri
Laravel, Composer, Node ve NPM tarafını kontrol et:
php -v
composer -V
node -v
npm -v
php artisan --version
Frontend paketlerini kur:
npm install
Veritabanı migrationlarını çalıştır:
php artisan migrate
Projeyi geliştirme modunda çalıştırmak için iki terminal kullanabilirsin. Bir terminalde Vite:
npm run dev
Diğer terminalde Laravel:
php artisan serve
Tarayıcıda şu adresleri kontrol et:
http://127.0.0.1:8000
http://127.0.0.1:8000/register
http://127.0.0.1:8000/login
http://127.0.0.1:8000/dashboard
Register ve login sayfaları açılıyorsa authentication tarafı hazır demektir. Laravel 12 starter kit'leri login, register, password reset ve email verification gibi temel auth akışlarını hazır getirir.
3. Blog Veritabanı Tasarımı
Bu projede dört ana tablo yeterli olacak:
- users: Starter kit ile hazır gelir.
- categories: Blog kategorileri.
- posts: Blog yazıları.
- comments: Yazılara gelen yorumlar.
Migration dosyalarını oluştur:
php artisan make:model Category -m
php artisan make:model Post -m
php artisan make:model Comment -m
4. Category Migration
`database/migrations/...create_categories_table.php` dosyasını şöyle düzenle:
<?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('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
5. Post Migration
`database/migrations/...create_posts_table.php` dosyasını şöyle düzenle:
<?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->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('body');
$table->string('cover_image')->nullable();
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['is_published', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
6. Comment Migration
`database/migrations/...create_comments_table.php` dosyasını şöyle düzenle:
<?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('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->boolean('is_approved')->default(false);
$table->timestamps();
$table->index(['post_id', 'is_approved']);
});
}
public function down(): void
{
Schema::dropIfExists('comments');
}
};
Migrationları çalıştır:
php artisan migrate
7. Model İlişkileri
`app/Models/Category.php`:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug', 'description'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
`app/Models/Post.php`:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'excerpt',
'body',
'cover_image',
'is_published',
'published_at',
];
protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function approvedComments(): HasMany
{
return $this->comments()->where('is_approved', true);
}
public function scopePublished(Builder $query): Builder
{
return $query
->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
}
`app/Models/Comment.php`:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
use HasFactory;
protected $fillable = [
'post_id',
'user_id',
'body',
'is_approved',
];
protected $casts = [
'is_approved' => 'boolean',
];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
`app/Models/User.php` içine ilişkileri ekle:
use Illuminate\Database\Eloquent\Relations\HasMany;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
8. Örnek Veri Seeder
Blogu hemen test etmek için seeder oluşturalım:
php artisan make:seeder BlogSeeder
`database/seeders/BlogSeeder.php`:
<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class BlogSeeder extends Seeder
{
public function run(): void
{
$user = User::firstOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => Hash::make('password'),
]
);
$category = Category::firstOrCreate(
['slug' => 'laravel'],
[
'name' => 'Laravel',
'description' => 'Laravel rehberleri ve proje notları.',
]
);
Post::firstOrCreate(
['slug' => 'laravel-12-blog-projesi'],
[
'user_id' => $user->id,
'category_id' => $category->id,
'title' => 'Laravel 12 Blog Projesi',
'excerpt' => 'Laravel 12 ile blog sitesi kurulumuna giriş.',
'body' => str_repeat('Bu örnek yazı Laravel blog projesi için hazırlandı. ', 20),
'is_published' => true,
'published_at' => now(),
]
);
}
}
`database/seeders/DatabaseSeeder.php` içinde çağır:
public function run(): void
{
$this->call(BlogSeeder::class);
}
Seeder'ı çalıştır:
php artisan db:seed
9. Controller Dosyalarını Oluştur
Blog liste/detay ve yorum kaydetme için controller oluşturalım:
php artisan make:controller BlogController
php artisan make:controller CommentController
`app/Http/Controllers/BlogController.php`:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class BlogController extends Controller
{
public function index(Request $request)
{
$posts = Post::query()
->published()
->with(['category', 'author'])
->when($request->filled('q'), function ($query) use ($request) {
$term = $request->string('q')->toString();
$query->where(function ($query) use ($term) {
$query
->where('title', 'like', "%{$term}%")
->orWhere('excerpt', 'like', "%{$term}%")
->orWhere('body', 'like', "%{$term}%");
});
})
->latest('published_at')
->paginate(9)
->withQueryString();
return view('blog.index', compact('posts'));
}
public function show(Post $post)
{
abort_unless($post->is_published, 404);
$post->load([
'category',
'author',
'approvedComments.user',
]);
$left = random_int(1, 9);
$right = random_int(1, 9);
session([
'comment_captcha_post_'.$post->id => $left + $right,
]);
$captchaQuestion = "{$left} + {$right} kaçtır?";
return view('blog.show', compact('post', 'captchaQuestion'));
}
}
`show` metodunda captcha sorusunu üretiyoruz ve doğru cevabı session içine yazıyoruz. Bu cevap sadece ilgili yazı için saklanıyor.
10. Yorum Controller ve Captcha Kontrolü
`app/Http/Controllers/CommentController.php`:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class CommentController extends Controller
{
public function store(Request $request, Post $post)
{
abort_unless($post->is_published, 404);
$validated = $request->validate([
'body' => ['required', 'string', 'min:5', 'max:1000'],
'captcha_answer' => ['required', 'integer'],
], [
'body.required' => 'Yorum alanı boş bırakılamaz.',
'body.min' => 'Yorum en az 5 karakter olmalıdır.',
'body.max' => 'Yorum en fazla 1000 karakter olabilir.',
'captcha_answer.required' => 'Captcha cevabını yazmalısın.',
'captcha_answer.integer' => 'Captcha cevabı sayı olmalıdır.',
]);
$sessionKey = 'comment_captcha_post_'.$post->id;
$expected = (int) session($sessionKey);
if ($expected === 0 || (int) $validated['captcha_answer'] !== $expected) {
throw ValidationException::withMessages([
'captcha_answer' => 'Captcha cevabı hatalı. Lütfen tekrar dene.',
]);
}
session()->forget($sessionKey);
$post->comments()->create([
'user_id' => $request->user()->id,
'body' => $validated['body'],
'is_approved' => false,
]);
return back()->with('status', 'Yorumun alındı. Onaylandıktan sonra yayınlanacak.');
}
}
Burada önemli noktalar şunlar: kullanıcı giriş yapmış olmalı, yorum metni validate edilmeli, captcha doğru olmalı ve yorum varsayılan olarak `is_approved=false` kaydedilmeli. Bu sayede spam veya uygunsuz yorumlar doğrudan yayına çıkmaz.
11. Route Tanımları
`routes/web.php` içine blog route'larını ekle:
<?php
use App\Http\Controllers\BlogController;
use App\Http\Controllers\CommentController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => redirect()->route('blog.index'));
Route::get('/blog', [BlogController::class, 'index'])
->name('blog.index');
Route::get('/blog/{post}', [BlogController::class, 'show'])
->name('blog.show');
Route::post('/blog/{post}/comments', [CommentController::class, 'store'])
->middleware(['auth', 'throttle:10,1'])
->name('comments.store');
`auth` middleware yorum yazmak için giriş zorunluluğu getirir. `throttle:10,1` ise aynı kullanıcının bir dakikada çok fazla yorum denemesini sınırlar. Captcha tek başına yeterli değildir; rate limit ile birlikte kullanmak daha sağlıklıdır.
12. Blog Liste Blade Dosyası
Klasörü oluştur:
mkdir -p resources/views/blog
`resources/views/blog/index.blade.php`:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Blog
</h2>
</x-slot>
<div class="py-10">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<form method="GET" action="{{ route('blog.index') }}" class="mb-6">
<input
type="search"
name="q"
value="{{ request('q') }}"
placeholder="Blogda ara..."
class="w-full rounded-md border-gray-300"
>
</form>
<div class="grid gap-6">
@forelse ($posts as $post)
<article class="bg-white shadow-sm rounded-lg p-6">
<div class="text-sm text-gray-500">
{{ $post->category->name }} · {{ $post->published_at->format('d.m.Y') }}
</div>
<h3 class="mt-2 text-2xl font-bold">
<a href="{{ route('blog.show', $post) }}">
{{ $post->title }}
</a>
</h3>
<p class="mt-3 text-gray-700">
{{ $post->excerpt }}
</p>
</article>
@empty
<p class="text-gray-600">Henüz yazı bulunamadı.</p>
@endforelse
</div>
<div class="mt-8">
{{ $posts->links() }}
</div>
</div>
</div>
</x-app-layout>
13. Blog Detay ve Yorum Formu
`resources/views/blog/show.blade.php`:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $post->title }}
</h2>
</x-slot>
<div class="py-10">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
@if (session('status'))
<div class="mb-6 rounded-md bg-green-50 p-4 text-green-700">
{{ session('status') }}
</div>
@endif
<article class="bg-white shadow-sm rounded-lg p-6">
<div class="text-sm text-gray-500">
{{ $post->category->name }} · {{ $post->author->name }} ·
{{ $post->published_at->format('d.m.Y') }}
</div>
<div class="mt-6 prose max-w-none">
{!! nl2br(e($post->body)) !!}
</div>
</article>
<section class="mt-10 bg-white shadow-sm rounded-lg p-6">
<h3 class="text-xl font-bold">Yorumlar</h3>
<div class="mt-5 space-y-5">
@forelse ($post->approvedComments as $comment)
<div class="border-b pb-4">
<div class="text-sm text-gray-500">
{{ $comment->user->name }} · {{ $comment->created_at->diffForHumans() }}
</div>
<p class="mt-2 text-gray-800">{{ $comment->body }}</p>
</div>
@empty
<p class="text-gray-600">Henüz onaylanmış yorum yok.</p>
@endforelse
</div>
</section>
<section class="mt-8 bg-white shadow-sm rounded-lg p-6">
<h3 class="text-xl font-bold">Yorum Yaz</h3>
@auth
<form method="POST" action="{{ route('comments.store', $post) }}" class="mt-5 space-y-4">
@csrf
<div>
<label for="body" class="block text-sm font-medium text-gray-700">Yorumun</label>
<textarea
id="body"
name="body"
rows="5"
class="mt-1 w-full rounded-md border-gray-300"
>{{ old('body') }}</textarea>
@error('body')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="captcha_answer" class="block text-sm font-medium text-gray-700">
Captcha: {{ $captchaQuestion }}
</label>
<input
id="captcha_answer"
name="captcha_answer"
type="number"
class="mt-1 w-full rounded-md border-gray-300"
>
@error('captcha_answer')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="rounded-md bg-gray-900 px-4 py-2 text-white">
Yorumu Gönder
</button>
</form>
@else
<p class="mt-4 text-gray-700">
Yorum yazmak için
<a class="underline" href="{{ route('login') }}">giriş yap</a>
veya
<a class="underline" href="{{ route('register') }}">kayıt ol</a>.
</p>
@endauth
</section>
</div>
</div>
</x-app-layout>
Yorumları gösterirken `approvedComments` ilişkisini kullandık. Böylece onay bekleyen yorumlar herkese görünmez.
14. Yorum Onaylama Mantığı
Bu yazıda admin panel kurmuyoruz; fakat yorum onayını terminalden veya basit bir admin ekranından yapabilirsin. Terminalde hızlı test için Tinker kullan:
php artisan tinker
Son yorumu onayla:
$comment = App\Models\Comment::latest()->first();
$comment->update(['is_approved' => true]);
Tarayıcıda yazı detayını yenilediğinde yorum görünmelidir.
15. Captcha Neden Böyle Kuruldu?
Captcha için iki yol vardır. Birincisi Google reCAPTCHA, Cloudflare Turnstile veya hCaptcha gibi dış servisler kullanmak. İkincisi basit projelerde session tabanlı matematik sorusu kullanmak. Bu rehberde ikinci yolu seçtik çünkü paket kurulumu, API anahtarı ve harici servis ayarına gerek kalmadan mantığı öğreniyorsun.
Bu captcha profesyonel bot koruması değildir; ama temel spam denemelerini azaltır. Production projede rate limit, moderation, IP kontrolü, e-posta doğrulama ve gerekirse Turnstile/reCAPTCHA ile desteklenmelidir.
16. Test Yazalım
Yorum akışının bozulmadığından emin olmak için feature test oluşturalım:
php artisan make:test BlogCommentTest
`tests/Feature/BlogCommentTest.php`:
<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BlogCommentTest extends TestCase
{
use RefreshDatabase;
public function test_guest_cannot_write_comment(): void
{
$post = $this->publishedPost();
$this->post(route('comments.store', $post), [
'body' => 'Misafir yorum denemesi',
'captcha_answer' => 5,
])->assertRedirect(route('login'));
}
public function test_authenticated_user_can_write_comment_with_valid_captcha(): void
{
$user = User::factory()->create();
$post = $this->publishedPost();
$this->withSession([
'comment_captcha_post_'.$post->id => 7,
])
->actingAs($user)
->post(route('comments.store', $post), [
'body' => 'Çok faydalı bir yazı olmuş.',
'captcha_answer' => 7,
])
->assertSessionHas('status');
$this->assertDatabaseHas('comments', [
'post_id' => $post->id,
'user_id' => $user->id,
'is_approved' => false,
]);
}
public function test_wrong_captcha_is_rejected(): void
{
$user = User::factory()->create();
$post = $this->publishedPost();
$this->withSession([
'comment_captcha_post_'.$post->id => 7,
])
->actingAs($user)
->post(route('comments.store', $post), [
'body' => 'Captcha hatalı olsun.',
'captcha_answer' => 8,
])
->assertSessionHasErrors('captcha_answer');
}
private function publishedPost(): Post
{
$user = User::factory()->create();
$category = Category::create([
'name' => 'Laravel',
'slug' => 'laravel',
]);
return Post::create([
'user_id' => $user->id,
'category_id' => $category->id,
'title' => 'Test Yazısı',
'slug' => 'test-yazisi',
'excerpt' => 'Test özeti',
'body' => 'Test yazı içeriği',
'is_published' => true,
'published_at' => now(),
]);
}
}
Testleri çalıştır:
php artisan test
17. Güvenlik ve Kalite Kontrol Listesi
- Yorum formunda `@csrf` kullanıldı.
- Yorum yazmak için `auth` middleware zorunlu.
- Yorum route'una `throttle` eklendi.
- Captcha cevabı session ile kontrol edildi.
- Yorumlar varsayılan olarak onay bekliyor.
- Yazı gövdesi ekrana basılırken `e()` ile escape edildi.
- Yayınlanmamış yazılar detayda 404 döndürüyor.
- Slug ile route model binding kullanıldı.
18. Production İçin Eklenebilecekler
Bu blog projesi sağlam bir başlangıçtır. Gerçek yayında şu özellikleri eklemek iyi olur:
- Admin panel: Filament ile yazı, kategori ve yorum yönetimi.
- SEO alanları: meta title, meta description, canonical, Open Graph görseli.
- Görsel yükleme: `cover_image` için storage upload ve image optimization.
- Spam koruması: Cloudflare Turnstile, reCAPTCHA veya hCaptcha.
- Yorum bildirimleri: yeni yorum geldiğinde admin maili.
- Markdown veya rich text editor: yazı gövdesini daha rahat düzenlemek için.
- Sitemap ve RSS feed: blogun arama motorları tarafından daha iyi keşfedilmesi için.
19. Hızlı Komut Özeti
laravel new laravel-blog
cd laravel-blog
npm install
php artisan migrate
php artisan make:model Category -m
php artisan make:model Post -m
php artisan make:model Comment -m
php artisan make:controller BlogController
php artisan make:controller CommentController
php artisan make:seeder BlogSeeder
php artisan db:seed
npm run dev
php artisan serve
php artisan test
Sonuç
Laravel 12 ile blog sitesi kurarken asıl kalite, yazı listelemekten daha fazlasıdır. Authentication ile kullanıcı kimliğini netleştirirsin, yorum sistemiyle etkileşim kurarsın, captcha ve throttle ile spam riskini azaltırsın, onay mekanizmasıyla içerik güvenliğini korursun. Bu rehberde kurduğumuz yapı küçük ama gerçek bir blog projesinin temelidir. Üzerine admin panel, SEO, medya yönetimi ve gelişmiş spam koruması ekleyerek production seviyesine taşıyabilirsin.
Kaynak notu: Authentication tarafında Laravel 12 starter kit ve auth dokümantasyonu temel alınmıştır. Validation, session, CSRF ve middleware davranışları Laravel'in yerleşik mekanizmalarıyla ilerler.