NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Posts for Laravel

Routing & URL safety

With posts.slugs.route-binding on (the default), the slug is the post’s route key. {post} and {post:slug} bind by the current locale’s slug, then the fallback locale’s, then any locale’s, and route() generates the current-locale slug — so URLs and binding always agree. A numeric slug is never shadowed by a post id:

use RoundlyConsulting\Posts\Models\Post;

Route::get('/posts/{post}', fn (Post $post) => view('posts.show', compact('post')))->name('posts.show');

route('posts.show', $post);   // /posts/hello-world — the current locale's slug

Turn route-binding off to keep Laravel’s default key binding.

Slug history and 301 redirects

Turn on posts.slugs.history to remember retired post slugs: a request for an old slug answers 301 to the current URL, query string kept. It needs sluggable’s history table:

# .env
POSTS_SLUG_HISTORY=true

php artisan vendor:publish --tag="sluggable-migrations"
php artisan migrate

On uuid or ulid posts, set sluggable.key_type to the same value as posts.primary_key_type before migrating the history table.

Locking published URLs

Turn on posts.slugs.lock-when-published to freeze a post’s slugs once it is published: nothing regenerates them, and a manual change throws SlugLockedException. Wrap a deliberate change in Slugs::unlocked():

// config/posts.php
'slugs' => [
    // …
    'lock-when-published' => true,
],

Posts::publish($post);
$post->setTranslation('slug', 'en', 'new-url')->save();   // throws SlugLockedException

use RoundlyConsulting\Sluggable\Facades\Slugs;

Slugs::unlocked(fn () => $post->setTranslation('slug', 'en', 'new-url')->save());

Validating slug input

Post::slugRules() returns rules for a host form that accepts a slug map (slug.en, slug.sk, …), using the same uniqueness check as generation. Pass the post being edited to ignore it:

$request->validate([...Post::slugRules($post), 'title' => ['required', 'array']]);

// Post::slugRules() returns a 'slug' rule set: nullable, array, and sluggable's
// UniqueSlug on the configured posts.model, ignoring $post

Admins who should see a validation error instead of a silent -2 can combine these rules with sluggable’s Strict manual policy in their own model subclass. Strict keeps a manual slug exactly as typed and refuses a taken one with SlugAlreadyTakenException, so add sluggable’s ValidSlug rule to reject input that is not already a slug:

use RoundlyConsulting\Posts\Models\Post;
use RoundlyConsulting\Sluggable\Definitions\SlugOptions;
use RoundlyConsulting\Sluggable\Enums\ManualSlugPolicy;
use RoundlyConsulting\Sluggable\Rules\ValidSlug;

// Configure it as posts.model
class Article extends Post
{
    public function slugOptions(): SlugOptions
    {
        $options = parent::slugOptions();
        $options->get('slug')->manual(ManualSlugPolicy::Strict);

        return $options;
    }
}

// Strict stores a manual slug as typed, so check its format in the form too
$rules = Article::slugRules($article);
$rules['slug'][] = ValidSlug::for(Article::class);

$request->validate([...$rules, 'title' => ['required', 'array']]);
// slug.en = 'taken'       → "The slug.en (en) has already been taken."
// slug.en = 'Custom Slug' → "The slug must be a valid slug (…)."

// A save that skips the form throws instead of saving taken-2:
$article->setTranslation('slug', 'en', 'taken')->save();   // SlugAlreadyTakenException

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.