Skip to main content

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.