Building a Simple Blog in Laravel
Table of Contents
A disciplined approach to shipping something small — and correct
There is a temptation, when building a blog, to reach for a CMS, a headless stack, a JavaScript framework, or an ecosystem of packages. But at its core, a blog is not complex. It is a publishing surface built on a small, well-defined domain model.
In Laravel, you can build a clean, reliable blog in an afternoon — if you resist the urge to overcomplicate it.
This is how.
Start with the Domain
Every system should begin with a clear definition of what it manages. In a blog, that unit is the Post.
Instead of scaffolding everything at once, generate only what you need:
php artisan make:model Post -m
The migration is where the important architectural decisions live.
1Schema::create('posts', function (Blueprint $table) { 2 $table->id(); 3 $table->string('title'); 4 $table->string('slug')->unique(); 5 $table->text('excerpt')->nullable(); 6 $table->longText('content'); 7 $table->timestamp('published_at')->nullable(); 8 $table->timestamps(); 9});
There are three deliberate choices here.
First, we use a slug instead of relying on an ID in the URL. That makes routes stable and human-readable.
Second, we avoid a boolean like is_published. Instead, published_at represents state. A null value means draft. A future timestamp means scheduled. A past timestamp means published. This gives us flexibility without additional columns.
Third, we allow an optional excerpt so the listing page remains readable and structured.
Already, the system is expressive — without being complicated.
Let the Model Express Intent
The Post model should carry the meaning of the system, not the controller.
1class Post extends Model 2{ 3 protected $fillable = [ 4 'title', 5 'slug', 6 'excerpt', 7 'content', 8 'published_at', 9 ]; 10 11 protected $casts = [ 12 'published_at' => 'datetime', 13 ]; 14 15 public function scopePublished($query) 16 { 17 return $query->whereNotNull('published_at') 18 ->where('published_at', '<=', now()); 19 } 20}
That published() scope is subtle but important.
It prevents publication logic from leaking into multiple controllers or views. If the definition of “published” changes later, you change it once.
This is small-system thinking: localise responsibility.
Routes That Reflect Structure
Routing should mirror how a reader moves through the site.
In routes/web.php:
1Route::get('/', [PostController::class, 'index']); 2Route::get('/posts/{slug}', [PostController::class, 'show']);
Two endpoints. Nothing more.
The homepage lists posts. The detail page renders one post.
That is the entire public surface.
The Controller as an Orchestrator
The controller’s job is not to contain logic — it is to coordinate.
1class PostController extends Controller 2{ 3 public function index() 4 { 5 $posts = Post::published() 6 ->latest('published_at') 7 ->paginate(10); 8 9 return view('posts.index', compact('posts')); 10 } 11 12 public function show(string $slug) 13 { 14 $post = Post::published() 15 ->where('slug', $slug) 16 ->firstOrFail(); 17 18 return view('posts.show', compact('post')); 19 } 20}
Notice what is absent:
- No publication rules.
- No filtering logic repeated.
- No data manipulation beyond retrieval.
The controller simply asks the model for published posts and passes them to the view.
Rendering Without Overengineering
Blade templates should prioritise readability — both for the developer and the reader.
A simple index view might look like this:
1@extends('layouts.app') 2 3@section('content') 4 <h1>Blog</h1> 5 6 @foreach ($posts as $post) 7 <article> 8 <h2> 9 <a href="{{ url('/posts/' . $post->slug) }}"> 10 {{ $post->title }} 11 </a> 12 </h2> 13 14 <p>{{ $post->excerpt }}</p> 15 </article> 16 @endforeach 17 18 {{ $posts->links() }} 19@endsection
And the single post view:
1@extends('layouts.app') 2 3@section('content') 4 <article> 5 <h1>{{ $post->title }}</h1> 6 7 <div> 8 {!! nl2br(e($post->content)) !!} 9 </div> 10 </article> 11@endsection
No reactive frameworks. No dynamic frontend layer. No unnecessary abstraction.
Just server-rendered HTML.
Publishing as a State Transition
With published_at, publishing becomes a state change:
1$post->update([ 2 'published_at' => now(), 3]);
Drafts have null.
Scheduled posts have a future timestamp.
Published posts satisfy the scope.
This is cleaner than toggling a boolean because it models time — and publishing is fundamentally temporal.
Why This Minimalism Matters
The blog described in the project context is intentionally restrained: no trackers, no cookies, no heavy client-side frameworks, and no hype-driven architecture .
That constraint shapes the implementation.
When you remove excess, you gain:
- Predictable queries
- Simple deployment
- Reduced attack surface
- Clear separation of responsibility
- Easier long-term maintenance
The system remains understandable even months later.
Final Reflection
A simple Laravel blog is not about features. It is about structure.
Define the model carefully. Localise logic in scopes. Keep controllers thin. Render clean HTML.
If you approach it with discipline, the result is small, reliable, and easy to reason about — which is precisely what a publishing system should be.