Automatic internal linking: the first mention of another page's name in your rendered content becomes a link to that page. Nothing is written into your source Markdown — disable the option and every auto link is gone.
composer require pushword/link-improver
The bundle registers itself in config/bundles.php, after the core bundle.
Then opt in per app — the filter is wired into every app's main_content chain (right after Markdown) but stays inert until you enable it, because it rewrites content:
pushword:
apps:
- hosts: [example.tld]
link_improver: true
# link_improver_max_links: 0.02 # the default
# link_improver_ignored_urls: ['/'] # never a target; empty by default
There is no CSV or admin screen to maintain: each line of a page's name field is a keyword that links to it. The first line is the name Pushword displays (breadcrumb, listings); further lines are never displayed, which makes them pure linking variants — including * wildcards, which match up to 10 characters:
name: |
Tour du Mont-Blanc
TMB en 11 étapes
tour du Mont*Blanc
Commas separate keywords too, as they do in the underlying library's CSV format. Keyword matching is case-insensitive; the inserted anchor keeps the casing found in the content. Only published pages of the same host and locale are offered as targets, redirections excluded, and a page never links itself.
Choose names accordingly: a page named Blog or Contact will attract links from everywhere its name is written.
link_improver_ignored_urls drops pages from the map without touching their name, which keeps working everywhere else (breadcrumb, listings). Write the URLs as the report prints them — / for the homepage, /slug otherwise:
link_improver_ignored_urls: ['/', '/contact']
The homepage is the usual candidate: its name is often the brand, written on nearly every page. On a real site, that alone took 44% of the inserted links. Whether that is what you want is an editorial call — a branded anchor next to the header link is fine for many sites; ignoring it spends the budget on topical targets instead.
The insertion engine refuses to place a link:
p, span, b, strong, em, i, li;#fragment, ?query or the absolute base_url form);link_improver_max_links bounds the total of in-content links, existing ones included. Below 1 it is a ratio of the word count — the default 0.02 means one link per 50 words; 1 or more is an absolute count.Because the cap counts the links already in the content, it reads as a target density rather than as a number of links to add: a page already linking above it gets nothing, a neglected page is filled up to it.
The default 0.02 — one link every 50 words — comes from measuring what the number means in practice. In Wikipedia's running prose (the <p> of 30 fr/en articles, 185 000 words, links to other articles only) the median article sits at one link per 20 words. Wikipedia is the densest reasonable reference, so half that density is a comfortable ceiling for a content site.
For scale, the same measure on real Pushword sites: a hand-linked site reaches one link per 48 words, while larger corpora sit between one per 180 and one per 720, with 8 to 49% of pages holding no internal link at all. Those are the pages the improver is for; 0.01 would have throttled it on exactly the short pages where a single link matters.
Automatic linking earns its bad reputation when it is invisible. Four surfaces, from the page you are reading to the whole site:
ROLE_EDITOR with a session sees this — the public HTML and the shared render cache are untouched, and an annotated response is sent private, no-store. The marking is a background, not a change to how your theme underlines links: a theme styles its links as it likes (the stock one uses a border-bottom and sets text-decoration: none), so a decoration-based marking would be invisible on some sites and doubled on others. Nothing is inserted into the text and no word moves, so the page still reads exactly as a visitor sees it.data-auto-link attribute — auto links stay distinguishable from editorial links to your crawler and to your own eyes (grep data-auto-link on a static export).php bin/console pw:link-improver renders every published page and reports each inserted link (page, anchor, target). --host example.tld narrows to one app, --simulate renders as if link_improver: true were set to preview a site before opting in, and --format follows the agent-output convention.The panel deliberately does not list which pages link to the one you are looking at: that answer needs the rest of the host rendered, and page-scanner's link graph (pw:link:graph) already reports inbound counts, depth and orphans — auto links included, since the scan renders each page.
Links are decorated at render time, after the Markdown/Twig pass and before the Html* link post-processors — an auto link to a page that becomes unpublished degrades exactly like an editorial one. Rendering is byte-deterministic: the map is derived from the pages ordered by slug, longest keyword first, so two renders of the same page produce the same bytes.
A page's rendered output now depends on the other pages' names: renaming, publishing or deleting a page already bumps the host's render epoch (name, slug and publishedAt are listing-relevant fields), so static and page-cache sites regenerate what a name change can affect.
Links inserted by the improver are registered with the link collector, so a pages_list(…, excludeAlreadyLinked: true) rendered after the content — in the template — will not offer a page the improver just linked in the body. A pages_list written inside the content renders before the improver and cannot know about them.