fkrzski/laravel-canonical
A Laravel package for adding canonical URL support to your application. It helps define and render canonical links, reducing duplicate-content SEO issues across pages with multiple URLs, and integrates simply with Laravel views and routing.
Install and confirm the wiring.
composer require fkrzski/laravel-canonical
The package follows standard Laravel package conventions (service provider + auto-discovery), so there is nothing to register by hand. Immediately after install, read the service provider — the package is small enough to audit in under an hour, and that audit is faster than guessing at method signatures:
vendor/fkrzski/laravel-canonical/src/ — the provider, any Blade directive registration, and the config defaults. This tells you the exact directive/helper name, whether it renders via a view component or a directive, and what the default canonical resolution is.config/canonical.php (after publishing) — normalization flags (trailing slash, query string stripping), default host/scheme, and any exclusion list.php artisan vendor:publish --tag=canonical-config # confirm the tag in the provider
Where to put it. The canonical tag belongs in your single base layout (typically resources/views/layouts/app.blade.php) inside <head>, not sprinkled across individual views. Add it once, immediately after your <title>/meta block, then let individual pages override.
First use case. The 10-minute win is the default case: a page that has no explicit canonical renders a <link rel="canonical" href="{current absolute URL}">. Install, add the directive, curl one public route, and confirm the tag is there and absolute:
curl -s https://yourapp.test/about | grep -i canonical
Only after that default works should you start overriding per page. Getting the override pattern right before the default is verified is how people end up with duplicate tags.
Decide the policy before touching views. Someone who owns the URL structure needs to answer, for each class of page: absolute or relative, which host (apex vs www, per-domain), what happens to query strings, and whether filtered/paginated listings self-canonicalize. The package implements whatever policy you feed it; it cannot pick one for you. This decision is the actual work.
Layout default + per-page override. The dominant pattern: the layout emits the package's default tag, and any view that needs something different calls the override before the layout renders (via a section, a stack, or the package's setter — whichever the source exposes). Keep the override in the controller or a view composer rather than inline in the Blade body, so it is testable.
Controller/view-composer resolution for dynamic pages. For models with slugs or nested resources, resolve the canonical from the route or the model rather than from the request:
// conceptually — map to the package's actual API
View::composer('products.show', fn ($view) => Canonical::set(
route('products.show', $view->getData()['product'])
));
Deriving from route() rather than request()->fullUrl() is what keeps the canonical stable when the page is reached via tracking params, pagination, or an alternate sort order.
Filtered and paginated listings. These are the canonical edge cases and where most SEO defects live. A pragmatic policy: the unfiltered first page is canonical for all filter/sort/?page= variants; paginated pages either self-canonicalize or point back to page 1 depending on your content strategy (Google no longer uses rel=next/prev, so the canonical is now the signal). Whatever you choose, encode it in one resolver per resource type rather than repeating conditionals in views.
Localized routes. With /{locale}/... routing, decide whether each locale self-canonicalizes (usual choice — each translation is its own page) and pair it with hreflang. The mistake to avoid is canonicalizing every locale to the default-language URL, which deindexes the other languages. If your app uses a locale-prefixed default (e.g. /en/), be deliberate about whether the unprefixed path redirects or is the canonical form, and make the tag agree with the redirect.
Middleware option. If the package ships middleware, apply it to the public web group so the canonical is set before the response renders — this is the cleanest way to keep the logic out of every controller. If it only ships a Blade directive, a small View::composer('*') in a service provider gives you the same centralization.
Coexistence with an existing SEO/meta layer. Run both in parallel on staging and diff the emitted <head> per route before removing the hand-rolled tag. A canonical regression is not caught by CI and takes weeks to surface in Search Console, so the parallel-run diff is the migration step that matters.
Pin it with a test. Add one assertion that a representative route emits the expected absolute canonical, so package bumps are covered:
$this->get('/products/example')
->assertSee('<link rel="canonical" href="https://yourapp.test/products/example"', false);
Add a second assertion for the trickiest page class you have (a filtered listing, or a non-default locale). Two tests cover most of the realistic failure surface.
The classic http://localhost canonical. Behind a proxy or load balancer, an unset APP_URL or unforwarded X-Forwarded-Host produces a canonical pointing at localhost or the internal hostname. This silently deindexes the site. Fix by setting APP_URL correctly and configuring trusted proxies (TrustProxies middleware with X-Forwarded-*), then verify with curl against the real hostname — not artisan serve.
Duplicate tags. If the package renders a default and a view renders its own, you get two <link rel="canonical"> tags and search engines may ignore both. Check whether the package guards against double emission; if not, make the override replace rather than append, and grep your views for any legacy canonical tag before shipping. Also verify it isn't emitted in partials that render more than once per page.
Absolute vs relative. Relative canonicals are legal but easy to get wrong under subdirectory deployments and are harder to eyeball in tests. Prefer absolute; if the package defaults to relative, normalize with the config or a wrapper.
Never derive the canonical from request()->fullUrl() on filtered/paginated pages. That self-canonicalizes every ?utm_source=, ?sort=, and ?page= variant, which is exactly the duplicate-content problem the package exists to solve. Whitelist the parameters that define a distinct page and drop the rest — the package's query-string normalization helps, but only if configured to strip the right set.
Response and view caching. If you cache full responses or rendered views, the canonical is baked in at cache-write time. Any per-request variation (session, locale negotiated from headers, host-based multi-domain) will leak the wrong canonical to other requests. Either key the cache on the dimensions that affect the canonical, or resolve it outside the cached region.
Multi-domain/multi-locale hosts. One app serving several domains needs the canonical host derived from the request (post-trusted-proxy) or from an explicit per-domain config — not from a single hard-coded APP_URL. This is the case most likely to require you to bypass the package's default resolution and set the URL explicitly.
Config quirks. After vendor:publish, config changes require php artisan config:clear in local dev and a config:cache rebuild in production; config edits appearing to "do nothing" are almost always a stale config cache. Publish the config rather than editing anything under vendor/, and keep the published file in version control so the canonical policy is reviewable.
Extension points. The config file (host, scheme, normalization rules) and the override mechanism are your two levers. If you need more — per-model canonical logic, exclusions — wrap the package in a tiny CanonicalResolver service of your own and let views call that; it keeps your policy in one auditable class and makes the package replaceable.
Dependency hygiene. Single-maintainer, very small user base, and 1.4.3 is documentation-and-packaging only — a safe pin with no runtime change from 1.4.2. Note that CHANGELOG.md was added to export-ignore, so change history is not in vendor/; read GitHub releases during audits. Treat it as "tolerable to replace": the fallback is a five-line Blade partial, so don't let it become load-bearing for anything beyond emitting the tag.
Debugging checklist when a canonical looks wrong: (1) is the tag present exactly once, (2) is the host correct from the public internet, not just localhost, (3) does it match the redirect target for that URL, (4) does it survive a query-string variant, (5) is it inside a cached region. Those five questions resolve nearly every report.
How can I help you explore Laravel packages today?