Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Polyslug For Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and scaffold (about 15 minutes to first working URL).

composer require pushery/polyslug-for-laravel
php artisan migrate   # polyslug_slugs migration auto-registers
php artisan make:polyslug Page   # namespace in name => subdirectory: make:polyslug Blog/Page

The generator is your first stop — it emits the model with the attribute, trait, and interface already wired, plus a route stub. Read the generated file before writing your own; the attribute is the whole API surface and the generator encodes the project's intended defaults.

Wire a model. Three pieces, no base-class inheritance:

use Polyslug\Attributes\Polyslug;
use Polyslug\Concerns\HasPolyslug;
use Polyslug\Contracts\Sluggable;

#[Polyslug(source: 'title')]
class Page extends Model implements Sluggable
{
    use HasPolyslug;
}

If the token is taken from a column other than the key — a public_id, an id from another system — name it now, before any URL is public:

#[Polyslug(source: 'title', identityOf: 'public_id', identitySecond: 'legacy_id')]

Wire a route. Route::polyslug() replaces Route::get(); it composes SubstituteBindings with the canonical middleware in the correct order, which is the entire reason not to hand-roll this.

Route::polyslug('/pages/{page}', PageController::class)->name('pages.show');

First use case. A CMS page with a title, a rename history, and a public URL. Create a Page, hit /pages/{slug}_{token} — the {token} half is what actually resolves. Change the title, hit the old URL: it still binds, polyslug.canonical 301s to the new slug. If you set identityOf, reissue the id in that column and hit the old URL too — it must still 301, not 404. That round trip is the acceptance test for your spike.

Where to look first, in order: the generated model, config/polyslug.php (it is wide — skim before adopting, don't discover it under load), then the canonical middleware and polyslugUrls(). Decide the encoder before anything is indexed publicly; see Gotchas.

Implementation Patterns

Route registration and scoping. The macro handles binding + canonical redirect; use it everywhere you'd use Route::get. For tenant/nested routes, scope bindings work but the parent needs ResolvesSluggableChildren — not the child:

Route::polyslug('/owners/{owner}/pages/{page}', PageController::class)
    ->scopeBindings();

Listing pages without N+1. This is the pattern you'll write most often. Every row's currentSlug() is a relation access unless you preload:

$pages = Page::with('slugs')->paginate();   // relation, per-model
// or bulk, when the interface is available:
$pages = Page::polyslugPreload()->paginate();

Then in Blade, resolve URLs through the model, never string-concatenate:

<a href="{{ $page->currentSlug()->url() }}">{{ $page->title }}</a>

Stable links when the identity column changes. With identityOf: 'public_id', a URL carrying an id that column no longer holds still resolves and the canonical middleware 301s to today's token. This is the import/migration story: adopt a model, keep the old system's identifier, and links survive the cutover. Add it to your contract tests — reissue the id on a record that has an indexed URL and assert 301-to-canonical, because a silent 404 here is exactly the failure mode that only shows up after launch.

Two id spaces on one record. identitySecond names a second id, and a URL writing that id after the whole id resolves to the record both name. This matters when you're merging records that collide on the primary identity (two imports, one per source system):

use Polyslug\Support\TailTokens;

TailTokens::apart($entries, length: 5, seconds: $externalIds); // {id}-{second id}, then -2, -3 for equal seconds
TailTokens::idsIn($token);        // read ids back out of a token (parsing, logging, redirect maps)
TailTokens::tokensFor($id);       // every token apart() can have produced for an id

Use idsIn()/tokensFor() when you need to reconstruct or migrate URL space outside the resolution path — don't hand-parse the - suffix.

Multilingual URLs and hreflang. One slug per locale, emitted in the layout head. Keep these calls behind your own presenter class so the package API has a single seam:

{!! $page->hreflangTags() !!}

For sitemaps, sitemapAlternateTags() emits the per-URL alternates; chunk by the platform limits (50k URLs / 50 MB per file) if you're generating yourself. Open Graph locale mapping ships with it — don't hand-write og:locale alongside it or you'll get two.

Locale-aware canonical redirects. Let polyslug.locale.source come from the route/session, not a static config. The canonical redirect is locale-sensitive; a stale URL in the wrong locale should redirect into the right locale's slug, not loop.

Multi-tenancy and draft isolation as a contract. Override polyslugResolveQuery() on every scoped model. Treat this as required, not hardening:

protected function polyslugResolveQuery(Builder $query): Builder
{
    return $query->where('tenant_id', tenant()->id)
                 ->where('published', true);
}

Out-of-scope rows must 404 identically to nonexistent ones — no existence oracle. This is also the mechanism for a preview/draft site off the same tables: same models, different resolve query. Note that identity-based resolution still goes through this query — an old id doesn't get to bypass tenant or publication scope.

Polymorphic routing across model types. Register several model types in the types registry and route them behind one URL contract (e.g. /@owner/{repo}-style prefixes) instead of duplicating controller logic per type. Adoption is per-model, so you can migrate surface by surface rather than in a flag day.

Short links. short_links.{scheme,length,alphabet} gives /go/{token} its own token space, separate from the identity encoder. Useful for share links and QR codes where the real URL is long and locale-dependent.

Per-model encoder options create a dedicated token space (encoderOptions: ['alphabet' => ..., 'min_length' => ...]). Reach for this when one model type needs short, human-shareable IDs (Sqids) while the rest stay leak-safe.

Backfill in production. polyslug:backfill --queue chunks the work; set retry depth from the environment rather than editing chunks later:

POLYSLUG_BACKFILL_TRIES=5

Dynamic config. For per-tenant or runtime behavior, implement ConfiguresPolyslug and return a PolyslugConfig from polyslug() — it overrides the attribute. Use sparingly; a PolyslugConfig built inside a request is a place bugs hide.

Testing. Write contract tests around four things and keep them: (1) stale slug → 301 → canonical, (2) out-of-scope record → 404 identical to missing, (3) encoder round-trip, (4) identity change → old id URL still 301s to today's token, including the two-id case. These are the tests that catch an encoder swap or an identity reissue before it ships.

Gotchas and Tips

Middleware order bites in a specific direction. polyslug.canonical runs the action before redirecting and then discards the response. Any side effect in a GET — view counters, impression logging, cache warming, third-party calls — fires on redirected requests too, and those requests pay double latency. This now includes identity-based redirects, which are common immediately after an import. Move counters to a separate endpoint or off GET.

can: does not sort before the canonical middleware. Laravel's middleware priority sort puts polyslug.canonical first, so authorization inside the route middleware runs after a redirect may have already happened. Do authorization inside the action (or a form request), not via route middleware, if you care about 403 vs. 301 ordering.

301s are cached forever by browsers and CDNs. A wrong polyslug.locale.source produces wrong-language canonical redirects that are painful to unwind once cached at the edge. The status code is configurable — use 302 during a locale rollout, then switch.

Encoder choice is a one-way-ish door. RandomTokenEncoder (the default) is unguessable and leak-free but costs a store round trip per key. Sqids is obfuscation only: it leaks PK, creation order, and growth rate. UlidEncoder leaks creation time. RawIdEncoder is internal-only. legacy_decoders buys a migration path, not a free one — decide before URLs are indexed.

identityOf only rescues ids written whole. The lookup is constrained to the identity column's index by trying every token TailTokens::apart() could have produced — so the old address resolves when it carries the id in full, or at the short end after the older of two ids. A hand-edited or truncated token is still a 404. Don't treat this as a general fuzzy resolver, and don't rename the identity column casually: the 301 chain depends on it existing where the attribute says it does.

identitySecond changes token shape, not just resolution. Entries that used to answer as {id}-2 now answer as {id}-{second id}. If you've persisted tokens, hard-coded -2 expectations in tests, or emitted tokensFor() output into a sitemap, refresh them together with the deploy.

Eager-load slugs on every list view. One query per row otherwise. ->with('slugs') or polyslugPreload(); there is no magic here and the N+1 is invisible until a page has 200 rows.

scopeBindings() needs ResolvesSluggableChildren on the parent. Miss it and nested routes 404 silently — no exception, no log. Only test coverage catches this.

polyslugResolveQuery() is not safe-by-default for multi-tenancy. Default resolution is unconstrained: any slug resolves to any row, and the canonical Location header carries that row's slug (usually derived from its title). Unpublished or foreign-tenant existence can leak if you don't override it. Make it a code-review checklist item — and remember the identity lookup path is only as constrained as this query.

reclaimActive leaves a record with no current slug. The displaced holder's row is retired until its own source syncs — a transient 404 window. Listen for SlugReclaimed and resync, or avoid reclaimActive unless the product genuinely needs name-stealing.

Reserved words are false positives under prefixes. On a model behind /@owner/repo, every route-derived reservation silently becomes api-2. Override polyslugReservedWords(array $inherited): array — returning [] opts out entirely.

maxLength trims the slug, not the token. The _{token} suffix comes on top, so the URL is longer than the slug. If you need shorter URLs, that's the encoder's length, not the attribute's.

idLess trades the rename-proof guarantee. In slug-only mode, resolution is by slug, with reclaim semantics to reason about. Marketing-friendly, but the "rename never breaks a link" property is gone — verify that trade before committing a public surface to it.

Hard constraints worth knowing up front: separator may be -, ., or ~ only; _ and / are refused. preserveCase is refused with unicode: 'native'. slugless and idLess together are refused; reclaim without idLess is refused. emptyFallback: 'id-only' keeps CJK/emoji-only titles from failing the save.

Soft-delete policy is a product call. onDelete: 'keep' reserves the slug after deletion (deleted-but-linked records keep resolving — required for CMS); 'release' frees it. Hard deletes always cascade slug rows regardless.

POLYSLUG_BACKFILL_TRIES fails soft. A non-numeric value or anything below 1 keeps null and silently falls back to the queue's own retry default — no exception, no warning. If you've set it to 3x or 0 and expected a boom, you'll only find out when a chunk dies mid-import. Read the resolved value back in a tinker session after setting it.

polyslug_slugs grows monotonically by design. Every historical slug is retained, on top of every id variant apart() can produce. Estimate rows as records × locales × rename events × id variants and decide on archival before it matters.

Maturity posture, concretely. Pin the version; wrap currentSlug(), polyslugUrls(), and the hreflang/sitemap emitters behind a presenter; keep contract tests around resolution and redirects, including the identity-change cases. That's a cheap insurance policy against a single-maintainer dependency with zero dependents, and it means a fork or replacement never touches product code. Run the two-messiest-model spike before committing the roadmap.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo