SEO
SEO gives every page of your Laravel application its title, meta tags, canonical and alternate links, Open Graph and Twitter cards. Set them from a controller, from an Eloquent model or from a Blade view, then print them all with one @seo directive in your layout.
Meta Tags
Charset, viewport, title, description, keywords, author, robots and copyright.
Links
Canonical, next and previous pages, and alternate languages with hreflang.
Social Cards
Open Graph and Twitter cards built from the same title, description and images.
Eloquent Models
A model returns its SEO data once, and one call applies it to the page.
Blade Directives
Set any tag from a view, and render everything with a single directive.
Escaped Output
Every value is HTML-escaped, so content from users is safe to use.
Set the values that rarely change, such as the charset, the viewport and the Twitter site, once in the configuration file.
#Installation
Install the package with Composer. The service provider is registered automatically.
#Requirements
- PHP 8.2, 8.3, 8.4 or 8.5
- Laravel 12.0 or later 12.x releases
#Composer Installation
composer require pharaonic/laravel-seo#Publish Configuration
Publish the configuration file to config/pharaonic/seo.php when you need to change the defaults or add elements:
php artisan vendor:publish --tag=seo-config#Render the Tags
Print every tag inside the <head> of your layout:
1<head>2 @seo3</head>You're all set! Set a title anywhere in the request, and @seo prints it with the rest of the tags.
#Upgrading from 2.x
The 2.x API keeps working: every method, element, configuration key, publish tag and Blade directive is still there. The upgrade fixes output that 2.x got wrong and adds clearer names; the 2.x names stay as deprecated aliases.
#Requirements
The package now depends on illuminate/* components instead of laravel/framework, and has one line per Laravel version. Install the line of your Laravel version; this one requires PHP 8.2, 8.3, 8.4 or 8.5 and Laravel 12.0+.
#Renamed APIs
| 2.x | Now |
|---|---|
@title, @keyword, @openGraphType and the other unprefixed directives | @seoTitle, @seoKeyword, @seoOpenGraphType |
config/Pharaonic/seo.php (Pharaonic.seo) | config/pharaonic/seo.php (pharaonic.seo) |
The open-graph key in the defaults and models | open_graph |
The copyrights element, key and @copyrights directive | copyright, @seoCopyright |
The config and seo publish tags | seo-config |
A configuration file published by 2.x is still read. To move it, publish the new file, copy your values into it and delete the old one.
The 2.x names are deprecated. They keep working on every current line and are removed in the first line that supports PHP 8.6.
#Changed Output
- Every value is HTML-escaped. If you passed values that were already escaped, such as
&, pass the plain text instead. - Open Graph tags use the
propertyattribute:<meta property="og:title" ...>. - The copyright tag is
<meta name="copyright">instead ofcopyrights. - The Twitter card renders one
twitter:image, the first image, instead oftwitter:image0,twitter:image1and so on. - Twitter usernames always get a single
@, sosetSite('@site')no longer renders@@site.
#Fixed Behavior
seo()->model()sets the Open Graph values and skipsnullvalues.getType(),getSiteName(),getCard(),getSite()andgetCreator()returnnullwhen nothing is set.remove()reindexes the list.- Errors are
InvalidArgumentException, which still extendsException. - Tags no longer leak between requests on Laravel Octane or between jobs in a queue worker.
#Configuration
The configuration lives in config/pharaonic/seo.php after you publish it.
1return [ 2 'default' => [ 3 'charset' => 'UTF-8', 4 // 'viewport' => 'width=device-width, initial-scale=1.0', 5 6 'open_graph' => [ 7 'type' => 'website', 8 // 'site_name' => 'Site Name', 9 ],10 11 'twitter' => [12 'card' => 'summary',13 // 'site' => '@site',14 // 'creator' => '@creator',15 ],16 ],17 18 'elements' => [19 // 'charset' => \App\SEO\Charset::class,20 ],21];#Defaults
default holds the starting value of any element, keyed by its name. A list element such as keywords takes an array or a comma-separated string.
1'default' => [2 'charset' => 'UTF-8',3 'viewport' => 'width=device-width, initial-scale=1.0',4 'author' => 'Pharaonic',5 'robots' => 'index, follow',6 'keywords' => ['laravel', 'packages'],7],| Key | Description |
|---|---|
default.<element> | Starting value of an element, such as title or robots |
default.open_graph.type | og:type |
default.open_graph.site_name | og:site_name |
default.twitter.card | twitter:card |
default.twitter.site | twitter:site, with or without the @ |
default.twitter.creator | twitter:creator, with or without the @ |
elements | Extra elements, or replacements for built-in ones. See Custom Elements |
#Basic Usage
The seo() helper returns the manager of the current request. Each element is a property, and each setter returns the element, so calls chain.
1public function __invoke() 2{ 3 seo()->title->set('Pharaonic'); 4 seo()->description->set('Open-source packages for Laravel.'); 5 seo()->keywords->set('laravel, packages')->add('php'); 6 seo()->canonical->set(url('/')); 7 seo()->images->add(asset('images/cover.png')); 8 9 return view('home');10}@seo in the layout prints every tag that has a value:
1<head>2 @seo3</head> 1<meta charset="UTF-8"> 2<title>Pharaonic</title> 3<meta name="description" content="Open-source packages for Laravel."> 4<meta name="keywords" content="laravel, packages, php"> 5<link rel="canonical" href="https://example.com"> 6<meta property="og:url" content="https://example.com"> 7<meta property="og:locale" content="en"> 8<meta property="og:type" content="website"> 9<meta property="og:title" content="Pharaonic">10<meta property="og:description" content="Open-source packages for Laravel.">11<meta property="og:image" content="https://example.com/images/cover.png">12<meta name="twitter:card" content="summary">13<meta name="twitter:title" content="Pharaonic">14<meta name="twitter:description" content="Open-source packages for Laravel.">15<meta name="twitter:image" content="https://example.com/images/cover.png">Read a value back with get(), and empty it with clear():
1seo()->title->get(); // "Pharaonic"2seo()->title->clear()->get(); // nullseo()->render() returns the same HTML as @seo, for places outside Blade.
#Elements
Each element renders one tag, or one tag per value for lists. Elements without a value render nothing.
| Element | Renders |
|---|---|
charset | <meta charset="..."> |
viewport | <meta name="viewport" content="..."> |
title | <title>...</title> |
description | <meta name="description" content="..."> |
keywords | <meta name="keywords" content="a, b"> |
author | <meta name="author" content="..."> |
robots | <meta name="robots" content="..."> |
copyright | <meta name="copyright" content="..."> |
next | <link rel="next" href="..."> |
prev | <link rel="prev" href="..."> |
alternates | <link rel="alternate" hreflang="..." href="..."> per language |
images | No tag of its own: the Open Graph and Twitter cards use it |
canonical | <link rel="canonical" href="..."> |
#Single Values
set() replaces the value:
1seo()->robots->set('noindex, nofollow');#Lists
keywords, images and alternates hold lists. set() replaces the list, add() appends to it, and remove() drops one entry by its position.
1seo()->keywords->set(['laravel', 'seo']);2seo()->keywords->set('laravel, seo'); // the same list3seo()->keywords->add('meta tags');4seo()->keywords->remove(0);5seo()->keywords->get(); // ["seo", "meta tags"]alternates takes a language and a URL for each entry:
1seo()->alternates->add('en', url('en/about'));2seo()->alternates->add('ar', url('ar/about'));1<link rel="alternate" hreflang="en" href="https://example.com/en/about">2<link rel="alternate" hreflang="ar" href="https://example.com/ar/about">#Pagination
Point search engines at the neighbouring pages of a paginator:
1$posts = Post::latest()->paginate();2 3seo()->prev->set((string) $posts->previousPageUrl());4seo()->next->set((string) $posts->nextPageUrl());An empty string renders nothing, so the first and the last page get only one link.
Every value is HTML-escaped when it is rendered. Pass plain text, not HTML.
#Open Graph & Twitter
Both cards reuse the page's title, description and images, so you set them once.
#Open Graph
seo()->openGraph renders, in this order:
| Tag | Value |
|---|---|
og:url | The current URL |
og:locale | The application locale |
og:locale:alternate | Each alternates language, with dots replaced by underscores |
og:type | setType(), website by default |
og:site_name | setSiteName() |
og:title | The title element |
og:description | The description element |
og:image | Each entry of images |
1seo()->openGraph->setType('article')->setSiteName('Pharaonic');2seo()->openGraph->getType(); // "article"seo()->twitter renders twitter:card, twitter:site, twitter:creator, twitter:title, twitter:description and twitter:image. A Twitter card takes one image, so it uses the first entry of images.
1seo()->twitter->setCard('summary_large_image')2 ->setSite('pharaonic')3 ->setCreator('@raggi');4 5seo()->twitter->getSite(); // "@pharaonic"6seo()->twitter->getCreator(); // "@raggi"Usernames get exactly one @, whether you pass it or not. A getter returns null until its value is set.
#Eloquent Models
A model that implements SEOContract returns its SEO data from seo(). The keys are element names, plus open_graph and twitter for the cards.
1use Illuminate\Database\Eloquent\Model; 2use Pharaonic\Laravel\SEO\Contracts\SEOContract; 3 4class Post extends Model implements SEOContract 5{ 6 public function seo(): array 7 { 8 return [ 9 'title' => $this->title,10 'description' => $this->excerpt,11 'keywords' => $this->tags->pluck('name')->all(),12 'images' => [$this->cover_url],13 'open_graph' => [14 'type' => 'article',15 ],16 'twitter' => [17 'card' => 'summary_large_image',18 ],19 ];20 }21}Apply it in the controller:
1public function show(Post $post)2{3 seo()->model($post);4 5 return view('posts.show', compact('post'));6}- A
nullvalue is skipped, so the element keeps its default. - Numbers are turned into strings.
- Card attributes call the matching setter:
site_namecallssetSiteName(). - An unknown key, or a model that doesn't implement
SEOContract, throws anInvalidArgumentException.
#Blade Directives
Every element has a directive that sets it, and list elements have a second one that adds to them. @seo prints all the tags.
1@extends('layouts.app') 2 3@seoTitle($post->title) 4@seoDescription($post->excerpt) 5@seoImage($post->cover_url) 6@seoOpenGraphType('article') 7 8@section('content') 9 ...10@endsectionA child view runs before its layout, so values set in the view are ready when @seo renders in the layout's <head>.
| Directive | Calls |
|---|---|
@seo | Prints every tag |
@seoCharset(...) | seo()->charset->set(...) |
@seoViewport(...) | seo()->viewport->set(...) |
@seoTitle(...) | seo()->title->set(...) |
@seoDescription(...) | seo()->description->set(...) |
@seoKeywords(...) / @seoKeyword(...) | seo()->keywords->set(...) / add(...) |
@seoAuthor(...) | seo()->author->set(...) |
@seoRobots(...) | seo()->robots->set(...) |
@seoCopyright(...) | seo()->copyright->set(...) |
@seoNext(...) / @seoPrev(...) | seo()->next->set(...) / seo()->prev->set(...) |
@seoAlternates(...) / @seoAlternate($lang, $url) | seo()->alternates->set(...) / add(...) |
@seoImages(...) / @seoImage(...) | seo()->images->set(...) / add(...) |
@seoCanonical(...) | seo()->canonical->set(...) |
@seoOpenGraphType(...) | seo()->openGraph->setType(...) |
@seoOpenGraphSiteName(...) | seo()->openGraph->setSiteName(...) |
@seoTwitterCard(...) | seo()->twitter->setCard(...) |
@seoTwitterSite(...) | seo()->twitter->setSite(...) |
@seoTwitterCreator(...) | seo()->twitter->setCreator(...) |
The 2.x directives without the seo prefix, such as @title and @keyword, still work but are deprecated. They are removed in the first line that supports PHP 8.6.
#Custom Elements
Add an element, or replace a built-in one, in the elements option. An element extends SingleElement for one value or MultipleElement for a list. Its key is its name, its tag name, and the base of its directives.
1namespace App\SEO;2 3use Pharaonic\Laravel\SEO\Classes\Elements\SingleElement;4 5class ThemeColor extends SingleElement6{7 //8}1'default' => [2 'theme-color' => '#4f46e5',3],4 5'elements' => [6 'theme-color' => \App\SEO\ThemeColor::class,7],1<meta name="theme-color" content="#4f46e5">A SingleElement renders <meta name="{name}" content="{value}">. Override render() for another tag, and escape the value with e():
1class Manifest extends SingleElement2{3 public function render()4 {5 return $this->get() ? '<link rel="manifest" href="'.e($this->get()).'">' : '';6 }7}The element is read as seo()->{'theme-color'}, and gets the @seoThemeColor directive. A MultipleElement also gets a singular directive, such as @seoTheme for themes, which calls an add() method that you define.
A class in elements that extends neither base class throws an InvalidArgumentException when the manager is created.
#API Reference
#Manager
seo() returns Pharaonic\Laravel\SEO\Classes\Manager, one per request or queued job.
| Member | Description | Returns |
|---|---|---|
->{element} | The element, such as title, openGraph or twitter. Throws InvalidArgumentException when unknown | element |
model(Model $model) | Applies the data of a model that implements SEOContract | void |
render() | Every tag, one per line | string |
getDirectives() | The directive of every element, by name | array |
#Single Elements
charset, viewport, title, description, author, robots, copyright, next, prev, canonical, and custom SingleElement classes.
| Method | Description | Returns |
|---|---|---|
set(string $value) | Replaces the value | static |
get() | The value | ?string |
clear() | Removes the value | static |
getName() | The element name | string |
render() | The tag, or an empty string | string |
#List Elements
keywords, images, alternates, and custom MultipleElement classes.
| Method | Description | Returns |
|---|---|---|
set(array|string $value) | Replaces the list. A string is split on , | static |
add(string $value) | Appends to keywords or images | static |
add(string $lang, string $url) | Appends to alternates | static |
remove(int $index) | Removes an entry and reindexes the list | static |
get() | The list | ?array |
clear() | Removes the list | static |
getName() | The element name | string |
#Open Graph
| Method | Description | Returns |
|---|---|---|
setType(string $type) | og:type | static |
getType() | og:type | ?string |
setSiteName(string $siteName) | og:site_name | static |
getSiteName() | og:site_name | ?string |
render() | The Open Graph tags | string |
| Method | Description | Returns |
|---|---|---|
setCard(string $card) | twitter:card | static |
getCard() | twitter:card | ?string |
setSite(string $site) | twitter:site, prefixed with one @ | static |
getSite() | twitter:site | ?string |
setCreator(string $creator) | twitter:creator, prefixed with one @ | static |
getCreator() | twitter:creator | ?string |
render() | The Twitter card tags | string |
#SEOContract
| Method | Description | Returns |
|---|---|---|
seo() | Element name => value, plus open_graph and twitter => attribute => value | array |
#Examples
#1. Site-wide Defaults
Set what every page shares once, so pages only set what's their own.
1'default' => [ 2 'charset' => 'UTF-8', 3 'viewport' => 'width=device-width, initial-scale=1.0', 4 'robots' => 'index, follow', 5 6 'open_graph' => [ 7 'type' => 'website', 8 'site_name' => 'Pharaonic', 9 ],10 11 'twitter' => [12 'card' => 'summary_large_image',13 'site' => 'pharaonic',14 ],15],#2. A Blog Post from Its Model
1class Post extends Model implements SEOContract 2{ 3 public function seo(): array 4 { 5 return [ 6 'title' => $this->title.' | Blog', 7 'description' => $this->excerpt, 8 'images' => [$this->cover_url], 9 'canonical' => route('posts.show', $this),10 'open_graph' => ['type' => 'article'],11 ];12 }13}1public function show(Post $post)2{3 seo()->model($post);4 5 return view('posts.show', compact('post'));6}1<head>2 @seo3</head>#3. A Multilingual Page
List every translation of the page; the Open Graph card lists them as alternate locales too.
1foreach (['en', 'ar', 'fr'] as $locale) {2 seo()->alternates->add($locale, url("$locale/about"));3}#4. Hide a Page from Search Engines
1@seoRobots('noindex, nofollow')#5. Tags in a Queued Mail or Job
Each queued job starts with a fresh manager, so tags set by one job never show up in the next one.
1public function handle()2{3 seo()->title->set($this->page->title);4 5 Storage::put("previews/{$this->page->id}.html", view('preview')->render());6}#Troubleshooting
#Nothing is printed in the head
@seo prints only elements that have a value. Check that the layout contains @seo, and that the values are set before the layout renders: in a controller, or in a child view that extends the layout.
#A tag shows & or "
Values are HTML-escaped when they are rendered. Pass the plain text: Tom & Jerry, not Tom & Jerry.
#"Element ... not found."
The element name, or a key returned by a model's seo(), isn't a built-in or configured element. Use the names in Elements, open_graph or twitter, or add the element in Custom Elements.
#"Class ... must extends SingleElement or MultipleElement."
A class in the elements option doesn't extend one of the two base classes.
#My published configuration is ignored
The configuration file is config/pharaonic/seo.php. A file published by 2.x at config/Pharaonic/seo.php is still read, and wins over the new one: move its values and delete it.
#A directive name clashes with my application
Use the seo-prefixed directives. The unprefixed 2.x directives, such as @title, are deprecated aliases: a directive with the same name that your application registers in a service provider replaces the alias.
Contributors
Amazing people who made this package possible
This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve SEO!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.