statikbe / craft-link-checker
Finds broken links in the content of Craft CMS entries (links to deleted, disabled or expired entries, deleted assets, own-site URLs that no longer exist) and lists the pages that contain them in a dashboard widget
Package info
github.com/statikbe/craft-link-checker
Type:craft-plugin
pkg:composer/statikbe/craft-link-checker
Requires
- php: ^8.2
- craftcms/cms: ^5.6.0
Requires (Dev)
- craftcms/ckeditor: ^4.0
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
Suggests
- verbb/hyper: Hyper link fields are checked too
Provides
None
Conflicts
None
Replaces
None
README
Finds the broken links in the content of your Craft CMS 5 site and lists the pages that contain them in a dashboard widget, so editors can fix them before visitors find them.
- Broken links: links to entries that were deleted, are in the trash, disabled, expired, not in the page's site or have no
page of their own; links to deleted assets; links to pages of the site itself (
/nl/over-ons,https://example.be/…) that no longer exist; links to a website whose domain doesn't exist (a typo, an expired domain); external pages that answer 404 or 410. - Warnings: links to entries that aren't published yet; links to a page that a redirect sends on (link to the page
itself); external pages that moved permanently; anchors (
#team) that aren't on the target page; invalid email addresses and phone numbers; a deleted entry whose saved URL still works. - Broken links dashboard widget: the pages with broken links, with a link to edit each one and every broken link with its reason and where it is (Content builder 2 (Content row) › Columns 1 (Text) › Text). Each user only sees the pages they may edit: in the sites they may edit, and other people's entries only with the permission to save them.
- Links panel in the entry sidebar: the problems of the page as it's edited, updated when the editor pauses.
- Check on publish: a warning (or, with
'block', an error that stops the save) when a page has broken links, shown on the row, the block or column and the field, as for Craft's own errors. - Global sets (a footer, site settings) are checked too.
- Utilities → Link Checker: every problem with filters (broken or warnings, site, search), a CSV export, and the ignored links.
- Ignore a link (in the utility or the Links panel): it's no longer reported anywhere, until you stop ignoring it.
- Console:
craft link-checker/rebuildreads every page again;craft link-checker/reportlists the problems.
It reads native Link fields, rich text fields (CKEditor, Redactor) and Hyper fields, also in nested entries: Matrix (any depth, also Trinity rows), Content Blocks and entries in CKEditor. No other plugin is needed, and it works on existing sites.
How it works
Every link of every page is stored in a table, per site, with where it is. A page is read again when it (or a block on it) is saved, in a queue job after the save, so saving isn't slower. Whether a link is broken isn't stored: it's worked out from the target's current state when the report is read. Deleting, disabling or restoring an entry shows up in the widget straight away, without reading the pages that link to it, and without cron. The report is cached for up to 10 minutes, once for everyone (each user sees the pages they may edit), and cleared when something it shows changes: a page or link target is published, moved, disabled, deleted or restored, pages are read, a link becomes or stops being broken, or a link is ignored. An entry whose post or expiry date passes shows up within those 10 minutes.
Only the links that may have a problem are read from the database: a link to a live page of the site itself without an anchor never leaves it, so the report stays fast on large sites.
Performance
The plugin adds nothing to the front end: no queries, no scripts. In the control panel and the queue:
- Saving an entry adds one row to a table of pages to read. One queue job at a time reads them, 20 pages per batch, after
Craft's own jobs (priority 2048). Saving the same page again while it waits doesn't add work. Resaving (
craft resave, a project config change) and propagating to other sites don't read pages again; imports in a queue job are read after that job. - Reading a page loads its nested entries a level at a time for all siblings, so a page with hundreds of blocks takes a few queries per level, not one per block.
- The report is built by one request at a time; while it's built, other requests get the previous one. It's cleared at most once per request or queue job, and not for drafts, nested entries, assets that are only saved, users, form submissions, resaving or propagation.
- Queue jobs carry no lists: each batch takes its next rows from the tables, so a job stays a few bytes, and a batch stops in time (TTR 300 seconds). Only one job per kind waits at a time.
- External checks: an unknown result (401, 403, 429, 5xx, a timeout) is checked again after 1, 3, 7 and then 14 days; a host that answers 401, 403 or 429 five times in a row is left alone for two weeks. Domains and URLs no page links to any more are removed after a month.
- The Links panel loads its list after the page, and reloads once the editor pauses (10 seconds; a minute while it's collapsed).
Links to entries are stored by ID ({entry:12@1:url}), so renaming an entry or changing its URL doesn't break them. Only
links typed as a URL break when a page moves. Those are matched against the URIs of the live entries, the routes and the
templates of the site, and the redirects of SEO Fields and
Retour.
External links
External links (to other websites) are checked in queue jobs, never in an editor's request:
- The domain is looked up (DNS), cached per domain: again after 7 days when it exists, after a day when it doesn't. A
domain only counts as not existing after two failed lookups at least an hour apart. When even
example.comcan't be looked up (no network, a broken resolver) nothing is marked. - The page is requested (
checkExternal), each URL once however many pages link to it: HEAD first, a GET when a server doesn't answer HEAD properly. One request at a time, withhostDelaybetween two requests to the same host.- 404 or 410 twice in a row, at least an hour apart (
failuresBeforeBroken): broken. - A permanent redirect (301, 308) to a page that works: a warning. Not when it only changes http to https,
www.or a trailing slash. - Anything else (401, 403, 429, 5xx, a timeout, an SSL error): unknown, never reported. Sites that block link checkers
would always look broken, so their pages aren't requested (
skipHosts: Facebook, Instagram, LinkedIn, X, TikTok). - A working page is checked again after 7 days, a failing one after an hour, a broken one after a day, an unknown one after 1, 3, 7, then every 14 days. A host that answers 401, 403 or 429 to five checks in a row is left alone for two weeks.
- Redirects are followed by hand, so every step is checked: only http(s) on ports 80 and 443, never a host with an address (IPv4 or IPv6) that isn't public (the server's own network, cloud metadata, carrier-grade NAT), and the checked address is the one connected to. Requests always go through curl and never through a proxy.
- Only one job runs at a time; a URL another job checked in the meantime is skipped. Between two checks on the same host
it waits
hostDelay; not between the steps of one check (a redirect, HEAD then GET).
- 404 or 410 twice in a row, at least an hour apart (
This needs outbound HTTP from the server. Where that's not allowed, set 'checkExternal' => false for that environment;
the domains are still checked.
Requirements
Craft CMS 5.6 or later, PHP 8.2 or later.
Installation
composer require statikbe/craft-link-checker php craft plugin/install link-checker
Updating from an earlier version: run php craft up (or the database update in the control panel). It adds the tables
and columns of the newer version and reads the links of every page again when needed.
Installing queues a job that reads every page. On a large site you can also run it yourself:
php craft link-checker/rebuild # every page, the domains and the external pages, now php craft link-checker/rebuild --skip-external # without requesting the external pages php craft link-checker/rebuild --queue # as a queue job php craft link-checker/check-urls # the external pages that are due (--all: every one) php craft link-checker/report # the broken links (--warnings: the warnings too)
Then add the Broken links widget to the dashboard.
Configuration
Copy this to config/link-checker.php and change what you need. Multi-environment configs work as usual.
<?php return [ // The sections whose entries are checked: '*' or a list of handles 'sections' => '*', // Fields that are never read for links 'excludeFields' => [], // Other hosts that are this website: the production domain on a local or staging install, an old domain. // www and non-www are already the same. 'internalHosts' => ['www.example.be'], // Links to ignore: regular expressions matched against the stored value 'ignorePatterns' => [], // A link to an entry in another site (language): 'ok' or 'warning' 'crossSiteLinks' => 'ok', // Anchors (#…) that aren't on the target page, as warnings 'checkAnchors' => true, // Anchors the templates render on every page (not in the content): strings, or regular expressions between # signs 'knownAnchors' => ['main', 'contact-form'], // Invalid mailto: and tel: links, as warnings 'checkEmailAndPhone' => true, // Also check the global sets 'checkGlobals' => true, // Check that the domain of an external link exists (DNS) 'checkDomains' => true, // Request external pages (HTTP): 404/410 twice is broken, a permanent redirect a warning 'checkExternal' => true, // Hosts whose pages aren't requested (they block link checkers); their domain is still checked 'skipHosts' => ['facebook.com', 'instagram.com', 'linkedin.com', 'x.com', 'twitter.com', 'tiktok.com'], 'timeout' => 10, // seconds 'hostDelay' => 1000, // milliseconds between two requests to the same host 'failuresBeforeBroken' => 2, // When an entry with broken links is published: 'warn' (saved, with a warning), 'block' (not saved, the errors on the // fields) or false. Warnings never count. A link can break without the page being edited, so 'warn' is the default. 'mode' => 'warn', // The Links panel in the entry sidebar 'panel' => true, ];
Anchors
A page's anchors are the ids in its rich text (id and name attributes) and the anchors of
Entry Anchors fields. Ids that only your templates render aren't in the
content: list them in knownAnchors, or a link to them is reported as an anchor that's not found. A page in a section that
isn't checked has no known anchors, so links to its anchors aren't reported.
The Links panel
The sidebar of an entry gets a Links panel under the notes: the broken links and warnings of the page as it's edited, also the changes that aren't saved yet, with the reason and where each link is. It's collapsible (the browser remembers it for every entry). Its list is loaded after the page, so it never slows the edit page down, and reloads once the editor pauses after an autosave (10 seconds; a minute while it's collapsed, and at once when it's opened). The number of broken links shows next to its title, also when it's collapsed.
Check on publish
With 'warn', publishing a page with broken links shows a warning that lists them; the page is saved. With 'block', it
isn't saved: each broken link is shown where it is, on the row or block, the column (its card turns red) and the field. Only
broken links count, never warnings, and only when an entry is published in the control panel: not for drafts, autosaves,
resaving, console commands, front-end entry forms or GraphQL. Links in disabled blocks don't count: they aren't shown on
the page.
Utilities → Link Checker
Every link with a problem, with the page (a link to edit it), the site, the link, the problem and where it is. Filter by broken links, warnings or both, by site, and search in the page, the link and the location. Export CSV downloads the filtered list. Users need Craft's permission for the utility ("Link Checker" under Utilities).
Ignore stops reporting a link everywhere it's used (by its stored value: a URL, an entry reference, a mailto:). The ignored links are listed at the bottom, with who ignored them and Stop ignoring. An editor can also ignore a link in the Links panel of a page they may edit (only a link that's on that page). In the CSV export, text that a spreadsheet would run as a formula starts with a quote.
Widget
The widget lists the pages with broken links, broken ones first. Its settings: the number of pages shown (the rest is counted) and whether to also list pages with only warnings.
License
MIT, see LICENSE.md.