php 122 lines · 4 tabs

Generating URL Slugs from Titles with a Laravel Model Observer

Shared by codesnips Jul 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('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');
    }
};
4 files · php Explain with highlit

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

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
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
php
<?php

namespace App\Providers;

use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;

Laravel service container and dependency injection

laravel dependency-injection service-container
by Carlos Mendez 2 tabs
ruby
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

rails postgres jsonb
by codesnips 3 tabs
sql
-- 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

database optimization query-performance
by Maria Garcia 2 tabs
json
{
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },

Laravel mix/Vite for asset compilation

laravel vite assets
by Carlos Mendez 4 tabs

Share this code

Here's the card — post it anywhere.

Generating URL Slugs from Titles with a Laravel Model Observer — share card
Link copied