#Blog Internal Linking — Documentation
#Table of Contents
- Installation
- Configuration
- Creating Internal Links
- Applying the Modifier
- Multilingual Sites
- How Matching Works
- Blueprint Reference
- Troubleshooting
#Installation
#Requirements
- PHP 8.2+
- Laravel 11+
- Statamic 6+
#Via Composer (recommended)
composer require 5k18a/blog-internal-linksphp artisan internal-links:install
The install command:
- Publishes
config/internal-links.php - Creates
content/collections/internal_links.yaml - Publishes the blueprint to
resources/blueprints/collections/internal_links/ - Auto-detects your blog collection by scanning collection handles for keywords like
blog,post,articles,news - On a multisite install, asks which site you use to manage content in the CP
- Writes the detected values into
config/internal-links.php - Runs
php artisan statamic:stache:refresh
#Manual install
If you prefer to control each step:
composer require 5k18a/blog-internal-linksphp artisan vendor:publish --tag=internal-links-configphp artisan vendor:publish --tag=internal-links-collectionphp artisan vendor:publish --tag=internal-links-blueprintsphp artisan statamic:stache:refresh
#Configuration
#Collection site
By default the addon creates the internal_links collection scoped to a single site (pl). If your admin language is different, edit content/collections/internal_links.yaml after install:
title: 'Blog Internal Linking'revisions: falsesites: - en # change to your admin site handlepropagate: false
The collection should always be scoped to one site — your admin/content language. Keywords for other languages are managed within each entry using the locale field.
#Blog collection
The blog collection is configured once in config/internal-links.php (created automatically by the install command):
return [ 'blog_collection' => 'blog', // handle of your blog collection 'admin_site' => 'en', // site used to manage internal_links in CP];
The modifier only processes content on entries that belong to blog_collection — it silently skips all other pages. If blog_collection is empty or null, the guard is disabled and the modifier runs everywhere it is placed.
#Collections for target entries
The blueprint's target_entry picker defaults to pages, services, and projects. To add more collections, publish the blueprint and edit resources/blueprints/collections/internal_links/internal_link.yaml:
handle: target_entryfield: type: entries collections: - pages - services - projects - blog # add any collection here
#Creating Internal Links
Go to CP → Collections → Blog Internal Linking and create a new entry.
#Fields
| Field | Description |
|---|---|
| Name | Admin label only — not displayed on the site. E.g. Coral Reef → Decorative Aquariums page |
| Target page | The entry to link to. URL resolves to the correct language automatically. |
| Keywords | One or more keyword rows (see below). |
| Priority (weight) | Higher number = processed first. Useful when two entries share similar keywords. Default: 0. |
| nofollow | Adds rel="nofollow" to generated links. |
| Open in new tab | Adds target="_blank" rel="noopener". |
| Active | Disable to temporarily stop processing this entry without deleting it. |
#Adding keywords
Each row in the Keywords replicator has:
- Word / phrase — the exact text to match, e.g.
coral reef,artificial rocks. Case-insensitive. - Language — optional. Leave empty to match on all language versions of your site. Set to a specific locale (e.g.
en) to match only on English pages.
#Applying the Modifier
The modifier apply_internal_links is applied in your Antlers templates wherever you render content that should contain auto-links.
#Bard field (blog posts)
Inside a Bard loop, apply it to the text variable in the else branch (plain paragraph/text blocks):
{{ content }} {{ if type == "quote_section" }} {{-- your quote markup --}} {{ elseif type == "image_section" }} {{-- your image markup --}} {{ else }} {{ text | apply_internal_links }} {{ /if }}{{ /content }}
#Free text / WYSIWYG blocks
{{ free_text_content | apply_internal_links }}{{ wysiwyg_html | apply_internal_links }}
#Any HTML field
The modifier accepts any string or HTML value:
{{ description | apply_internal_links }}
#Multilingual Sites
All keyword → page mappings live in a single collection (your admin language). You do not need separate entries per language.
For each keyword row, the Language field controls which site locale it applies to:
| Keyword | Language | Applied on |
|---|---|---|
coral reef |
en |
English pages only |
rafa koralowa |
pl |
Polish pages only |
Korallenriff |
de |
German pages only |
reef |
(empty) | All languages |
The Target page field is set once. The modifier calls Entry::in($site) internally to resolve the URL to the correct language version of that page — no extra configuration needed.
#How Matching Works
- Case-insensitive —
Coral Reef,coral reef, andCORAL REEFall match. - Unicode word boundaries — partial matches inside words are avoided.
reefwill not match insidereefing. - Protected zones — the following are never modified:
- Headings (
<h1>–<h6>) - Existing links (
<a>) - Images (
<img>) - Figures (
<figure>) - Iframes (
<iframe>) - WordPress embed comments
- Headings (
- Deduplication — each target URL is linked at most once per page request. If
coral reefandreefboth point to the same page, only the first match wins. - Priority — entries are processed in descending
weightorder. Higher weight = matched first. - Max one link per keyword per page — after a keyword successfully creates a link, that entry is done for the current page.
#Blueprint Reference
Full list of fields in resources/blueprints/collections/internal_links/internal_link.yaml:
- handle: title # text, required — admin label- handle: target_entry # entries picker, max 1, required- handle: keywords # replicator - handle: keyword # text, required - handle: locale # select (optional) — pl, en, de, fr, es, it, nl, sv, no, da, lv, cs- handle: weight # integer, default 0- handle: nofollow # toggle, default false- handle: open_in_new_window # toggle, default false- handle: enabled # toggle, default true
#Troubleshooting
#Links are not appearing
- Check that
config/internal-links.phpexists andblog_collectionmatches your blog collection handle exactly (e.g.blog, notBlog). - Check that the entry in the
Blog Internal Linkingcollection has Active set totrue. - Verify the keyword exists verbatim in the rendered HTML (check page source, not browser inspector which may alter whitespace).
- Confirm the modifier is applied in the correct template and branch of the Bard loop.
- Check that the target entry has a published URL for the current site locale.
#Links appear on some languages but not others
The Language field on a keyword row must match the site handle (e.g. en, de), not the locale code (e.g. en_US). Leave it empty to apply to all languages.
#The same page is linked multiple times
This should not happen — the addon deduplicates per target URL per request. If it occurs, confirm you are not applying the modifier twice in the same template (e.g. in both the partial and the parent template).
#A keyword inside a heading is not linked
This is intentional — headings are protected to preserve SEO structure and avoid breaking heading semantics.
#After install nothing shows in CP
Run php artisan statamic:stache:refresh. If the collection still does not appear, verify that content/collections/internal_links.yaml was created and that its sites list contains your current site handle.