pushery/polyslug-for-laravel
Polyslug for Laravel adds polymorphic slug support to Eloquent models, letting multiple models share a single slug table with automatic generation, uniqueness checks, and route binding. Keep SEO-friendly URLs without duplicating slug logic across models.
Feature: localized, SEO-safe public URLs. "Do we serve content per market?" reduces to "how do localized URLs work?" This package answers that directly: one slug per locale, hreflangLinks() / hreflangTags() / sitemapAlternateTags() for head tags and split sitemaps (respecting 50k URL / 50 MB per file limits), plus locale-aware canonical redirects that don't loop when the locale is derived from the route. That is a quarter of SEO plumbing we don't fund separately.
Policy: editors may rename published content. The route key is {slug}_{opaqueToken} and resolution happens on the token, not the slug. Renaming a live page can never 404: every slug ever issued stays in polyslug_slugs, and the polyslug.canonical middleware 301s stale URLs to the current form. This converts "can marketing retitle a live page?" from a risk conversation into a non-decision — and eliminates the redirect spreadsheet as an operational artifact.
Policy: the identity scheme is no longer a URL freeze (new in v0.24). #[Polyslug(identityOf: 'public_id')] decouples the token from the column it was minted from: a URL carrying a whole id still resolves after the identity column rotates to a new token, and the canonical middleware 301s forward. This turns two previously scary roadmap items — "reissue public ids," "adopt the upstream system's identifier" — from URL-breaking migrations into config changes. identitySecond extends that to two-id keys, so a record is addressable by the pairing rather than by one column we can never touch again.
Build vs. buy: buy, for any surface with public URLs. In-house means owning slug generation, transliteration, per-locale uniqueness with scoping, redirect history, encoder migration, identity rotation, hreflang emission, sitemap chunking, and short links. Months of build plus permanent maintenance. MIT removes lock-in as a counterargument in the buy/buy-not debate.
Roadmap gate: choose the encoder and the identity column before URLs go public. Encoder choice (RandomTokenEncoder default vs. Sqids vs. UUID) is a one-way-ish door — legacy_decoders buys a migration path, not a free one. Swapping encoders without it 404s every published URL at once. v0.24 partially softens the adjacent decision (identity column), but only for URLs written with the whole id — tail-token forms don't carry it. Keep this as a sequencing constraint on the roadmap ("encoder + identity column signed off before indexability"), not a sprint task.
Use cases that justify adoption: CMS/content pages, blog and docs with translated editions, product and listing catalogs, marketplace listings under prefixed routes (/owners/{owner}/pages/{page}), bulk imports where records carry an upstream system's own id, identity columns we expect to rotate (customer public_id, SKU reissue), and any app with soft deletes where deleted-but-linked records must keep resolving (onDelete: keep vs. release).
Multi-tenant and draft isolation as a product lever. polyslugResolveQuery() isn't only a security control; it's how we ship a preview/draft site and a public site off the same models. Out-of-scope rows 404 indistinguishably from nonexistent — no existence oracle. That's an enumeration-resistance decision made for us.
Scope discipline via posture switches. idLess (pretty URL, slug-resolved), slugless (opaque stable short ID, /lists/k3f9dlq7), and immutable (frozen slug) are product calls about brand, shareability, and permanence. Because they're declarative per model, they're cheap to revisit — which means we can ship conservatively and loosen later without re-architecting.
Short links and collision handling fall out for free. A dedicated /go token space with its own alphabet and length means campaign and share links don't require a second vendor or a bespoke table. v0.24's TailTokens::apart() third argument makes duplicate-id disambiguation deterministic: two entries sharing an id are separated as {id}-{second id} rather than by arrival order (-2, -3), so links survive re-imports that shuffle row order. idsIn() / tokensFor() make the token set inspectable, which is what we'd need to audit or redirect-map an import after the fact.
Scale/backfill is an ops decision, not a code change (new in v0.24). POLYSLUG_BACKFILL_TRIES sets retry attempts for polyslug:backfill --queue chunks. That's the knob that decides whether a large historical import survives a flaky queue overnight or fails a chunk for good — worth setting explicitly at launch rather than inheriting a queue default.
Adopt when most of these hold:
identityOf / identitySecond).Look elsewhere when any of these hold:
spatie/laravel-sluggable or a column plus Str::slug() is the honest answer; this is over-engineering./{slug}). idLess exists, but it trades away the rename-proof guarantee for slug-based resolution with reclaim semantics. Validate the trade before committing.slugless gives short IDs, but we'd be paying for machinery we don't use.Maturity caveat to record in the decision log: the documentation is unusually rigorous — it names its own failure modes (the canonical middleware runs the route action before redirecting, so view counters and impression logging fire on redirected requests; can: middleware sorts after polyslug.canonical because of Laravel's priority ordering; eager-loading slugs is required on list views to avoid N+1). v0.24 continues that pattern but is also the third consecutive minor on a fast-moving surface — identityOf is new enough that its interaction with existing slugless/immutable postures is unproven in our context. Treat it as a strong beta: gate rollout on a half-day spike against our two messiest models and a written encoder + identity decision.
For executives. Our URLs are a revenue asset. Today, renaming a page, launching a second language, or reissuing a customer/product identifier risks broken links, lost search ranking, and manual redirect maintenance. This package makes URLs permanent by construction: the readable slug can change freely while a stable opaque token resolves the record, stale URLs self-heal with a 301, and — as of v0.24 — a link keeps working even after the underlying identity column changes, which de-risks any replatform, CRM migration, or id reissue we have on the roadmap. It also ships the multilingual SEO plumbing — per-locale slugs, hreflang tags, and chunked sitemaps — that we would otherwise fund as a multi-month build. It's MIT-licensed, so the cost is integration time, not license or lock-in. The real exposure is maturity risk on a single-maintainer dependency, mitigated cheaply: pin the version, wrap the surface behind our own presenter, and keep the encoder migration path available.
For engineering — the shape of the work. Adoption per model is one attribute, one trait, one interface; one auto-registered migration; Route::polyslug(...) replaces Route::get(...) and wires binding plus the canonical middleware in the correct order. php artisan make:polyslug Page scaffolds it. v0.24 adds three things to the checklist: (1) if you set identityOf / identitySecond, treat the named columns as a contract — the rotation-resolution guarantee only holds for URLs that carry the id written whole, so audit any tail-token/short-link surfaces before relying on it; (2) when two records can share an id, pass the second id to TailTokens::apart() so disambiguation is {id}-{second id} and not order-dependent -2, and keep that second id genuinely unique/stable or you silently fall back to arrival-order suffixes; (3) set POLYSLUG_BACKFILL_TRIES explicitly for the launch backfill instead of inheriting the queue default, and decide in advance what a permanently failed chunk means for indexability. Budget for the pre-existing non-obvious costs: eager-load slugs (and polyslugPreload()) on every list view or pay a query per row; override polyslugResolveQuery() on anything tenant- or draft-scoped and treat it as a required contract, not optional hardening; scopeBindings() needs ResolvesSluggableChildren on the parent; authorization must be handled inside the action because the canonical redirect runs first; and pick the encoder deliberately — the default random-token encoder is unguessable and leak-free, while Sqids is obfuscation only and leaks primary key, creation order, and growth rate.
For the risk register. Single maintainer, zero known dependents, small star count, and a last-release date worth confirming as a real published tag with a cadence behind it — v0.24 shipping on the heels of prior minors is evidence of cadence but also of churn. New items from this release to size: the identity-column resolution path adds lookups "under every token TailTokens::apart() can have given it," so its cost scales with how many tokens a record has accumulated — worth measuring on a record with a long rename/import history before committing to identityOf on high-traffic models; and the {id}-{second id} scheme is only as stable as the second id, so a mutable or recycled second id reintroduces the ordering fragility it was meant to remove. Carried-over items: 301s are cached permanently by browsers and CDNs, so a misconfigured locale source produces wrong-language canonicals that are painful to unwind at the edge (the status code is configurable — that's the mitigation); reclaimActive can transiently leave a displaced record with no current slug unless we listen for SlugReclaimed; and an open default polyslugResolveQuery() can leak unpublished or foreign-tenant existence through the Location header. All mitigations are cheap and concrete — pin, test the resolution/redirect contract, wrap currentSlug() / polyslugUrls() / hreflangTags() behind our own interface, and document legacy_decoders as the encoder-swap escape hatch. MIT plus a narrow internal surface means we can fork or replace without touching product code.
Recommendation. Adopt for content and catalog surfaces where locales, renames, or identity churn are real; skip for single-locale CRUD. Gate the rollout on a two-model spike (one of them exercising identityOf), a written encoder + identity-column decision, an explicit backfill retry policy, and a confirmed Laravel/PHP support matrix with CI evidence.
How can I help you explore Laravel packages today?