Nested (hierarchical) slugs
Compose ancestor slugs into the URL path — /electronics/phones/iphone_TOKEN. Override
polyslugParent() to point at the parent, and scope uniqueness on the parent key so the
same segment can repeat under different parents:
#[Polyslug(source: 'name', scope: 'parent_id')]
class Category extends Model implements Sluggable
{
use HasPolyslug;
public function polyslugParent(): ?Sluggable
{
return $this->parent_id === null ? null : self::find($this->parent_id);
}
}
Route it with a catch-all segment:
Route::polyslug('/{category}', [CategoryController::class, 'show'])->where('category', '.*');
Composed on read, never stored
The path is computed from the ancestors' current slugs each time it is built. Renaming
or reparenting an ancestor therefore changes the URL immediately, and the canonical
middleware 301s the stale one — with no cascade to run and no stored path column to keep
in sync.
That is the whole point of the design: a stored materialized path is a second source of truth, and the moment a background job fails, it disagrees with the slugs it was derived from. Here there is nothing to disagree with.
Read the path directly when you need it:
$category->polyslugPath(); // "electronics/phones/iphone"
$category->polyslugPath('de'); // the German path
$category->polyslugPath('de', 10); // with a tighter recursion bound
Recursion is depth-bounded — 20 levels by default — so an accidental parent cycle stops rather than looping forever.
Scope on the parent key
Without scope: 'parent_id', phones under Electronics and phones under Accessories
would collide and the second would become phones-2. Scoping on the parent is what makes
each level's names independent. See Uniqueness and scope.
Combining with id-less URLs
Nested composes with idLess, which gives a fully readable path with
no token at all — the natural shape for a documentation tree or a category hierarchy whose
segments are already unique per parent.
The e-commerce and real-estate recipes both build on this.