Usage
What happens on a 404
Friend listens on Craft's ErrorHandler::EVENT_BEFORE_HANDLE_EXCEPTION, which fires at the top of
Craft's exception handling — before Craft reads config/redirects.php, and before it renders your
404 template. A redirect from there costs nothing that would otherwise be thrown away.
For a 404 that passes the guards, Friend checks
config/redirects.php, then pins, then rules. It writes the result to the log, and either
redirects or steps aside and lets your 404 template render.
Nothing Friend does on that path can turn a 404 into a 500. If something goes wrong, it writes an
error to Craft's log under the friend category and the visitor gets their 404.
The 404 log
Friend → 404 log. One row per site and URI, with a hit counter — a usable "what is broken on this site" report, not a million-row request log.
Each row shows the requested path, the most recent referrer, how many times it has been requested, what happened (the status and target, and which rule or pin decided), the score, and when it was last seen. The summary at the top counts dead URLs, total requests, how many were sent somewhere and how many are still 404.
Filter by Still 404 or Sent somewhere, search URIs and targets, and sort by last seen, requests, URI or score. Test opens the row in the tester.
Only 404s Friend actually looked at are logged. Requests stopped by a guard, and URIs that
config/redirects.php handled, are not.
The log is trimmed by age (Keep for) and then by row count (Row cap) during Craft's garbage
collection, never on a visitor's request. php craft gc runs it on demand, and so does
php craft friend/log/prune.
Pins
A pin is a stored exact answer for one URI, checked before any rule and costing one indexed lookup. It is the escape hatch for the one URL the scoring gets wrong, without making the rules worse for every other URL.
There are two ways to make one:
- Pin on a row in the 404 log. The row's current target becomes the pin's target. The button only appears on rows that have a target, and for users who can edit pins.
- Friend → Pins → New pin.
| Field | Notes |
|---|---|
| URI | The path that 404s, without the domain. A leading slash is fine |
| Point at | An entry, or A URL — a site URI like blog, or a full http(s) URL to somewhere else entirely |
| Site | One site, or All sites. A pin for one site beats an all-sites pin for the same URI |
| Redirect status | The plugin default, or 301/302/307/308 |
| Enabled | A disabled pin is ignored |
A pin that points at an entry follows the entry: if its URI changes, the pin's redirect changes with it. A pin whose target no longer resolves, or would point back at the URI that missed, is skipped and the rules get their turn. Each pin counts its hits.
The tester
Friend → Tester. Enter a path, pick a site on a multi-site install, and press Try it.
You see the slug and the words Friend extracted, every enabled rule's verdict with the reason and its best score, every candidate with its slug, title and path scores and the methods that found it, and the final answer. Verdicts are:
| Verdict | Meaning |
|---|---|
skipped | The rule does not apply to this URI — pattern, exclusion or segment count |
no-candidates | Nothing came back from any retrieval method |
below-threshold | Candidates came back, but the best did not reach the threshold |
matched | The rule (or a pin, or a fallback URL) redirects |
suggested | The rule matched and hands suggestions to the template |
ignored | The rule matched and is set to do nothing |
The tester bypasses the cache and does not write to the log, so it shows what the rules say now. (A pin it lands on does still count the hit.)
It cannot evaluate config/redirects.php for a typed-in URI, because Craft's redirect rules only
match against the live request. When the file exists and Let config/redirects.php win is on,
the tester says so rather than implying it checked.
Twig
craft.friend is available in every template. In your 404 template:
{% set suggestions = craft.friend.suggestions(5) %}
{% if suggestions %}
<h2>Did you mean…</h2>
<ul>
{% for candidate in suggestions %}
<li>
<a href="{{ candidate.url }}">{{ candidate.title }}</a>
<span class="score">{{ candidate.score }}%</span>
</li>
{% endfor %}
</ul>
{% endif %}
| Tag | Returns |
|---|---|
craft.friend.suggestions(limit, uri) | Ranked candidates, best first. limit defaults to 5 |
craft.friend.best(uri) | The single best candidate, or null |
craft.friend.outcome(uri) | Everything Friend decided, or null |
craft.friend.missedUri() | The path that 404'd, in normal form — no domain, no query, no leading slash |
craft.friend.enabled() | Whether Look for friend is on. When it is off, the other tags return nothing |
Without a uri, the tags answer for the current request. With one, they resolve that URI as though
it had just 404'd — so they work on any page, not just a 404. Each call with a uri runs the rules
fresh, uncached, so set the result once and reuse it.
Suggestions come from the rule that decided, or — when no rule decided — from the first rule that found any candidates, whether or not they cleared its threshold. A "did you mean" list has a much lower bar than a redirect.
A candidate has title, uri (no leading slash), url, score (0–100), elementId,
elementType, methods (which retrieval methods found it) and breakdown (slug, title and
path, each 0–1).
An outcome has action (redirect, suggest, ignore or none), source (pin, rule or
null), rule, pin, candidate, candidates, targetUrl, statusCode, miss and traces,
plus matched(), shouldRedirect() and getScore().
Console
php craft friend/match/test <uri> [--site=handle] [--all] # resolve a URI, print the reasoning
php craft friend/match/rules # the rule set in evaluation order
php craft friend/log [--limit=25] [--unresolved] # the busiest 404s
php craft friend/log/prune # apply the retention settings now
php craft friend/log/clear # empty the log
friend/match/test is the tester without a browser. It resolves against the primary site unless
you pass --site, and shows the top eight candidates unless you pass --all.
$ php craft friend/match/test blog/2019/our-new-offices
blog/2019/our-new-offices
site: Main slug: our-new-offices tokens: blog, 2019, our, new, office
skipped Ignore probes — The URI does not match `re:^wp-`.
matched Similar entries (best 92.3) — Redirecting to news/our-new-office.
Candidates
92.3 /news/our-new-office slug 0.87 title 1.00 path 0.00 [slug,tokens,search]
41.0 /news/office-move slug 0.44 title 0.40 path 0.00 [search]
→ 302 https://example.com/news/our-new-office
friend/log lists rows by hit count, with totals at the top. friend/log/clear asks for
confirmation; add --interactive=0 to skip it in a script.
Caching
Dead URLs get hammered — one broken link in a newsletter, one stale sitemap, one scanner working through a wordlist. Friend caches its decision for each dead URL for Cache decisions for seconds (an hour by default), so only the first request pays for the search. Every request is still counted in the log.
Saving, deleting or reordering rules, and saving or deleting a pin, clears every cached decision at once. Saving an entry does not. Suggestion decisions are never cached.
Rule candidates are scored from id, slug, title and uri, and their URLs built from the URI
and the site exactly as Element::getUrl() would. Matching a rule never loads a full element.