Search by

x-laravel / sluggable

X-Adam

Slugs for Eloquent models with Turkish-aware generation, race-safe uniqueness and a slug history that keeps old URLs working.

Package info

github.com/x-laravel/sluggable

pkg:composer/x-laravel/sluggable

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-14 19:30 UTC

This package is auto-updated.

Last update: 2026-10-14 19:49:01 UTC


README

Tests Latest Stable Version PHP Version Total Downloads License

Slugs for Eloquent models: generated from the model's own attributes with language-aware transliteration, kept unique across soft-deleted and scoped rows, retried when two requests race for the same slug, and remembered after they change so that old links keep finding the record.

Requirements

Package version PHP Laravel
1.x 8.3+ 12, 13

Installation

composer require x-laravel/sluggable
php artisan vendor:publish --tag=sluggable-migrations
php artisan migrate

Publish the config to change the defaults:

php artisan vendor:publish --tag=sluggable-config

Making a Model Sluggable

Implement the Sluggable interface, use the HasSlug trait and name the attribute the slug is built from:

use XLaravel\Sluggable\Concerns\HasSlug;
use XLaravel\Sluggable\Contracts\Sluggable;

class Event extends Model implements Sluggable
{
    use HasSlug;

    protected string|array $slugSource = 'title';
}

On every save the trait:

  • generates a slug from the source when the column is empty: Rock Konseri becomes rock-konseri;
  • numbers it when it is taken: rock-konseri-2, rock-konseri-3;
  • lowercases a slug that was set by hand.

A slug that is already set is not touched when the source changes, so shared links stay stable.

Every behaviour can be tuned per model with a property:

Property Default Meaning
$slugSource required Attribute, or list of attributes joined with a space, the slug is built from
$slugColumn 'slug' Column that holds the slug
$regenerateSlug regenerate config Build a new slug when the source changes
$slugHistory true Keep old slugs for this model
$slugReservedFor history.reserve_for config Days an old slug stays reserved; null forever, 0 not at all
$slugMaxLength max_length config Longest generated slug

Override slugSource() when the source has to be computed, and slugUniqueQuery() when uniqueness should be checked against a narrower query. By default it ignores every global scope, so soft-deleted rows and rows hidden by tenant or approval scopes still count — as they do for the database's unique index.

Configuration

Key Default Meaning
separator '-' Between words and before the number
language 'en' Transliteration language; use 'tr' for Turkish
max_length 255 Longest generated slug
first_suffix 2 Number given to the first duplicate
regenerate false Build a new slug when the source changes
retries 3 Attempts when a generated slug loses a race
history.enabled true Keep and resolve old slugs
history.connection null Connection of the history table
history.table 'slug_history' Name of the history table
history.reserve_for 90 Days an old slug stays reserved for its record
history.prune_after 365 Days after which history rows are pruned
history.prune_schedule 'daily' Frequency or cron expression of the pruning job; null to schedule it yourself

Put the history table on the same connection as your models, so a slug change and its history row are written in one transaction.

Slug History

When a slug changes, the old one is written to the history table. Looking a record up by slug checks both the current slug and the history:

$event = Event::findBySlug($slug);

$event = Event::query()->whereSlug($slug)->with('venue')->firstOrFail();

Compare the slug you were given with $event->slug to send the visitor to the current address.

Route model binding follows the history too when the route key is the slug column.

Reservation

An old slug stays reserved for its record for reserve_for days: no other record can take it, and the generator skips it. The record itself can always go back to an old slug. When the period ends, another record may take the slug; the old record's history row is then removed and the address belongs to the new record.

Pruning

Rows older than prune_after days are deleted by Laravel's model:prune command. The package schedules it for the history table itself, daily by default; set history.prune_schedule to another frequency such as 'hourly', to a cron expression, or to null to schedule it yourself. Nothing is scheduled when the history or pruning is turned off.

A pruned row no longer resolves. Keep prune_after longer than reserve_for; a row that is pruned earlier also ends its reservation.

Force-deleting a record removes its history. Soft-deleting keeps it.

Validating Slugs Set by Hand

Rules\Slug checks the format: lowercase letters, digits and the separator, never at the edges or twice in a row. Rules\UniqueSlug checks that no other record uses the slug or keeps it reserved:

use XLaravel\Sluggable\Rules\Slug;
use XLaravel\Sluggable\Rules\UniqueSlug;

$request->validate([
    'slug' => ['required', new Slug, new UniqueSlug($event)],
]);

Pass the model instance to ignore its own current and old slugs, or the class name for a new record. Laravel's unique rule cannot see the history table.

slug is also available as a string alias for the format check.

Reacting to a Changed Slug

The trait dispatches XLaravel\Sluggable\Events\SlugChanged with the model and the old and new slugs after the surrounding transaction commits. It also adds two model events for observers:

  • slugChanging runs before the row is written; return false to cancel the save.
  • slugChanged runs right after the row is written; getOriginal() still returns the old slug.
Event::slugChanged(fn (Event $event) => Cache::forget("event:{$event->getOriginal('slug')}"));

Giving a new model its first slug does not count as a change.

Concurrent Saves

Two requests can pick the same free slug at the same moment. When the trait generated the slug and the insert fails on the slug's unique index, it generates a new one and tries again. A slug that was set by hand is never retried. Inside an open transaction the attempt runs in a savepoint, so a failed attempt does not break the surrounding transaction.

The retry lives in the trait's save() method. If your model overrides save(), call parent::save() from it to keep the retry. A model cannot use this trait together with another trait that also overrides save().

Customising the Messages

The rules come with Turkish and English messages:

php artisan vendor:publish --tag=sluggable-translations

Testing

composer install
composer test
composer analyse

License

Open source software licensed under the MIT license.