<?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('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->longText('body')->nullable();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('articles');
}
};
<?php
namespace App\Models;
use App\Observers\ArticleObserver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Article extends Model
{
use SoftDeletes;
protected $fillable = ['title', 'slug', 'body', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
];
protected static function boot()
{
parent::boot();
static::observe(ArticleObserver::class);
}
public function getRouteKeyName()
{
return 'slug';
}
}
<?php
namespace App\Observers;
use App\Models\Article;
use Illuminate\Support\Str;
class ArticleObserver
{
public function creating(Article $article): void
{
if (empty($article->slug)) {
$article->slug = $this->uniqueSlug($article->title);
}
}
protected function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$slug = $base;
$suffix = 2;
while ($this->slugExists($slug)) {
$slug = "{$base}-{$suffix}";
$suffix++;
}
return $slug;
}
protected function slugExists(string $slug): bool
{
return Article::withTrashed()
->where('slug', $slug)
->exists();
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ArticleController extends Controller
{
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['nullable', 'string'],
]);
// No slug passed in; the observer derives it on creating.
$article = Article::create($data);
return redirect()->route('articles.show', $article);
}
public function show(Article $article): View
{
return view('articles.show', compact('article'));
}
}
This snippet shows how a Laravel Article model gets a URL-friendly slug generated automatically the moment a record is created, without cluttering the controller or the model itself. The work lives in an observer, which is Laravel's idiomatic hook for reacting to Eloquent lifecycle events like creating, updating, and deleting.
The create_articles_table migration defines the storage contract. The slug column is declared unique(), which is the real backstop for correctness: even if two requests race to create articles with the same title, the database constraint guarantees no duplicate slug survives. The application-level uniqueness check is an optimization that keeps slugs readable, but the constraint is what makes it safe.
The Article model keeps the boot method to register the observer via observe, and exposes getRouteKeyName returning 'slug'. That single method changes route-model binding so URLs like /articles/my-post-title resolve directly by slug instead of numeric id, which is the whole point of generating one.
The ArticleObserver carries the logic. Its creating method runs before the INSERT, so mutating $article->slug in place is enough — no extra save is needed. It only generates a slug when one was not supplied, letting callers override manually. Str::slug handles transliteration and lowercasing, and uniqueSlug appends an incrementing suffix (-2, -3, ...) when a collision exists. The lookup uses withTrashed so a soft-deleted article does not silently free up a slug that still occupies the unique index.
A subtle detail is why creating is chosen over saving: saving also fires on updates, which would risk rewriting a published slug and breaking existing inbound links. Generating only on creating freezes the slug at birth, preserving link stability — a common SEO requirement. The trade-off is that a later title edit will not refresh the slug automatically; that is usually the desired behavior, but a separate opt-in path would be needed if renaming should propagate. The pattern generalizes to any derived, write-once field such as reference numbers or public tokens.
Related snips
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
# Vulnerable: user input is concatenated directly into SQL.
email = params[:email]
password = params[:password]
sql = "SELECT * FROM users WHERE email = '#{email}' AND password_hash = '#{password}'"
user = ActiveRecord::Base.connection.execute(sql).first
SQL injection prevention with unsafe and safe query patterns
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
-- EXPLAIN ANALYZE (actual execution statistics)
EXPLAIN ANALYZE
SELECT u.username, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2024-01-01'
Advanced query optimization techniques
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.