Addresses for a read model
A read model is rebuilt whenever its source changes: a catalog projected from another system,
emptied and written again, its rows under new primary keys each time. Its pages still need
addresses that last. identity takes the token from a column of the record instead of the
encoded primary key, and stored: false computes the slug from the source on every call
instead of writing it:
use Polyslug\Attributes\Polyslug;
use Polyslug\Concerns\HasPolyslug;
use Polyslug\Contracts\Sluggable;
#[Polyslug(source: 'name', unique: false, stored: false, identity: 'address_token')]
final class CatalogProduct extends Model implements Sluggable
{
use HasPolyslug;
}
$product->getRouteKey(); // linen-shirt_01j9x7k2m4
Nothing of such a record is written to polyslug_slugs or polyslug_tokens, so the package's
tables never hold rows of a tenant whose own tables carry a tenant column, and a record written
again under a new key answers at the same address.
A short link is the one exception. shortLink() writes a row to
polyslug_short_links that names the record by its primary key, so that table does hold a row
of the tenant, and once the record is written again under a new key, the old link answers 404
and the record gets a new one. Leave short links out for such a model, or give it a primary key
that survives the rewrite.
The token from a column
identity names the column whose value follows the delimiter, and a URL resolves by comparing
its token with that column, through the resolution gate. The value takes
letters, digits and hyphens; a value with anything else throws InvalidArgumentException when
its URL is built. The column wants a unique index of its own.
A stable id from the other system makes a good token, and the end of it makes a short one: the
last ten characters of a ULID are random. Polyslug\Support\TailTokens takes them and settles
two ids that end alike, the same way whatever order they arrive in:
use Polyslug\Support\TailTokens;
TailTokens::of('01J9X7K2M4ABCDEFGHJK', 10); // abcdefghjk
TailTokens::apart(['a' => '01HZXYABCDEFGHJK0123', 'b' => '01J9X7K2M4ABCDEFGHJK0123'], 10);
// ['a' => 'efghjk0123', 'b' => '01j9x7k2m4abcdefghjk0123']
The id that sorts first, which for a ULID or a UUIDv7 is the older one, keeps the short token,
and every other one answers under its whole id. Store the result in the identity column when the
records are written, and hand apart() every id that can end like them, the stored ones
included.
The slug computed from the source
With stored: false the slug is built from the source each time it is asked for, the way a
stored one would be generated, and kept nowhere. Renaming the record changes its URL at once.
The old URL still resolves by its token, and the canonical middleware
answers it with a 301 to the current slug. There is no history to keep, so slugHistory() is
empty and retireSlug() retires nothing. Two records may compute the same slug, which is why
unique: false is required: the token after it tells them apart.
The source is read from the configured columns, the same text in every locale. A record that
holds its text per language overrides polyslugComputedSource():
public function polyslugComputedSource(string $locale): string
{
return $this->names[$locale] ?? $this->name;
}
A computed slug is announced in the default locale. A record served under more locales names
them through ProvidesAddressLocales, and
hreflangLinks(), polyslugUrls() and polyslug:sitemap then list each locale
under the slug of its own text.
A slugless model may set stored: false as well: its URL is the token alone, and it writes no
row at all.
Gone without a record
A read model drops a withdrawn record, so no route binding can find it to answer gone. Override
polyslugIsGoneAddress() to name the tokens it withdrew; when a token resolves to nothing and
the method says yes, the binding answers with polyslug.gone.status (410 by default) instead of
404:
public function polyslugIsGoneAddress(string $token): bool
{
return WithdrawnProduct::query()->where('token', $token)->exists();
}
The answer comes before the application's own, as a 404 does, so name only tokens whose
withdrawal anyone may learn. For a record that is still stored, polyslugIsGone() remains the
place to say it: see Gone and superseded.