Skip to main content

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.

A slug-only path is resolved by its last segment, and with scope: 'parent_id' two parents may each have a child of the same name. The rest of the path then decides which one is meant: /electronics/phones answers with the phones under Electronics and /accessories/phones with the ones under Accessories. An old path still finds its record after an ancestor is renamed or moved, because the ancestor part resolves through the parent's earlier slugs, and the canonical middleware redirects it to the new path.

The e-commerce and real-estate recipes both build on this.