<?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->string('title');
$table->string('slug')->unique();
$table->longText('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = ['title', 'body', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function sluggable(): string
{
return $this->title;
}
}
<?php
namespace App\Observers;
use App\Models\Post;
use Illuminate\Support\Str;
class PostObserver
{
public function creating(Post $post): void
{
$post->slug = $this->uniqueSlug($post);
}
public function updating(Post $post): void
{
if ($post->isDirty('title')) {
$post->slug = $this->uniqueSlug($post);
}
}
protected function uniqueSlug(Post $post): string
{
$base = Str::slug($post->sluggable());
$slug = $base;
$suffix = 2;
while ($this->slugTaken($slug, $post->getKey())) {
$slug = "{$base}-{$suffix}";
$suffix++;
}
return $slug;
}
protected function slugTaken(string $slug, $ignoreId): bool
{
return Post::query()
->where('slug', $slug)
->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId))
->exists();
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
]);
$post = $this->persistWithRetry(new Post($data));
return redirect()->route('posts.show', $post);
}
protected function persistWithRetry(Post $post, int $attempts = 2): Post
{
for ($i = 1; $i <= $attempts; $i++) {
try {
$post->save();
return $post;
} catch (QueryException $e) {
// 23000 = integrity constraint violation (duplicate slug under a race).
if ($i === $attempts || $e->getCode() !== '23000') {
throw $e;
}
}
}
return $post;
}
}
This snippet shows the Laravel-idiomatic way to derive a URL-friendly slug from a post title and keep it unique across the table, without scattering slug logic through controllers. The whole feature is wired through a model observer so it runs consistently on every create and update, whether the save comes from a form, a seeder, or an API endpoint.
In create_posts_table migration, the schema does the heavy lifting for correctness: slug is declared unique(), so the database is the ultimate authority on uniqueness. Relying on a DB constraint rather than only on application checks matters because two concurrent requests can both pass an application-level where('slug', ...) check and then both insert — the unique index is what actually prevents duplicates under that race.
Post model keeps the model thin. It exposes getRouteKeyName() returning slug so route-model binding resolves /posts/{post} by slug instead of id, which is the point of having slugs at all. The sluggable() helper centralizes which attribute the slug is derived from, so the observer stays generic.
The real logic lives in PostObserver. On creating it always generates a slug; on updating it only regenerates when the title actually changed, checked via isDirty('title'), so editing a post's body never silently rewrites its URL. uniqueSlug() builds a base slug with Str::slug(), then appends an incrementing numeric suffix (-2, -3, ...) until no collision is found. The lookup excludes the current record with where('id', '!=', ...) so a post never conflicts with itself on update.
Because uniqueSlug() still has a check-then-insert window, store() in PostController wraps the save and catches QueryException. If the unique index rejects the insert, it retries once, letting the observer recompute a fresh suffix against the now-visible row. This belt-and-suspenders pattern — application-side suffixing for readable slugs, DB constraint plus retry for correctness — is the trade-off most production apps land on. The main pitfalls it addresses are concurrency, self-collision on update, and accidental URL churn; it deliberately keeps slugs immutable-ish by only recomputing when the title changes.
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
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
# 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
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
Share this code
Here's the card — post it anywhere.