SEO-002
One head builder for titles, meta and social tags
Every page passes an array, one function writes the head. The alternative is a site where half the pages have no description and nobody notices for months.
Head tags written by hand in each template drift. A page gets added without a description, another keeps a canonical copied from the page it was based on, and both problems are invisible until you look at what search engines actually indexed.
One function that takes an array and emits the whole head fixes it structurally. A missing description becomes the site default rather than nothing. The canonical is computed rather than pasted. Adding a social tag happens once for every page at the same time.
Take the canonical from the caller rather than the request URI, so query strings and tracking parameters cannot fragment a page into a dozen indexed copies.
function seo_head(array $o): string
{
$siteName = setting('site_name');
$title = trim((string) ($o['title'] ?? $siteName));
$desc = trim((string) ($o['description'] ?? setting('meta_description')));
$canon = (string) ($o['canonical'] ?? url(ltrim((string) ($_SERVER['REQUEST_URI'] ?? '/'), '/')));
$image = trim((string) ($o['image'] ?? setting('og_image')));
$type = (string) ($o['og_type'] ?? 'website');
$h = '<title>' . e($title) . '</title>' . "\n";
if ($desc !== '') {
$h .= '<meta name="description" content="' . e($desc) . '">' . "\n";
}
$h .= '<link rel="canonical" href="' . e($canon) . '">' . "\n";
// noindex still allows follow: a thin page's links must stay crawlable.
$h .= empty($o['noindex'])
? '<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large">' . "\n"
: '<meta name="robots" content="noindex, follow">' . "\n";
$h .= '<meta property="og:site_name" content="' . e($siteName) . '">' . "\n";
$h .= '<meta property="og:title" content="' . e($title) . '">' . "\n";
$h .= '<meta property="og:type" content="' . e($type) . '">' . "\n";
$h .= '<meta property="og:url" content="' . e($canon) . '">' . "\n";
if ($desc !== '') {
$h .= '<meta property="og:description" content="' . e($desc) . '">' . "\n";
}
$h .= $image !== ''
? '<meta property="og:image" content="' . e($image) . '">' . "\n"
. '<meta name="twitter:card" content="summary_large_image">' . "\n"
: '<meta name="twitter:card" content="summary">' . "\n";
if (!empty($o['jsonld'])) {
$h .= '<script type="application/ld+json">'
. json_encode($o['jsonld'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
. '</script>' . "\n";
}
return $h;
}
Using it
Keep titles under about 60 characters. Anything longer is truncated in results, and padding the middle with a category name buys nothing.
Descriptions of 120 to 160 characters show in full. Shorter gets padded by the engine with text you did not choose.
Pass the canonical explicitly from every page. Deriving it from the request URI means a link with a tracking parameter creates a second canonical for the same content.
What bites people
Escape everything. A quote in a title breaks the attribute and silently swallows the tags after it.
json_encode with JSON_UNESCAPED_SLASHES matters for structured data: escaped slashes in URLs are valid JSON but make the output unreadable when you are debugging it.