php 135 lines · 4 tabs

Slugify Post Titles on Save With a Laravel Model Observer and Guaranteed Uniqueness

Shared by codesnips Sep 2026
4 tabs
<?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');
    }
};
4 files · php Explain with highlit

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

html
<!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

html html5 semantics
by Alex Chang 2 tabs
rust
use crossbeam::channel::unbounded;
use std::thread;

fn main() {
    let (tx, rx) = unbounded();

Crossbeam for advanced concurrent data structures

rust concurrency lock-free
by Marcus Chen 1 tab
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

Channels (mpsc) for message passing between threads

rust concurrency channels
by Marcus Chen 1 tab
ruby
# 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

sql-injection owasp database
by Kai Nakamura 3 tabs
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs

Share this code

Here's the card — post it anywhere.

Slugify Post Titles on Save With a Laravel Model Observer and Guaranteed Uniqueness — share card
Link copied