Laravel Packagev7.0.0MIT License

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.

Quick Tip

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.0
  • Laravel 7.30 or later 7.x releases

#Composer Installation

Terminal
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:

Terminal
php artisan vendor:publish --tag=seo-config

#Render the Tags

Print every tag inside the <head> of your layout:

resources/views/layouts/app.blade.php
1<head>
2 @seo
3</head>
Installation Complete

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.0 and Laravel 7.30+.

#Renamed APIs

2.xNow
@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 modelsopen_graph
The copyrights element, key and @copyrights directivecopyright, @seoCopyright
The config and seo publish tagsseo-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.

Deprecated Names

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 &amp;, pass the plain text instead.
  • Open Graph tags use the property attribute: <meta property="og:title" ...>.
  • The copyright tag is <meta name="copyright"> instead of copyrights.
  • The Twitter card renders one twitter:image, the first image, instead of twitter:image0, twitter:image1 and so on.
  • Twitter usernames always get a single @, so setSite('@site') no longer renders @@site.

#Fixed Behavior

  • seo()->model() sets the Open Graph values and skips null values.
  • getType(), getSiteName(), getCard(), getSite() and getCreator() return null when nothing is set.
  • remove() reindexes the list.
  • Errors are InvalidArgumentException, which still extends Exception.
  • 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.

config/pharaonic/seo.php
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.

config/pharaonic/seo.php
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],
KeyDescription
default.<element>Starting value of an element, such as title or robots
default.open_graph.typeog:type
default.open_graph.site_nameog:site_name
default.twitter.cardtwitter:card
default.twitter.sitetwitter:site, with or without the @
default.twitter.creatortwitter:creator, with or without the @
elementsExtra 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.

app/Http/Controllers/HomeController.php
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:

resources/views/layouts/app.blade.php
1<head>
2 @seo
3</head>
Output
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(); // null
Render to a String

seo()->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.

ElementRenders
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
imagesNo 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 list
3seo()->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'));
Output
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.

Escaping

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:

TagValue
og:urlThe current URL
og:localeThe application locale
og:locale:alternateEach alternates language, with dots replaced by underscores
og:typesetType(), website by default
og:site_namesetSiteName()
og:titleThe title element
og:descriptionThe description element
og:imageEach entry of images
1seo()->openGraph->setType('article')->setSiteName('Pharaonic');
2seo()->openGraph->getType(); // "article"

#Twitter

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.

app/Post.php
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:

app/Http/Controllers/PostController.php
1public function show(Post $post)
2{
3 seo()->model($post);
4 
5 return view('posts.show', compact('post'));
6}
  • A null value is skipped, so the element keeps its default.
  • Numbers are turned into strings.
  • Card attributes call the matching setter: site_name calls setSiteName().
  • An unknown key, or a model that doesn't implement SEOContract, throws an InvalidArgumentException.

#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.

resources/views/posts/show.blade.php
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@endsection

A child view runs before its layout, so values set in the view are ready when @seo renders in the layout's <head>.

DirectiveCalls
@seoPrints 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(...)
Deprecated Directives

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.

app/SEO/ThemeColor.php
1namespace App\SEO;
2 
3use Pharaonic\Laravel\SEO\Classes\Elements\SingleElement;
4 
5class ThemeColor extends SingleElement
6{
7 //
8}
config/pharaonic/seo.php
1'default' => [
2 'theme-color' => '#4f46e5',
3],
4 
5'elements' => [
6 'theme-color' => \App\SEO\ThemeColor::class,
7],
Output
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():

app/SEO/Manifest.php
1class Manifest extends SingleElement
2{
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.

Errors

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.

MemberDescriptionReturns
->{element}The element, such as title, openGraph or twitter. Throws InvalidArgumentException when unknownelement
model(Model $model)Applies the data of a model that implements SEOContractvoid
render()Every tag, one per linestring
getDirectives()The directive of every element, by namearray

#Single Elements

charset, viewport, title, description, author, robots, copyright, next, prev, canonical, and custom SingleElement classes.

MethodDescriptionReturns
set(string $value)Replaces the valuestatic
get()The value?string
clear()Removes the valuestatic
getName()The element namestring
render()The tag, or an empty stringstring

#List Elements

keywords, images, alternates, and custom MultipleElement classes.

MethodDescriptionReturns
set(array|string $value)Replaces the list. A string is split on ,static
add(string $value)Appends to keywords or imagesstatic
add(string $lang, string $url)Appends to alternatesstatic
remove(int $index)Removes an entry and reindexes the liststatic
get()The list?array
clear()Removes the liststatic
getName()The element namestring

#Open Graph

MethodDescriptionReturns
setType(string $type)og:typestatic
getType()og:type?string
setSiteName(string $siteName)og:site_namestatic
getSiteName()og:site_name?string
render()The Open Graph tagsstring

#Twitter

MethodDescriptionReturns
setCard(string $card)twitter:cardstatic
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 tagsstring

#SEOContract

MethodDescriptionReturns
seo()Element name => value, plus open_graph and twitter => attribute => valuearray

#Examples

#1. Site-wide Defaults

Set what every page shares once, so pages only set what's their own.

config/pharaonic/seo.php
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

app/Post.php
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}
app/Http/Controllers/PostController.php
1public function show(Post $post)
2{
3 seo()->model($post);
4 
5 return view('posts.show', compact('post'));
6}
resources/views/layouts/app.blade.php
1<head>
2 @seo
3</head>

#3. A Multilingual Page

List every translation of the page; the Open Graph card lists them as alternate locales too.

app/Http/Controllers/AboutController.php
1foreach (['en', 'ar', 'fr'] as $locale) {
2 seo()->alternates->add($locale, url("$locale/about"));
3}

#4. Hide a Page from Search Engines

resources/views/account/settings.blade.php
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.

app/Jobs/RenderPreview.php
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 &amp; or &quot;

Values are HTML-escaped when they are rendered. Pass the plain text: Tom & Jerry, not Tom &amp; 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!

Moamen Eltouny

@MoamenEltouny

51 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.