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

Slug history & redirects

Opt a definition into history (or set history.enabled globally), then publish and run the history migration:

SlugDefinition::for('slug')->from('name')->regenerateOnUpdate()->keepHistory()->routeKey();
php artisan vendor:publish --tag="sluggable-migrations"
php artisan migrate

How redirects work

  • Every persisted change retires the previous value into slug_history. A model taking back one of its own old slugs removes that entry.
  • A GET or HEAD request with a retired slug renders a 301 (history.redirect_status) to the same route with the current slug, keeping the query string. Other methods get a 404.
  • The target is resolved through the route’s own query — global scopes, the parent of a scoped binding, trashed handling — so drafts, other tenants’ rows and trashed rows are never revealed.
  • Only the matched route segment comes from input, so there is no open redirect. SlugMovedException implements ShouldntReport; it never reaches your error log.
  • redirectFromHistory(false) keeps the history without redirecting; avoidHistoricalSlugs() keeps other models from taking a retired slug.

Lookups

Slugs::model() looks a retired slug up through the model’s default query (global scopes applied) or the query you pass as within:

use RoundlyConsulting\Sluggable\Facades\Slugs;

Slugs::model(Product::class)->findInHistory('old-name', column: 'slug', locale: 'sk');   // ?Model

// Resolve only within a query you trust — a retired slug never reveals a row it would not return
Slugs::model(Product::class)->findInHistory('old-name', within: Product::query()->where('shop_id', $shop->id));

Cleanup and pruning

History rows are removed when the model is really deleted (no SoftDeletes, or a force delete); a soft delete keeps them. Mass deletes that fire no model events leave inert orphan rows — pruning ages them out. Set history.prune_after_days and schedule Laravel’s model:prune for the history model (or your configured history.model):

// routes/console.php, with sluggable.history.prune_after_days set
Schedule::command('model:prune', ['--model' => [\RoundlyConsulting\Sluggable\Models\SlugHistory::class]]);

Constraints

  • The history morph id column follows sluggable.key_type (bigint, uuid or ulid); a model whose key type does not fit is rejected at definition time (historyKeyTypeMismatch). One key type per host.
  • History stores slugs of up to 255 characters; a history-keeping definition with a larger maxLength is rejected at definition time.
  • The history model is swappable through history.model — your class must extend SlugHistory.
  • If the history table is missing, the local and testing environments throw historyTableMissing; elsewhere a warning is logged and recording is skipped — never a 500 after the row was saved.

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.