CanonicalTag.com logo, a canonical tag referenceCanonicalTag.comThe canonical tag, explained
Code & JavaScript Frameworks

Canonical Tags in PHP

PHP emits no canonical until your code prints one, and the common copy-paste version built from the request's Host header is injectable.

VerdictManual code, and the naive version is a security bug

Nothing is emitted unless your code writes it

PHP has no canonical tag of its own: it is a programming language, not a content management system, so a page built in plain PHP carries a <link rel="canonical"> element only if your template prints one. The version found in countless snippets, which glues $_SERVER['HTTP_HOST'] to the raw request URI, is worse than having none, because it copies attacker-controlled input straight into the page head.

Frameworks do not change the starting point much. Laravel core prints nothing either; the canonical arrives only when a package such as artesaos/seotools or ralphjsmit/laravel-seo is installed, or when a developer writes it into the layout. On a hand-built site the application is the CMS, which means it has to decide, for every request, which URL is the preferred one and print exactly that.

Two constraints from Google shape what that code has to produce. The element counts only inside the <head>, and the URL should be absolute. RFC 6596, the specification that defines the canonical link relation, tolerates relative targets, but Google's documentation is stricter, and the stricter rule is the one worth following. Google also asks for a self-referencing canonical on the preferred page itself, so the tag belongs in the shared layout rather than on a handful of hand-picked templates.

Whatever the code prints is advice, not an instruction. In Google's words, "indicating a canonical preference is a hint, not a rule", and it is weighed alongside redirects, HTTPS, and sitemap inclusion. A correct tag makes agreement likely; it does not compel it.

Why the $_SERVER['HTTP_HOST'] version is unsafe

This is the pattern to recognize and remove:

<?php // do not use: host and path both come from the client
$canonical = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
?>
<link rel="canonical" href="<?= $canonical ?>">

It fails in three separate ways.

  • The host is supplied by the requester. $_SERVER['HTTP_HOST'] is simply the Host request header. Anyone can send Host: evil.example, and the page will name that domain as its preferred version. PortSwigger's research on Host-header attacks describes the root cause as the assumption that the header cannot be controlled by the user. SERVER_NAME is not an automatic fix either: the PHP manual notes that under Apache 2 it only reflects your configured name when UseCanonicalName = On and ServerName are set, and otherwise mirrors the client's value.
  • Caches spread the damage. If a reverse proxy or CDN stores the poisoned response, every later visitor and crawler receives the attacker's canonical, not just the person who sent the forged header. Proxies that honor X-Forwarded-Host widen the same hole. RFC 6596's own security section anticipates this, warning that a canonical on a compromised site can be pointed at the attacker's address as the preferred version.
  • The path is unescaped. REQUEST_URI echoed into an attribute is reflected cross-site scripting (XSS) waiting for a crafted URL. This is not hypothetical: issue #247 on artesaos/seotools reported exactly that reflection into the canonical href.

There is also a plain SEO failure hiding underneath the security one. REQUEST_URI includes the query string, so ?utm_source=newsletter, session IDs, and sort orders all end up in the canonical. Every variant then declares itself preferred, which is the opposite of what the tag exists to do.

A secure canonical builder in plain PHP

The safe version reverses each of those choices: the scheme and host are a constant you control, only the path is taken from the request, the query string is discarded unless a parameter is explicitly allowed, and the output is escaped at the point of printing.

<?php
// Fixed origin. Never read it from $_SERVER['HTTP_HOST'].
const CANONICAL_ORIGIN = 'https://www.example.com';

function canonical_url(array $keepParams = []): string {
    // Path only: parse_url drops the query string and fragment.
    $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
    $path = preg_replace('#/+#', '/', $path);   // collapse "//"
    if ($path !== '/') {
        $path = rtrim($path, '/');              // one slash policy: none
    }
    $query = '';
    if ($keepParams) {                          // allowlist, e.g. ['page']
        $kept = array_intersect_key($_GET, array_flip($keepParams));
        if ($kept) {
            $query = '?' . http_build_query($kept);
        }
    }
    return CANONICAL_ORIGIN . $path . $query;
}

$canonical = canonical_url();
?>
<link rel="canonical" href="<?= htmlspecialchars($canonical, ENT_QUOTES, 'UTF-8') ?>">

A few choices here deserve a note. Using parse_url with PHP_URL_PATH strips tracking and sort parameters in one step instead of trying to blocklist them. The trailing-slash rule is arbitrary, but it has to match your internal links, your sitemap, and your redirects; pick one and apply it everywhere. The $keepParams allowlist exists for the rare parameter that genuinely changes content, and htmlspecialchars with ENT_QUOTES ensures that even an unexpected character cannot break out of the href attribute.

The stronger design: derive the canonical from the record, not the request. If a catch-all route answers /product/123/anything-here with a 200, any request-based function will endorse that junk path as preferred. Looking up the product and printing its stored URL means an unknown path can never self-canonicalize.

Sending it as a Link header instead

Google also accepts the canonical as an HTTP response header, which is the only option for PDFs and other files a PHP download script serves. The header costs nothing in page weight and can cover any number of duplicate URLs. In PHP it is one call:

header('Link: <' . $canonical . '>; rel="canonical"', false);

Two details trip people up. First, header() has to run before any output at all; a stray blank line before the opening PHP tag is enough to produce a "headers already sent" warning and no header. Second, that trailing false is the $replace argument. Link also carries preload hints, and with the default true one Link header overwrites the other, so the canonical or the preload quietly disappears.

Sending both a header and an element is allowed, but they must name the same URL. Google's guidance is not to specify different canonical URLs for one page through different methods, and a template that builds the element from one function and the header from another is exactly how that disagreement creeps in. Compute $canonical once and reuse it for both.

Laravel: which URL helper, and which package default

Laravel gives you the right building block, and one of its package defaults quietly undoes it.

  • Use url()->current(), not url()->full(). The first returns the current URL without the query string; the second includes it. The facade form is URL::current(), and $request->url() behaves the same way.
  • Expect the trailing slash to vanish. In the framework source, Request::url() removes both the query string and a trailing slash, so /shoes/ and /shoes produce the same canonical. That is convenient, unless your routes, links, and sitemap use slashes, in which case the canonical contradicts them on every page.
  • Restrict trusted hosts. The Laravel request documentation states that by default the framework responds regardless of the Host header and uses that header's value when generating absolute URLs. That is the HTTP_HOST problem again, one layer down. The fix is the TrustHosts middleware in bootstrap/app.php, for example $middleware->trustHosts(at: ['^example\.com$']), or restricting hostnames at the web server.

Package defaults

artesaos/seotools reads meta.defaults.canonical in config/seotools.php. Its config comment says null or 'full' uses Url::full(), which carries the query string; issue #180 documents UTM parameters landing in canonicals as a result. Set it explicitly:

// config/seotools.php, under meta.defaults
'canonical' => 'current',

Per-page overrides go through SEOMeta::setCanonical($url), rendered with {!! SEOMeta::generate() !!}. Whether the project has since changed that default, or patched the #247 reflection in code, was not confirmed, so check your installed config and escape defensively regardless. ralphjsmit/laravel-seo starts from the safer url()->current(), controlled by canonical_link, with per-page overrides through SEOData(url: ...).

Testing the output: curl, forged hosts, and Search Console

Three requests tell you most of what matters, and none needs anything beyond curl:

# the element
curl -s https://www.example.com/page | grep -i 'rel="canonical"'
# the header, if you send one
curl -sI https://www.example.com/page | grep -i '^link:'
# forged host: the canonical must not change
curl -s -H 'Host: attacker.example' https://<server-ip>/page

Then request the page with ?utm_source=x&foo=<script> appended. The canonical should come back clean, and nothing from the query should appear in the source unescaped. If the forged-host request prints attacker.example anywhere in the canonical, treat it as a vulnerability first and an SEO issue second.

In Search Console, URL Inspection shows the user-declared canonical beside the Google-selected canonical; a mismatch means Google weighed your other signals and disagreed. Pages that land in "Duplicate, Google chose different canonical than user" or "Duplicate without user-selected canonical" in the Page indexing report are covered on their own pages here. A desktop SEO crawler that reads both the header and the HTML element will flag disagreements between them across a whole site faster than spot checks can.

When a PHP canonical is the wrong fix

A canonical is a consolidation hint, and several duplicate problems on PHP sites have firmer fixes.

  • Protocol, host, and trailing-slash variants are better handled with redirects at the web server. The self-referencing canonical then reinforces a decision already made, rather than asking Google to make it.
  • Distinct paginated pages should keep their page parameter (such as ?page=2) in the canonical; collapsing them into page one discards content. Tracking and sort parameters are the ones to strip. No universal allowlist exists, which is why the function above takes one as an argument.
  • A canonical you cannot secure should not ship. If a legacy codebase cannot guarantee a fixed host and escaped output, printing no canonical is safer than printing an injectable one; Google will still pick a canonical from its other signals.

The general rule is that the canonical should state something the rest of the site already agrees with: the URL the router answers, the URL internal links use, the URL in the sitemap. Code that computes it from a trusted origin and a known record gets that agreement almost for free.

Frequently asked questions

How do I add a canonical tag in PHP?

Print a <link rel="canonical"> element in the shared <head> template, built from a hardcoded scheme and host plus the request path with the query string removed. Escape the value with htmlspecialchars before printing it. Better still, print the stored URL of the record the page displays, so unknown paths never declare themselves canonical.

Is it safe to use $_SERVER['HTTP_HOST'] in a canonical URL?

No. It is the client-supplied Host header, so a forged request can make the page name another domain as its canonical, and a cache can then serve that response to everyone. Define the origin as a constant or configuration value and validate hosts at the server or framework level.

Should the canonical URL include the query string?

Usually not. Copying REQUEST_URI brings tracking codes, session IDs, and sort orders into the canonical, so every variant declares itself preferred. Keep only parameters that change the content, such as a page number on distinct paginated pages, through an explicit allowlist.

How do I send a canonical as an HTTP header in PHP?

Call header('Link: <' . $canonical . '>; rel="canonical"', false) before any output is sent. The false argument keeps the call from replacing other Link headers such as preload hints. If the page also has a canonical element, both must point to the same URL.

Does Laravel add a canonical tag automatically?

No. Laravel core emits none; packages such as artesaos/seotools and ralphjsmit/laravel-seo add one. If you use seotools, set its canonical default to 'current', because the null default uses the full URL including the query string.

What is the difference between url()->current() and url()->full() in Laravel?

url()->current() returns the current URL without the query string, while url()->full() includes it. For canonicals, current() is the right starting point. Note that the underlying Request::url() also strips a trailing slash, which matters if your site's URLs end in one.

Top