Rich Links by asign
CommunityInternal links for the RichEditor: pick a record, store its ID instead of a URL, and resolve it to the current address in every language — slug changes and unpublished pages handled.
filament/
namespace. Review the source and install at your own risk. Found
malware or an unresolved security issue the author won't
address?
Report it
.
Author:
asign
Documentation
- Purchase
- Requirements
- Installation
- Quick start
- Table resolver
- Targets and search
- Extra link kinds
- Rendering
- Fallback route
- Multilingual sites
- Configuration
- Gotchas
- Translations
- AI agents
- Testing
- Screenshots
- License
Documentation only. Filament Rich Links is a commercial plugin: this public repository holds its documentation, changelog and licence terms. The package itself is installed from the private Composer repository you get with a licence — buy one on Anystack. Questions: support@asign.in.ua.
Internal links for the Filament 5 RichEditor: the editor picks a record, the content stores
the record, not a URL, and the address is filled in when the page is rendered.
Filament's rich editor links to URLs only. An editor who wants to link to another page of the site has to paste an address, and that address silently rots: someone renames the slug, unpublishes the page, or the site gets a second language, and the link is dead. This plugin fixes the cause instead of the symptom:
-
IDs, not URLs. The saved
hrefis a marker (/rich-link/post/42with a morph map,/rich-link/App%5CModels%5CPost/42without one). A changed slug never breaks a link; with an enforced morph map a renamed or moved model class does not either. -
Unpublished targets become text. A deleted, hidden or not-yet-translated target is replaced by its link text at render time, so the site never serves a link to a 404.
-
Per language. The marker is resolved for the language of the page being rendered; a target that has no address in that language disappears from that language's page only.
-
Cheap. One DOM pass for all markers, one query per target type, an early exit when the HTML has none.
-
Safe. Absolute and protocol-relative URLs are foreign links and are never touched, even when their path looks like a marker. A resolved address is written only when it is a root-relative path or an
http(s)URL — ajavascript:or//other-hostvalue from a resolver loses the link instead. A marker naming a class that is not an instantiable Eloquent model is never loaded; with the default resolver the class must also implementHasRichLink, with the table resolver it must have rows in the slug table (which only your code writes).
#Purchase
Filament Rich Links is a commercial plugin. Both tiers include one year of updates; after that the plugin keeps working on the last release you received, and you can renew for further updates.
| Tier | Projects | Activations | Price | Renewal |
|---|---|---|---|---|
| Single Project | 1 | up to 3 (production, staging, local) | €49 | €25 / year |
| Unlimited | any number, SaaS included | unlimited | €119 | €59 / year |
Refunds are available within 14 days of purchase.
Buy a licence on Anystack — after the purchase you receive a licence key and access to the private Composer repository. See LICENSE.md for the licence terms.
#Requirements
- PHP 8.3+ with the
domandlibxmlextensions - Laravel 12 or 13
- Filament 5
- Optional:
spatie/laravel-translatable, so the picker can search translatable titles in every language
#Installation
-
Add the private repository (the same for every customer):
composer config repositories.filament-rich-links composer https://filament-rich-links.composer.sh -
Add your credentials. The username is the e-mail address you bought the licence with; the password is your licence key. A Single Project licence is bound to a fingerprint, so the password is the key followed by a colon and the fingerprint you activated — usually the production domain:
# Single Project composer config --auth http-basic.filament-rich-links.composer.sh you@example.com "LICENCE-KEY:shop.example.com" # Unlimited composer config --auth http-basic.filament-rich-links.composer.sh you@example.com "LICENCE-KEY"This writes
auth.jsonnext to yourcomposer.json— keep it out of git (addauth.jsonto.gitignore). On CI and servers pass the same JSON through theCOMPOSER_AUTHenvironment variable instead. A Single Project licence allows three activations (for example production, staging and local); manage them in your Anystack account. -
Require the package:
composer require asignua/filament-rich-links
Publish the config file if you want to change a default (everything in it can also be set in code):
php artisan vendor:publish --tag=filament-rich-links-config
#Quick start
1. Tell the plugin which records can be linked to. Put it in AppServiceProvider::boot():
use Asignua\FilamentRichLinks\RichLinks;
use Asignua\FilamentRichLinks\Support\LinkTarget;
RichLinks::targets([
LinkTarget::model(Post::class)->label('Posts')->titleColumn('title'),
]);
2. Make the model say where it lives. The default resolver asks the model:
use Asignua\FilamentRichLinks\Contracts\HasRichLink;
class Post extends Model implements HasRichLink
{
public function richLinkUrl(string $locale): ?string
{
return route('posts.show', $this); // or null: "no address in this language"
}
public function richLinkVisible(string $locale): bool
{
return $this->published_at?->isPast() ?? false;
}
}
3. Put the button in the editor.
use Asignua\FilamentRichLinks\RichLinks;
use Asignua\FilamentRichLinks\RichLinksPlugin;
RichEditor::make('body')
->plugins([RichLinksPlugin::make()])
->toolbarButtons([['bold', 'italic', 'link', RichLinks::getToolName()]]);
To give every editor in the application the plugin, use RichEditor::configureUsing(fn (RichEditor $editor) => $editor->plugins([RichLinksPlugin::make()]))
and add the button name to the toolbars you want it in.
4. Sanitize, then resolve the markers when you render — see Rendering:
{!! RichLinks::resolve(RichContentRenderer::make($post->body)->toHtml()) !!}
toHtml() runs Filament's HTML sanitizer; never pass raw editor content to {!! !!}. RichLinks::resolve() does not sanitize.
The stock "Link" button stays; use it for external URLs.
#Table resolver
When the address is not something the model can compute — you already keep a table of slugs, one row per record and language — resolve through the table instead:
use Asignua\FilamentRichLinks\Resolvers\TableLinkResolver;
RichLinks::resolveUsing(
TableLinkResolver::make()
->table('links')
->columns(type: 'entity_type', id: 'entity_id', locale: 'language', slug: 'slug')
->href(fn (string $locale, string $slug): string => localized_url($locale, $slug))
->visible(fn (Model $model, string $locale): bool => $model->isPublished($locale)),
);
href()builds the URL from the language and the stored slug. Default:/{slug}, and/{locale}/{slug}for every language except the unprefixed one (see Multilingual sites).visible()is the visibility rule. Default: the model's ownisVisible($locale)when it has one, otherwise visible.- The type column may hold a class name or a morph alias; both are understood.
The plugin runs one query when visibility does not matter (menus, banners) and two queries per target type (the table and the models) when it does (rendered content), however many links the page holds.
No such table? Publish the optional migration and keep it filled:
php artisan vendor:publish --tag=filament-rich-links-migrations
php artisan migrate
use Asignua\FilamentRichLinks\Support\RichLinkSlugs;
RichLinks::resolveUsing(TableLinkResolver::make()); // the defaults match the rich_links table
// whenever a model is saved / deleted:
RichLinkSlugs::sync($page, ['en' => 'about', 'uk' => 'pro-nas']); // a language that is missing or empty is removed
RichLinkSlugs::forget($page);
#Targets and search
Every picker entry is a LinkTarget:
LinkTarget::model(Page::class)
->label('Pages') // the entry in the type select
->titleColumn('title') // default: title
->query(fn (Builder $query) => $query->where('team_id', auth()->user()->team_id))
->search(fn (Builder $query, string $term) => $query->where('title', 'like', "%{$term}%")->orWhere('slug', 'like', "%{$term}%"))
->optionLabel(fn (Model $page, string $term): string => "{$page->title} ({$page->slug})");
By default the picker searches the title column with LIKE (the term is matched literally: % and _ are not wildcards), newest first, 50 at a time (search_limit in the config). When the
column is declared translatable by spatie/laravel-translatable it is searched in every language of
RichLinks::locales(), so a query in English finds a record whose current-language title is Ukrainian; the option then says
which language matched ([uk] Онлайн-курси · [en] Online courses).
#Tenancy and access
The picker lists the titles of every record its query returns, drafts included, and the record field rejects a submitted id that is outside that query. In a panel with tenancy the query is scoped to the current tenant by default, through the panel's ownership relationship — the same rule Filament applies to resources:
LinkTarget::model(Page::class)->tenantOwnershipRelationship('team'); // when the relationship has another name
LinkTarget::model(Country::class)->scopeToTenant(false); // a model shared by every tenant
A target with no relationship to the tenant throws a LogicException rather than leaking another tenant's titles. Anything
finer — drafts, a policy, a role — goes into ->query(): every lookup of the dialog (search, the label of the chosen record,
the check of a submitted id) goes through it.
The tenant scope applies to the picker only. Rendering and the fallback route resolve whatever marker the HTML holds, so a
marker for another tenant's record (content copied between tenants, hand-edited HTML) still becomes a link whenever that record
is visible. If a site needs tenant isolation at render time, put the rule into visible() or the model's richLinkVisible().
When the editor leaves the link text empty, the title of the record is inserted — without a language marker, so a [en]
label never ends up in published content.
#Extra link kinds
A link target that is not a record (the value of a setting, a phone number from the site config…) is an extra kind. It
appears in the same type select, brings its own fields, and writes its own href:
use Asignua\FilamentRichLinks\Support\LinkKind;
RichLinks::kind(
LinkKind::make('setting')
->label('Setting')
->fields(fn (): array => [Select::make('setting_key')->options(fn () => Setting::linkable()->pluck('key', 'key'))->searchable()])
->encode(fn (array $data): ?string => filled($data['setting_key'] ?? null) ? '/setting/'.rawurlencode($data['setting_key']) : null)
->decode(fn (string $href): ?array => str_starts_with($href, '/setting/') ? ['setting_key' => rawurldecode(substr($href, 9))] : null)
->defaultText(fn (array $data, string $href): ?string => Setting::valueOf($data['setting_key']))
->handler(new SettingLinkHandler),
);
- the fields are shown only while the kind is chosen, and the record picker is hidden then;
encode()returningnullmeans "nothing chosen" and removes the link;decode()pre-fills the dialog when an existing link of this kind is edited;handler()takes part in the render pass — see below.
A marker type that needs no entry in the dialog (a link to a file from your own file picker, say) is just a handler:
use Asignua\FilamentRichLinks\Contracts\SentinelHandler;
final class SettingLinkHandler implements SentinelHandler
{
public function prefix(): string { return '/setting/'; }
public function collect(string $path): bool { /* remember the key; false = broken marker */ }
public function resolveBatch(string $locale): void { /* one lookup for everything collected */ }
public function replace(DOMElement $anchor, string $path): void { /* setAttribute('href', …) or HtmlLinkRewriter::unwrap($anchor) */ }
public function reset(): void { /* forget the state of the render */ }
}
RichLinks::handler(new SettingLinkHandler);
All handlers share one DOM pass with the plugin's own: collect() is called per anchor under your prefix (only relative
paths ever reach it), resolveBatch() once if anything valid was collected, then replace() per anchor. reset() runs before
and after each render. HtmlLinkRewriter::unwrap() removes the link and keeps its text.
#Rendering
use Filament\Forms\Components\RichEditor\RichContentRenderer;
{!! RichLinks::resolve(RichContentRenderer::make($post->body)->toHtml()) !!} // the application locale
{!! RichLinks::resolve(str($post->body)->sanitizeHtml(), 'uk') !!} // an explicit language
resolve()does not sanitize. It only rewrites the markers; the HTML around them goes out as it came in. Always feed it sanitized HTML —RichContentRenderer::toHtml()orstr()->sanitizeHtml()— and never the raw column.
Call it after the HTML sanitizer. A marker is a relative href, which any sanitizer keeps; the resolved address is not
necessarily one the sanitizer would let through. (If you resolve first and sanitize second, the sanitizer sees finished URLs,
which is a different — and for some values a stricter — question.) The same goes for anything that turns saved HTML into
something else, such as a Markdown export for llms.txt: resolve first, convert second.
A link whose target cannot be resolved is unwrapped: the <a> disappears and its text stays. Query strings and fragments
on a marker are dropped. Because the resolved address never meets the sanitizer, the plugin checks it itself: only a path
starting with a single / (not // or /\) or an absolute http/https URL is written; anything else unwraps the link, and
the fallback route answers 404 instead of redirecting.
HTML without a marker is returned byte for byte. HTML with one goes through a DOM round trip; foreign href/src values and
in text are written back verbatim, and content after a stray closing tag (</div>) is kept. Well-formed (sanitized)
HTML comes back unchanged apart from the rewritten links; malformed nesting is repaired the way a browser's parser would repair
it (an element closed out of order can move text out of its list or paragraph), and inside an attribute comes back as a
raw U+00A0.
#Fallback route
HTML occasionally leaks past the renderer — an e-mail template, a stale cache, a manual export — and a visitor follows a raw marker. The plugin registers a redirect so that still works:
GET /rich-link/{type}/{id}— the unprefixed language;GET /{locale}/rich-link/{type}/{id}— a prefixed language.
The language comes strictly from the URL, with no fallback to another one, and an unpublished target is a 404 for
everybody, including logged-in panel users. Absolute markers on your own host (https://site.test/rich-link/…) end up on the
same route.
A site with a catch-all route must register this one before it: set routes.register to false in the config and call
RichLinks::routes() yourself, before the catch-all. The route names (rich-links.redirect, rich-links.redirect.locale) are
configurable with RichLinks::routeNames(plain:, locale:).
#Multilingual sites
RichLinks::locales(default: 'uk', all: ['uk', 'en'], unprefixed: 'uk');
allis the list the picker searches in and the redirect route knows about;unprefixedis the language whose URLs have no/{locale}prefix.RichLinks::localizedUrl($locale, $path)applies the rule and is the default URL builder of the table resolver;- without this call the application locale is the only language.
Call it in AppServiceProvider::boot(), before the routes are registered (they are registered once the application has booted).
#Configuration
Each value can be set in config/filament-rich-links.php or in code; the code wins.
| Config key | Code | Default |
|---|---|---|
prefix |
RichLinks::prefix('/rich-link/') |
/rich-link/ |
tool |
RichLinks::tool('richLink') |
richLink |
form_keys.type / .id |
RichLinks::formKeys(type: 'entity_type', id: 'entity_id') |
entity_type / entity_id |
table |
RichLinks::table('rich_links') |
rich_links |
search_limit |
50 |
|
routes.register / .middleware |
true / ['web'] |
|
routes.names.plain / .locale |
RichLinks::routeNames(plain:, locale:) |
rich-links.redirect(.locale) |
The prefix must start and end with a slash. Changing it later orphans every link already saved, so pick it once. The
tool name is both the toolbar button and the modal action behind it; your toolbarButtons([...]) refers to it. The
form keys are the keys of the dialog state — change them only to stay compatible with code that already refers to them.
#Toolbar icon
The button uses its own icon (a page with a link), not the stock chain: with the cursor inside one
of these links the stock Link button lights up too, and two identical chains side by side cannot
be told apart. Use your own with RichLinks::toolIcon('heroicon-o-document-text') or an SVG string.
#Gotchas
These cost real debugging time while building the plugin; they are all handled for you, and each is pinned by a test. They matter if you copy the tool or write your own.
->action()needsarguments:.RichEditorTool::action()without it gives the dialog an empty$arguments: the editing of an existing link silently never pre-fills, while a unit test that calls the PHP method directly stays green. The stocklinkbutton passesarguments:for the same reason.$getEditor()?., always with?.. On the first render the TipTap editor is stillundefined(it is created after anawaitininit()), so a bare$getEditor().isActive(...)inactiveJsExpressionthrows aTypeErrorin Alpine.- The
linkmark dropsdata-*attributes. Only attributes declared by its JS extension survive a re-render of the editor, so the marker lives inhref, which is stored verbatim. - The link text has no language marker. The picker labels translatable records with the language they were read in
(
[en] Title) because the editor must know what they are looking at; that label must never be inserted into content. The default link text is the clean title. - Custom toolbar buttons only come from a
RichContentPlugin.RichEditor::getDefaultActions()is a hard-coded list; there is no publicextraActions(). That is why the button is a plugin. - Resolve after sanitizing, not before — see Rendering.
- Markers carry the morph class. With a morph map the marker holds the alias, otherwise the class name. Markers written before a morph map was added keep working; both spellings resolve.
- IDs are numeric. A marker is
{prefix}{type}/{digits}; models with UUID/ULID keys need a numeric key to be linkable.
#Translations
The interface ships in English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and Turkish
under the filament-rich-links::rich-links namespace. A test keeps every language in step with the English keys. Override a
string by publishing the translations (--tag=filament-rich-links-translations) and editing the copy in
lang/vendor/filament-rich-links.
#AI agents
The package ships Laravel Boost guidelines (resources/boost/guidelines/core.blade.php) that
describe the registry, the resolvers, the render call and the traps above, so a coding agent wires it up correctly.
#Testing
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/pint --test
The suite runs on Orchestra Testbench with a workbench/ panel, a plain Post model and a
translatable Article. The registry is static: call RichLinks::flush() in your own tests' setUp() and tearDown() if you
configure it there.
#Screenshots
The internal-link dialog: pick a type, search a record.

The same dialog in dark mode.

The toolbar button lights up while the cursor is inside an internal link.

What is stored versus what the page renders: the content keeps a marker, the site shows the current address; an unpublished target becomes plain text.

#License
Commercial: Single Project or Unlimited, each with one year of updates. See LICENSE.md.
The author
asign is a small web-dev company from Lviv, Ukraine. We build business applications on Laravel and Filament — CRMs, automation systems for standard and non-standard business processes, booking and content management systems, including our own Filament-based CMS. We open-source the parts that prove useful beyond a single project
From the same author
Relation Manager Tabs
Render relation managers as ordinary tabs of the record form, so an edit or view page has exactly one row of tabs.
Author:
asign
SEO Files
Generate and edit sitemap.xml with hreflang and a sitemap index for large sites, robots.txt and llms.txt / llms-full.txt from your Filament panel
Author:
asign
CSP Nonce
Per-request CSP nonce, a Content-Security-Policy header and violation reports for Filament panels.
Author:
asign
Activity Log Plus
An add-on for spatie/laravel-activitylog that records per-language diffs, skips phantom changes, groups each action into one entry and adds a History button to every record.
Author:
asign
Featured Plugins
A selection of plugins curated by the Filament team
Noir Theme
A theme that gives panels a focused, refined look with near-black surfaces, crisp actions, and restrained color.
Filament
Advanced Tables (formerly Filter Sets)
Supercharge your tables with powerful features like user-customizable views, quick filters, multi-column sorting, advanced table searching, convenient view management, and more. Compatible with Resource Panel Tables, Relation Managers, Table Widgets, and Table Builder!
Kenneth Sese
Compact Theme
A theme that makes data-heavy panels easier to scan by fitting more useful information on each screen.
Filament