├── src ├── Commands │ └── .gitkeep ├── Contracts │ └── Taggable.php ├── Twitter │ ├── Cards │ │ ├── SummaryLargeImage.php │ │ ├── Summary.php │ │ └── Card.php │ └── Image.php ├── Concerns │ └── HasSeo.php ├── Tags │ ├── Title.php │ ├── Tag.php │ ├── Script.php │ ├── Meta.php │ ├── Link.php │ └── TagVoid.php ├── OpenGraph │ ├── Verticals │ │ ├── Website.php │ │ ├── Profile.php │ │ ├── Book.php │ │ ├── Article.php │ │ └── Vertical.php │ ├── Audio.php │ ├── Locale.php │ ├── Video.php │ ├── Image.php │ └── OpenGraph.php ├── Facades │ └── SeoManager.php ├── SeoTags.php ├── helpers.php ├── Middleware │ └── SeoNoIndexMiddleware.php ├── Schemas │ ├── Schema.php │ └── WebPage.php ├── Traits │ └── DeepClone.php ├── SeoServiceProvider.php ├── Standard │ ├── Alternate.php │ └── Standard.php ├── SeoImage.php └── SeoManager.php ├── database ├── factories │ └── .gitkeep └── migrations │ └── .gitkeep ├── resources └── views │ └── .gitkeep ├── CHANGELOG.md ├── pint.json ├── LICENSE.md ├── composer.json ├── config └── seo.php └── README.md /src/Commands/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/factories/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-seo` will be documented in this file. 4 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "declare_strict_types": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Contracts/Taggable.php: -------------------------------------------------------------------------------- 1 | content = $content ? trim($content) : null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/OpenGraph/Verticals/Website.php: -------------------------------------------------------------------------------- 1 | escape ? e($this->content, false) : $this->content; 16 | 17 | return "<{$this->tag} {$this->toProperties()->join(' ')}>{$content}tag}>"; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/SeoTags.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class SeoTags extends Collection implements Htmlable 16 | { 17 | use DeepClone; 18 | 19 | public function toHtml(): string 20 | { 21 | return $this->map(fn (TagVoid $tag) => $tag->toHtml())->join("\n"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Tags/Script.php: -------------------------------------------------------------------------------- 1 | properties = Collection::make([ 20 | 'type' => $type, 21 | ])->filter(fn (?string $item) => ! blank($item)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | apply($value); 17 | } 18 | 19 | return $value; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/OpenGraph/Verticals/Profile.php: -------------------------------------------------------------------------------- 1 | noIndex(); 22 | 23 | return $next($request); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Tags/Meta.php: -------------------------------------------------------------------------------- 1 | properties = Collection::make([ 20 | 'name' => $name, 21 | 'property' => $property, 22 | 'content' => $content, 23 | 'charset' => $charset, 24 | ])->filter(fn (?string $item) => ! blank($item)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/OpenGraph/Verticals/Book.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class Schema extends Collection implements Taggable 17 | { 18 | use DeepClone; 19 | 20 | public function toTags(): SeoTags 21 | { 22 | return new SeoTags([ 23 | new Script( 24 | type: 'application/ld+json', 25 | content: $this->toJson(), 26 | ), 27 | ]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Traits/DeepClone.php: -------------------------------------------------------------------------------- 1 | $value) { 12 | if (is_object($value)) { 13 | $this->{$property} = clone $value; 14 | } 15 | 16 | if (is_array($value)) { 17 | $this->{$property} = array_map(function ($item) { 18 | if (is_object($item)) { 19 | return clone $item; 20 | } 21 | 22 | return $item; 23 | }, $value); 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/OpenGraph/Verticals/Article.php: -------------------------------------------------------------------------------- 1 | properties = Collection::make([ 22 | 'rel' => $rel, 23 | 'hreflang' => $hreflang, 24 | 'href' => $href, 25 | 'title' => $title, 26 | 'type' => $type, 27 | 'sizes' => $sizes, 28 | ])->filter(fn (?string $item) => ! blank($item)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SeoServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-seo') 21 | ->hasConfigFile() 22 | ->hasViews(); 23 | } 24 | 25 | public function registeringPackage(): void 26 | { 27 | $this->app->scoped(SeoManager::class, function () { 28 | return SeoManager::default(); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Twitter/Image.php: -------------------------------------------------------------------------------- 1 | url, 26 | ), 27 | ]); 28 | 29 | if ($this->alt) { 30 | $tags->push(new Meta( 31 | name: 'twitter:image:alt', 32 | content: $this->alt 33 | )); 34 | } 35 | 36 | return $tags; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Standard/Alternate.php: -------------------------------------------------------------------------------- 1 | hreflang; 26 | } 27 | 28 | public function toTags(): SeoTags 29 | { 30 | return new SeoTags([ 31 | new Link( 32 | rel: 'alternate', 33 | hreflang: $this->hreflang, 34 | href: $this->href, 35 | ), 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/OpenGraph/Audio.php: -------------------------------------------------------------------------------- 1 | url, 27 | ), 28 | ]); 29 | 30 | foreach (get_object_vars($this) as $property => $content) { 31 | if ($content !== null) { 32 | $tags->push(new Meta( 33 | property: "og:audio:{$property}", 34 | content: $content, 35 | )); 36 | } 37 | } 38 | 39 | return $tags; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/OpenGraph/Locale.php: -------------------------------------------------------------------------------- 1 | locale, 32 | ), 33 | ]); 34 | 35 | foreach ($this->alternate as $locale) { 36 | $tags->push(new Meta( 37 | property: 'og:locale:alternate', 38 | content: $locale 39 | )); 40 | } 41 | 42 | return $tags; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/SeoImage.php: -------------------------------------------------------------------------------- 1 | url, 25 | secure_url: $this->secure_url, 26 | type: $this->type, 27 | width: $this->width, 28 | height: $this->height, 29 | alt: $this->alt, 30 | ); 31 | } 32 | 33 | public function toTwitter(): TwitterImage 34 | { 35 | return new TwitterImage( 36 | url: $this->secure_url ?? $this->url, 37 | alt: $this->alt 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Tags/TagVoid.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public ?Collection $properties = null; 21 | 22 | /** 23 | * @return Collection 24 | */ 25 | public function toProperties(): Collection 26 | { 27 | if (! $this->properties) { 28 | return new Collection; 29 | } 30 | 31 | return $this->properties 32 | ->map(function (string $value, string $property) { 33 | $value = e(trim($value)); 34 | 35 | return "{$property}=\"{$value}\""; 36 | }); 37 | } 38 | 39 | public function toHtml(): string 40 | { 41 | return "<{$this->tag} {$this->toProperties()->join(' ')} />"; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Twitter/Cards/Summary.php: -------------------------------------------------------------------------------- 1 | url, 29 | ), 30 | ]); 31 | 32 | foreach (get_object_vars($this) as $property => $content) { 33 | if ($content !== null) { 34 | $tags->push(new Meta( 35 | property: "og:video:{$property}", 36 | content: $content, 37 | )); 38 | } 39 | } 40 | 41 | return $tags; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Elegantly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/OpenGraph/Image.php: -------------------------------------------------------------------------------- 1 | url, 30 | ), 31 | ]); 32 | 33 | foreach (get_object_vars($this) as $property => $content) { 34 | if ($content !== null) { 35 | $tags->push(new Meta( 36 | property: "og:image:{$property}", 37 | content: $content, 38 | )); 39 | } 40 | } 41 | 42 | return $tags; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Schemas/WebPage.php: -------------------------------------------------------------------------------- 1 | 'https://schema.org', 19 | '@type' => 'WebPage', 20 | 'name' => __(config('seo.defaults.title')), 21 | 'description' => __(config('seo.defaults.description')), 22 | 'image' => static::getImageFromConfig(), 23 | 'url' => Request::url(), 24 | ]); 25 | 26 | return $schema 27 | ->merge(config('seo.schema.webpage', [])) 28 | ->merge(array_filter([ 29 | 'name' => $title, 30 | 'description' => $description, 31 | 'image' => $image, 32 | 'url' => $url, 33 | ])) 34 | ->filter(); 35 | } 36 | 37 | public static function getImageFromConfig(): ?string 38 | { 39 | $url = config('seo.defaults.image.url') ?? config('seo.defaults.image'); 40 | 41 | if ($url) { 42 | return filter_var($url, FILTER_VALIDATE_URL) ? $url : asset($url); 43 | } 44 | 45 | return null; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Twitter/Cards/Card.php: -------------------------------------------------------------------------------- 1 | $content) { 22 | 23 | if ($content instanceof Taggable) { 24 | $tags->push(...$content->toTags()); 25 | } elseif (! blank($content)) { 26 | $tags->push(new Meta( 27 | name: "twitter:{$property}", 28 | content: $content 29 | )); 30 | } 31 | } 32 | 33 | return $tags; 34 | } 35 | 36 | public static function getImageFromConfig(): ?Image 37 | { 38 | $url = config('seo.twitter.image.url') ?? config('seo.defaults.image.url') ?? config('seo.defaults.image'); 39 | 40 | if ($url) { 41 | return new Image( 42 | url: filter_var($url, FILTER_VALIDATE_URL) ? $url : asset($url), 43 | alt: config('seo.twitter.image.alt') ?? config('seo.defaults.image.alt') 44 | ); 45 | } 46 | 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/OpenGraph/Verticals/Vertical.php: -------------------------------------------------------------------------------- 1 | getType(); 24 | } 25 | 26 | public function getTypeTag(): Meta 27 | { 28 | return new Meta( 29 | property: 'og:type', 30 | content: $this->getType(), 31 | ); 32 | } 33 | 34 | protected function formatContent(null|string|Carbon $value): ?string 35 | { 36 | if ($value === null) { 37 | return $value; 38 | } 39 | 40 | if ($value instanceof Carbon) { 41 | return $value->format(DateTime::ATOM); 42 | } 43 | 44 | return $value; 45 | } 46 | 47 | public function toTags(?string $prefix = null): SeoTags 48 | { 49 | $tags = new SeoTags; 50 | 51 | if ($prefix === null) { 52 | $tags->push($this->getTypeTag()); 53 | $prefix = $this->getNamespace(); 54 | } 55 | 56 | $properties = get_object_vars($this); 57 | 58 | foreach ($properties as $property => $content) { 59 | 60 | if ($content === null) { 61 | continue; 62 | } 63 | 64 | foreach (Arr::wrap($content) as $item) { 65 | 66 | if ($item instanceof Vertical) { 67 | $tags->push(...$item->toTags("{$prefix}:{$property}")); 68 | } else { 69 | $tags->push(new Meta( 70 | property: $prefix.':'.$property, 71 | content: $this->formatContent($item) 72 | )); 73 | } 74 | } 75 | } 76 | 77 | return $tags; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elegantly/laravel-seo", 3 | "description": "An Elegant & flexible SEO tag builder for Laravel", 4 | "keywords": [ 5 | "Elegantly", 6 | "laravel", 7 | "laravel-seo", 8 | "seo", 9 | "opengraph" 10 | ], 11 | "homepage": "https://github.com/ElegantEngineeringTech/laravel-seo", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Quentin Gabriele", 16 | "email": "quentin.gabriele@gmail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "spatie/laravel-package-tools": "^1.16", 23 | "illuminate/contracts": "^11.0||^12.0" 24 | }, 25 | "require-dev": { 26 | "laravel/pint": "^1.14", 27 | "nunomaduro/collision": "^8.1.1||^7.10.0", 28 | "larastan/larastan": "^2.9||^3.0", 29 | "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", 30 | "pestphp/pest": "^3.0", 31 | "pestphp/pest-plugin-arch": "^3.0", 32 | "pestphp/pest-plugin-laravel": "^3.0", 33 | "phpstan/extension-installer": "^1.3||^2.0", 34 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 35 | "phpstan/phpstan-phpunit": "^1.3||^2.0" 36 | }, 37 | "autoload": { 38 | "files": [ 39 | "src/helpers.php" 40 | ], 41 | "psr-4": { 42 | "Elegantly\\Seo\\": "src/", 43 | "Elegantly\\Seo\\Database\\Factories\\": "database/factories/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Elegantly\\Seo\\Tests\\": "tests/", 49 | "Workbench\\App\\": "workbench/app/" 50 | } 51 | }, 52 | "scripts": { 53 | "post-autoload-dump": "@composer run prepare", 54 | "clear": "@php vendor/bin/testbench package:purge-laravel-seo --ansi", 55 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 56 | "build": [ 57 | "@composer run prepare", 58 | "@php vendor/bin/testbench workbench:build --ansi" 59 | ], 60 | "start": [ 61 | "Composer\\Config::disableProcessTimeout", 62 | "@composer run build", 63 | "@php vendor/bin/testbench serve" 64 | ], 65 | "analyse": "vendor/bin/phpstan analyse", 66 | "test": "vendor/bin/pest", 67 | "test-coverage": "vendor/bin/pest --coverage", 68 | "format": "vendor/bin/pint" 69 | }, 70 | "config": { 71 | "sort-packages": true, 72 | "allow-plugins": { 73 | "pestphp/pest-plugin": true, 74 | "phpstan/extension-installer": true 75 | } 76 | }, 77 | "extra": { 78 | "laravel": { 79 | "providers": [ 80 | "Elegantly\\Seo\\SeoServiceProvider" 81 | ], 82 | "aliases": { 83 | "Seo": "Elegantly\\Seo\\Facades\\Seo" 84 | } 85 | } 86 | }, 87 | "minimum-stability": "dev", 88 | "prefer-stable": true 89 | } 90 | -------------------------------------------------------------------------------- /src/OpenGraph/OpenGraph.php: -------------------------------------------------------------------------------- 1 | $content) { 86 | 87 | if ($content instanceof Taggable) { 88 | $tags->push(...$content->toTags()); 89 | } elseif (! blank($content)) { 90 | $tags->push(new Meta( 91 | property: "og:{$property}", 92 | content: $content 93 | )); 94 | } 95 | } 96 | 97 | return $tags; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Standard/Standard.php: -------------------------------------------------------------------------------- 1 | $value) { 84 | if (blank($value)) { 85 | continue; 86 | } 87 | 88 | if ($key === 'title') { 89 | $tags->push(new Title( 90 | content: $value, 91 | )); 92 | } elseif ($key === 'sitemap') { 93 | $tags->push(new Link( 94 | rel: $key, 95 | href: $value, 96 | title: 'Sitemap', 97 | type: 'application/xml', 98 | )); 99 | } elseif ($key === 'canonical') { 100 | $tags->push(new Link( 101 | rel: $key, 102 | href: $value, 103 | )); 104 | } elseif ($key === 'alternates') { 105 | /** 106 | * @var Taggable[] $value 107 | */ 108 | foreach ($value as $item) { 109 | $tags->push(...$item->toTags()); 110 | } 111 | } else { 112 | $tags->push(new Meta( 113 | name: $key, 114 | content: is_array($value) ? implode(',', $value) : $value, 115 | )); 116 | } 117 | } 118 | 119 | return $tags; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /config/seo.php: -------------------------------------------------------------------------------- 1 | [ 8 | /* 9 | |-------------------------------------------------------------------------- 10 | | Default Title 11 | |-------------------------------------------------------------------------- 12 | | 13 | | This is the default value used for , "og:title", "twitter:title" 14 | | 15 | */ 16 | 'title' => env('APP_NAME', 'Laravel'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Default Description 21 | |-------------------------------------------------------------------------- 22 | | 23 | | This is the default value used for <meta name="description">, 24 | | <meta property="og:description">, <meta name="twitter:description"> 25 | | 26 | */ 27 | 'description' => null, 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Default Author 32 | |-------------------------------------------------------------------------- 33 | | 34 | | This is the default value used for <meta name="author"> 35 | | 36 | */ 37 | 'author' => null, 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Default Generator 42 | |-------------------------------------------------------------------------- 43 | | 44 | | This is the default value used for <meta name="generator"> 45 | | 46 | */ 47 | 'generator' => null, 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Default Keywords 52 | |-------------------------------------------------------------------------- 53 | | 54 | | This is the default value used for <meta name="keywords"> 55 | | Types supported: string or array of strings 56 | | 57 | */ 58 | 'keywords' => null, 59 | 60 | /* 61 | |-------------------------------------------------------------------------- 62 | | Default Referrer 63 | |-------------------------------------------------------------------------- 64 | | 65 | | This is the default value used for <meta name="referrer"> 66 | | 67 | */ 68 | 'referrer' => null, 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Default Theme color 73 | |-------------------------------------------------------------------------- 74 | | 75 | | This is the default value used for <meta name="theme-color"> 76 | | 77 | */ 78 | 'theme-color' => null, 79 | 80 | /* 81 | |-------------------------------------------------------------------------- 82 | | Default Color Scheme 83 | |-------------------------------------------------------------------------- 84 | | 85 | | This is the default value used for <meta name="color-scheme"> 86 | | 87 | */ 88 | 'color-scheme' => null, 89 | 90 | /* 91 | |-------------------------------------------------------------------------- 92 | | Default Image path 93 | |-------------------------------------------------------------------------- 94 | | 95 | | This is the default value used for <meta property="og:image">, <meta name="twitter:image"> 96 | | You can use relative path like "/opengraph.png" or url like "https://example.com/opengraph.png" 97 | | 98 | */ 99 | 'image' => null, 100 | 101 | /* 102 | |-------------------------------------------------------------------------- 103 | | Default Robots 104 | |-------------------------------------------------------------------------- 105 | | 106 | | This is the default value used for <meta name="robots"> 107 | | See Google documentation here: https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag?hl=fr#directives 108 | | 109 | */ 110 | 'robots' => 'max-snippet:-1,max-image-preview:large,max-video-preview:-1', 111 | 112 | /* 113 | |-------------------------------------------------------------------------- 114 | | Default Sitemap path 115 | |-------------------------------------------------------------------------- 116 | | 117 | | This is the default value used for <link rel="sitemap"> 118 | | You can use relative path like "/sitemap.xml" or url like "https://example.com/sitemap.xml" 119 | | 120 | */ 121 | 'sitemap' => null, 122 | ], 123 | 124 | /** 125 | * @see https://ogp.me/ 126 | */ 127 | 'opengraph' => [ 128 | /* 129 | |-------------------------------------------------------------------------- 130 | | Default Site Name 131 | |-------------------------------------------------------------------------- 132 | | 133 | | This is the default value used for <meta property="og:site_name" /> 134 | | If null: config('app.name') is used. 135 | | 136 | */ 137 | 'site_name' => null, 138 | 139 | /* 140 | |-------------------------------------------------------------------------- 141 | | Default Determiner 142 | |-------------------------------------------------------------------------- 143 | | 144 | | This is the default value used for <meta property="og:determiner" /> 145 | | Possible values are: a, an, the, "", auto 146 | | 147 | */ 148 | 'determiner' => '', 149 | ], 150 | 151 | /** 152 | * @see https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards 153 | */ 154 | 'twitter' => [ 155 | /* 156 | |-------------------------------------------------------------------------- 157 | | Default Twitter username 158 | |-------------------------------------------------------------------------- 159 | | 160 | | This is the default value used for <meta name="twitter:site" /> 161 | | Example: @X 162 | | 163 | */ 164 | 'site' => null, 165 | ], 166 | 167 | /** 168 | * @see https://schema.org/WebPage 169 | */ 170 | 'schema' => [ 171 | /* 172 | |-------------------------------------------------------------------------- 173 | | Default WebPage schema 174 | |-------------------------------------------------------------------------- 175 | | 176 | | This is the default value used for the schema WebPage 177 | | @see https://schema.org/WebPage for all available properties 178 | | 179 | */ 180 | 'webpage' => [], 181 | ], 182 | 183 | ]; 184 | -------------------------------------------------------------------------------- /src/SeoManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace Elegantly\Seo; 6 | 7 | use Closure; 8 | use Elegantly\Seo\Concerns\HasSeo; 9 | use Elegantly\Seo\Contracts\Taggable; 10 | use Elegantly\Seo\OpenGraph\Locale; 11 | use Elegantly\Seo\OpenGraph\OpenGraph; 12 | use Elegantly\Seo\Schemas\Schema; 13 | use Elegantly\Seo\Schemas\WebPage; 14 | use Elegantly\Seo\Standard\Alternate; 15 | use Elegantly\Seo\Standard\Standard; 16 | use Elegantly\Seo\Traits\DeepClone; 17 | use Elegantly\Seo\Twitter\Cards\Card; 18 | use Elegantly\Seo\Twitter\Cards\Summary; 19 | use Illuminate\Contracts\Support\Htmlable; 20 | use Illuminate\Support\Facades\App; 21 | use Illuminate\Support\Traits\Conditionable; 22 | use Stringable; 23 | 24 | class SeoManager implements Htmlable, Stringable, Taggable 25 | { 26 | use Conditionable; 27 | use DeepClone; 28 | 29 | /** 30 | * @param null|Schema[] $schemas 31 | */ 32 | public function __construct( 33 | public ?Standard $standard = null, 34 | public ?OpenGraph $opengraph = null, 35 | public ?Card $twitter = null, 36 | public ?WebPage $webpage = null, 37 | public ?array $schemas = null, 38 | public ?SeoTags $customTags = null, 39 | ) {} 40 | 41 | /** 42 | * @return $this 43 | */ 44 | public function current(): static 45 | { 46 | return $this; 47 | } 48 | 49 | public function apply(HasSeo $class): self 50 | { 51 | return $class->applySeo($this); 52 | } 53 | 54 | /** 55 | * @return $this 56 | */ 57 | public function set(SeoManager $manager): static 58 | { 59 | return $this 60 | ->setStandard($manager->standard) 61 | ->setOpengraph($manager->opengraph) 62 | ->setTwitter($manager->twitter) 63 | ->setWebpage($manager->webpage) 64 | ->setSchemas($manager->schemas) 65 | ->setCustomTags($manager->customTags); 66 | } 67 | 68 | /** 69 | * @param null|Standard|(Closure(Standard):(null|Standard)) $value 70 | * @return $this 71 | */ 72 | public function setStandard(null|Standard|Closure $value): static 73 | { 74 | if ($value instanceof Closure) { 75 | $this->standard = $value($this->standard ?? Standard::default()); 76 | } else { 77 | $this->standard = $value; 78 | } 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * @param null|OpenGraph|(Closure(OpenGraph):(null|OpenGraph)) $value 85 | * @return $this 86 | */ 87 | public function setOpengraph(null|OpenGraph|Closure $value): static 88 | { 89 | if ($value instanceof Closure) { 90 | $this->opengraph = $value($this->opengraph ?? OpenGraph::default()); 91 | } else { 92 | $this->opengraph = $value; 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * @param null|Card|(Closure(Card):(null|Card)) $value 100 | * @return $this 101 | */ 102 | public function setTwitter(null|Card|Closure $value): static 103 | { 104 | if ($value instanceof Closure) { 105 | $this->twitter = $value($this->twitter ?? Summary::default()); 106 | } else { 107 | $this->twitter = $value; 108 | } 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * @param null|WebPage|(Closure(WebPage):(null|WebPage)) $value 115 | * @return $this 116 | */ 117 | public function setWebpage(null|WebPage|Closure $value): static 118 | { 119 | if ($value instanceof Closure) { 120 | $this->webpage = $value($this->webpage ?? WebPage::default()); 121 | } else { 122 | $this->webpage = $value; 123 | } 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * @param null|Schema[]|(Closure(Schema[]):(null|Schema[])) $value 130 | * @return $this 131 | */ 132 | public function setSchemas(null|array|Closure $value): static 133 | { 134 | if ($value instanceof Closure) { 135 | $this->schemas = $value($this->schemas ?? []); 136 | } else { 137 | $this->schemas = $value; 138 | } 139 | 140 | return $this; 141 | } 142 | 143 | /** 144 | * @param null|SeoTags|(Closure(SeoTags):(null|SeoTags)) $value 145 | * @return $this 146 | */ 147 | public function setCustomTags(null|SeoTags|Closure $value): static 148 | { 149 | if ($value instanceof Closure) { 150 | $this->customTags = $value($this->customTags ?? new SeoTags); 151 | } else { 152 | $this->customTags = $value; 153 | } 154 | 155 | return $this; 156 | } 157 | 158 | /** 159 | * @return $this 160 | */ 161 | public function addSchema(Schema $value): static 162 | { 163 | return $this->setSchemas(function ($schemas) use ($value) { 164 | $schemas[] = $value; 165 | 166 | return $schemas; 167 | }); 168 | } 169 | 170 | public function setTitle(string $value): static 171 | { 172 | if ($this->standard) { 173 | $this->standard->title = $value; 174 | } 175 | if ($this->opengraph) { 176 | $this->opengraph->title = $value; 177 | } 178 | if ($this->webpage) { 179 | $this->webpage->put('name', $value); 180 | } 181 | if ($this->twitter instanceof Summary) { 182 | $this->twitter->title = $value; 183 | } 184 | 185 | return $this; 186 | } 187 | 188 | public function setDescription(?string $value): static 189 | { 190 | if ($this->standard) { 191 | $this->standard->description = $value; 192 | } 193 | if ($this->opengraph) { 194 | $this->opengraph->description = $value; 195 | } 196 | if ($this->webpage) { 197 | $this->webpage->put('description', $value); 198 | } 199 | if ($this->twitter instanceof Summary) { 200 | $this->twitter->description = $value; 201 | } 202 | 203 | return $this; 204 | } 205 | 206 | public function setUrl(string $value): static 207 | { 208 | if ($this->standard) { 209 | $this->standard->canonical = $value; 210 | } 211 | if ($this->opengraph) { 212 | $this->opengraph->url = $value; 213 | } 214 | if ($this->webpage) { 215 | $this->webpage->put('url', $value); 216 | } 217 | 218 | return $this; 219 | } 220 | 221 | public function setImage(null|SeoImage|string $value): static 222 | { 223 | $value = is_string($value) ? new SeoImage($value) : $value; 224 | 225 | if ($this->opengraph) { 226 | $this->opengraph->image = $value?->toOpenGraph(); 227 | } 228 | if ($this->twitter instanceof Summary) { 229 | $this->twitter->image = $value?->toTwitter(); 230 | } 231 | if ($this->webpage) { 232 | $this->webpage->put('image', $value->secure_url ?? $value?->url); 233 | } 234 | 235 | return $this; 236 | } 237 | 238 | /** 239 | * @return $this 240 | */ 241 | public function setRobots(?string $value): static 242 | { 243 | if ($this->standard) { 244 | $this->standard->robots = $value; 245 | } 246 | 247 | return $this; 248 | } 249 | 250 | /** 251 | * @return $this 252 | */ 253 | public function setSitmap(?string $value): static 254 | { 255 | if ($this->standard) { 256 | $this->standard->sitemap = $value; 257 | } 258 | 259 | return $this; 260 | } 261 | 262 | /** 263 | * @return $this 264 | */ 265 | public function setLocale( 266 | string $value, 267 | ): static { 268 | if ($this->opengraph) { 269 | $this->opengraph->locale = new Locale( 270 | locale: $value, 271 | alternate: $this->opengraph->locale->alternate ?? [], 272 | ); 273 | } 274 | 275 | return $this; 276 | } 277 | 278 | /** 279 | * @param null|Alternate[]|array<string, string> $value 280 | * @return $this 281 | */ 282 | public function setAlternates(?array $value): static 283 | { 284 | if ($value) { 285 | $value = collect($value) 286 | ->map(function ($href, $hrelang) { 287 | if ($href instanceof Alternate) { 288 | return $href; 289 | } 290 | 291 | return new Alternate((string) $hrelang, $href); 292 | }) 293 | ->values() 294 | ->all(); 295 | } 296 | 297 | if ($this->standard) { 298 | $this->standard->alternates = $value; 299 | } 300 | 301 | if ($this->opengraph) { 302 | $this->opengraph->locale = new Locale( 303 | locale: $this->opengraph->locale->locale ?? App::getLocale(), 304 | alternate: collect($value) 305 | ->where('hreflang', '!=', 'x-default') 306 | ->map(fn ($item) => $item->toOpenGraph()) 307 | ->toArray() 308 | ); 309 | } 310 | 311 | return $this; 312 | } 313 | 314 | /** 315 | * @param null|string|string[] $value 316 | * @return $this 317 | */ 318 | public function setKeywords(null|string|array $value): static 319 | { 320 | if ($this->standard) { 321 | $this->standard->keywords = $value; 322 | } 323 | 324 | return $this; 325 | } 326 | 327 | /** 328 | * @return $this 329 | */ 330 | public function setAuthor(?string $value): static 331 | { 332 | if ($this->standard) { 333 | $this->standard->author = $value; 334 | } 335 | 336 | return $this; 337 | } 338 | 339 | /** 340 | * @return $this 341 | */ 342 | public function noIndexNoFollow(): static 343 | { 344 | return $this->setRobots('noindex, nofollow'); 345 | } 346 | 347 | /** 348 | * @return $this 349 | */ 350 | public function noIndex(): static 351 | { 352 | return $this->setRobots('noindex'); 353 | } 354 | 355 | /** 356 | * @param null|string|string[] $keywords 357 | * @param null|Alternate[] $alternates 358 | */ 359 | public static function default( 360 | ?string $title = null, 361 | ?string $url = null, 362 | ?string $description = null, 363 | null|string|array $keywords = null, 364 | ?SeoImage $image = null, 365 | ?string $robots = null, 366 | ?string $sitemap = null, 367 | ?array $alternates = null, 368 | ): self { 369 | return new self( 370 | standard: Standard::default( 371 | title: $title, 372 | canonical: $url, 373 | description: $description, 374 | keywords: $keywords, 375 | robots: $robots, 376 | sitemap: $sitemap, 377 | alternates: $alternates 378 | ), 379 | opengraph: OpenGraph::default( 380 | title: $title, 381 | url: $url, 382 | description: $description, 383 | image: $image?->toOpenGraph(), 384 | ), 385 | twitter: Summary::default( 386 | title: $title, 387 | description: $description, 388 | image: $image?->toTwitter(), 389 | ), 390 | webpage: WebPage::default( 391 | title: $title, 392 | url: $url, 393 | description: $description, 394 | image: $image->secure_url ?? $image?->url, 395 | ), 396 | ); 397 | } 398 | 399 | public function toTags(): SeoTags 400 | { 401 | $tags = new SeoTags; 402 | 403 | if ($this->standard) { 404 | $tags->push(...$this->standard->toTags()); 405 | } 406 | if ($this->opengraph) { 407 | $tags->push(...$this->opengraph->toTags()); 408 | } 409 | if ($this->twitter) { 410 | $tags->push(...$this->twitter->toTags()); 411 | } 412 | 413 | if ($this->webpage) { 414 | $tags->push(...$this->webpage->toTags()); 415 | } 416 | 417 | if ($this->schemas) { 418 | foreach ($this->schemas as $schema) { 419 | $tags->push(...$schema->toTags()); 420 | } 421 | } 422 | if ($this->customTags) { 423 | $tags->push(...$this->customTags); 424 | } 425 | 426 | return $tags; 427 | } 428 | 429 | public function toHtml(): string 430 | { 431 | return $this->toTags()->toHtml(); 432 | } 433 | 434 | public function __toString(): string 435 | { 436 | return $this->toHtml(); 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An Elegant & Flexible SEO Tag Builder for Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/elegantly/laravel-seo.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-seo) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-seo/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-seo/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-seo/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-seo/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/elegantly/laravel-seo.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-seo) 7 | 8 | ![laravel-seo](https://repository-images.githubusercontent.com/845966143/6ff7437c-852d-41eb-8b2f-927551506a13) 9 | 10 | ## Introduction 11 | 12 | `laravel-seo` is a flexible and powerful package for managing SEO tags in Laravel applications. 13 | 14 | With this package, you can easily handle: 15 | 16 | - Standard HTML tags like `<title>` and `<meta name="robots">` 17 | - Localization with alternate tags ([Google SEO Localization](https://developers.google.com/search/docs/specialty/international/localized-versions)) 18 | - [Open Graph tags](https://ogp.me/) with structured properties, arrays, and object types 19 | - [Twitter (X) tags](https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards) 20 | - [Structured data (JSON-LD)](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) 21 | 22 | ## Installation 23 | 24 | You can install the package via Composer: 25 | 26 | ```bash 27 | composer require elegantly/laravel-seo 28 | ``` 29 | 30 | Next, publish the config file: 31 | 32 | ```bash 33 | php artisan vendor:publish --tag="seo-config" 34 | ``` 35 | 36 | ### Config File Overview 37 | 38 | The configuration file (`config/seo.php`) allows you to customize the default SEO behavior for your application. Here's a snippet with all available settings: 39 | 40 | ```php 41 | return [ 42 | 43 | 'defaults' => [ 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Default Title 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This is the default value used for <title>, "og:title", "twitter:title" 50 | | 51 | */ 52 | 'title' => env('APP_NAME', 'Laravel'), 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Default Description 57 | |-------------------------------------------------------------------------- 58 | | 59 | | This is the default value used for <meta name="description">, 60 | | <meta property="og:description">, <meta name="twitter:description"> 61 | | 62 | */ 63 | 'description' => null, 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | Default Author 68 | |-------------------------------------------------------------------------- 69 | | 70 | | This is the default value used for <meta name="author"> 71 | | 72 | */ 73 | 'author' => null, 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Default Generator 78 | |-------------------------------------------------------------------------- 79 | | 80 | | This is the default value used for <meta name="generator"> 81 | | 82 | */ 83 | 'generator' => null, 84 | 85 | /* 86 | |-------------------------------------------------------------------------- 87 | | Default Keywords 88 | |-------------------------------------------------------------------------- 89 | | 90 | | This is the default value used for <meta name="keywords"> 91 | | Types supported: string or array of strings 92 | | 93 | */ 94 | 'keywords' => null, 95 | 96 | /* 97 | |-------------------------------------------------------------------------- 98 | | Default Referrer 99 | |-------------------------------------------------------------------------- 100 | | 101 | | This is the default value used for <meta name="referrer"> 102 | | 103 | */ 104 | 'referrer' => null, 105 | 106 | /* 107 | |-------------------------------------------------------------------------- 108 | | Default Theme color 109 | |-------------------------------------------------------------------------- 110 | | 111 | | This is the default value used for <meta name="theme-color"> 112 | | 113 | */ 114 | 'theme-color' => null, 115 | 116 | /* 117 | |-------------------------------------------------------------------------- 118 | | Default Color Scheme 119 | |-------------------------------------------------------------------------- 120 | | 121 | | This is the default value used for <meta name="color-scheme"> 122 | | 123 | */ 124 | 'color-scheme' => null, 125 | 126 | /* 127 | |-------------------------------------------------------------------------- 128 | | Default Image path 129 | |-------------------------------------------------------------------------- 130 | | 131 | | This is the default value used for <meta property="og:image">, <meta name="twitter:image"> 132 | | You can use relative path like "/opengraph.png" or url like "https://example.com/opengraph.png" 133 | | 134 | */ 135 | 'image' => null, 136 | 137 | /* 138 | |-------------------------------------------------------------------------- 139 | | Default Robots 140 | |-------------------------------------------------------------------------- 141 | | 142 | | This is the default value used for <meta name="robots"> 143 | | See Google documentation here: https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag?hl=fr#directives 144 | | 145 | */ 146 | 'robots' => 'max-snippet:-1,max-image-preview:large,max-video-preview:-1', 147 | 148 | /* 149 | |-------------------------------------------------------------------------- 150 | | Default Sitemap path 151 | |-------------------------------------------------------------------------- 152 | | 153 | | This is the default value used for <link rel="sitemap"> 154 | | You can use relative path like "/sitemap.xml" or url like "https://example.com/sitemap.xml" 155 | | 156 | */ 157 | 'sitemap' => null, 158 | ], 159 | 160 | /** 161 | * @see https://ogp.me/ 162 | */ 163 | 'opengraph' => [ 164 | /* 165 | |-------------------------------------------------------------------------- 166 | | Default Site Name 167 | |-------------------------------------------------------------------------- 168 | | 169 | | This is the default value used for <meta property="og:site_name" /> 170 | | If null: config('app.name') is used. 171 | | 172 | */ 173 | 'site_name' => null, 174 | 175 | /* 176 | |-------------------------------------------------------------------------- 177 | | Default Determiner 178 | |-------------------------------------------------------------------------- 179 | | 180 | | This is the default value used for <meta property="og:determiner" /> 181 | | Possible values are: a, an, the, "", auto 182 | | 183 | */ 184 | 'determiner' => '', 185 | ], 186 | 187 | /** 188 | * @see https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards 189 | */ 190 | 'twitter' => [ 191 | /* 192 | |-------------------------------------------------------------------------- 193 | | Default Twitter username 194 | |-------------------------------------------------------------------------- 195 | | 196 | | This is the default value used for <meta name="twitter:site" /> 197 | | Example: @X 198 | | 199 | */ 200 | 'site' => null, 201 | ], 202 | 203 | /** 204 | * @see https://schema.org/WebPage 205 | */ 206 | 'schema' => [ 207 | /* 208 | |-------------------------------------------------------------------------- 209 | | Default WebPage schema 210 | |-------------------------------------------------------------------------- 211 | | 212 | | This is the default value used for the schema WebPage 213 | | @see https://schema.org/WebPage for all available properties 214 | | 215 | */ 216 | 'webpage' => [], 217 | ], 218 | 219 | ]; 220 | ``` 221 | 222 | ## Usage 223 | 224 | ### Displaying SEO Tags 225 | 226 | You can easily render all SEO tags in your Blade views by calling the `seo()` helper function: 227 | 228 | ```php 229 | <head> 230 | {!! seo() !!} 231 | </head> 232 | ``` 233 | 234 | This will render all the default tags, for example: 235 | 236 | ```html 237 | <title>Home 238 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 261 | ``` 262 | 263 | ### Setting SEO Tags in Controllers 264 | 265 | You will typically want to define your SEO tags dynamically in your controllers. You can do this using either the `seo()` helper or the `SeoManager` facade: 266 | 267 | ```php 268 | namespace App\Http\Controllers; 269 | 270 | use \Elegantly\Seo\Facades\SeoManager; 271 | use Elegantly\Seo\Standard\Alternate; 272 | 273 | class HomeController extends Controller 274 | { 275 | public function __invoke() 276 | { 277 | // Using the helper 278 | seo() 279 | ->setTitle("Homepage") 280 | ->setImage(asset('images/opengraph.jpg')) 281 | ->setDescription("The homepage description") 282 | ->when(!App::isProduction(), fn($seo) => $seo->noIndex()) 283 | ->setLocale("fr") 284 | ->setAlternates([ 285 | new Alternate( 286 | hreflang: "en", 287 | href: route('home', ['locale' => "en"]), 288 | ), 289 | new Alternate( 290 | hreflang: "fr", 291 | href: route('home', ['locale' => "fr"]), 292 | ), 293 | ]) 294 | ->setOpengraph(function(OpenGraph $opengraph){ 295 | $opengraph->title = "Custom opengraph title"; 296 | return $opengraph; 297 | }); 298 | 299 | // Using the facade 300 | SeoManager::current() 301 | ->setTitle("Homepage") 302 | ->setDescription("The homepage description"); 303 | // ... 304 | 305 | return view('home'); 306 | } 307 | } 308 | ``` 309 | 310 | Then, in your Blade view, just render the tags like this: 311 | 312 | ```html 313 | 314 | {!! seo() !!} 315 | 316 | ``` 317 | 318 | ### Advanced Usage 319 | 320 | For more complex SEO needs, you can instantiate and configure the `SeoManager` class directly. This gives you full control over every tag, including Open Graph, Twitter, JSON-LD, and custom schema tags. 321 | 322 | ```php 323 | use Elegantly\Seo\SeoManager; 324 | use Elegantly\Seo\Standard\Standard; 325 | use Elegantly\Seo\OpenGraph\OpenGraph; 326 | use Elegantly\Seo\Twitter\Cards\Card; 327 | use Elegantly\Seo\Schemas\Schema; 328 | use Elegantly\Seo\SeoTags; 329 | 330 | $seo = new SeoManager( 331 | standard: new Standard(/* ... */), 332 | opengraph: new OpenGraph(/* ... */), 333 | twitter: new Card(/* ... */), 334 | webpage: new WebPage(/* ... */), 335 | schemas: [/* ... */], 336 | customTags: new SeoTags(/* ... */) 337 | ); 338 | ``` 339 | 340 | Then, in your Blade view: 341 | 342 | ```html 343 | 344 | {!! $seo !!} 345 | 346 | ``` 347 | 348 | ## Testing 349 | 350 | To run the tests: 351 | 352 | ```bash 353 | composer test 354 | ``` 355 | 356 | ## Changelog 357 | 358 | Please see [CHANGELOG](CHANGELOG.md) for details on recent updates. 359 | 360 | ## Contributing 361 | 362 | See [CONTRIBUTING](CONTRIBUTING.md) for guidelines on contributing to this project. 363 | 364 | ## Security 365 | 366 | Please refer to our [security policy](../../security/policy) for reporting vulnerabilities. 367 | 368 | ## Credits 369 | 370 | - [Quentin Gabriele](https://github.com/QuentinGab) 371 | - [All Contributors](../../contributors) 372 | 373 | ## License 374 | 375 | This package is licensed under the [MIT License](LICENSE.md). 376 | --------------------------------------------------------------------------------