├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── seo.php ├── database ├── factories │ └── ModelFactory.php └── migrations │ └── create_seo_table.php.stub ├── pint.json ├── resources └── views │ ├── .gitkeep │ └── tags │ └── tag.blade.php ├── src ├── Facades │ └── SEOManager.php ├── LaravelSEOServiceProvider.php ├── Models │ └── SEO.php ├── SEOManager.php ├── Schema │ ├── ArticleSchema.php │ ├── BreadcrumbListSchema.php │ ├── CustomSchema.php │ ├── CustomSchemaFluent.php │ └── FaqPageSchema.php ├── SchemaCollection.php ├── Support │ ├── AlternateTag.php │ ├── HasSEO.php │ ├── ImageMeta.php │ ├── LinkTag.php │ ├── MetaContentTag.php │ ├── MetaTag.php │ ├── OpenGraphTag.php │ ├── RenderableCollection.php │ ├── SEOData.php │ ├── SchemaTagCollection.php │ ├── SitemapTag.php │ ├── Tag.php │ └── TwitterCardTag.php ├── TagCollection.php ├── TagManager.php ├── Tags │ ├── AlternateTags.php │ ├── AuthorTag.php │ ├── CanonicalTag.php │ ├── DescriptionTag.php │ ├── FaviconTag.php │ ├── ImageTag.php │ ├── OpenGraphTags.php │ ├── RobotsTag.php │ ├── SitemapTag.php │ ├── TitleTag.php │ ├── TwitterCard │ │ ├── Summary.php │ │ └── SummaryLargeImage.php │ └── TwitterCardTags.php └── helpers.php └── todo /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-seo` will be documented in this file. 4 | 5 | ## 1.7.0 - 2025-02-25 6 | 7 | - Feat: Laravel 12 support 8 | 9 | ## 1.6.7 - 2025-01-22 10 | 11 | - Fix: prevent Livewire from injecting morph markers into tag view. 12 | 13 | ## 1.6.6 - 2025-01-22 14 | 15 | - Fix: incorrect Blade closing `@unless` tag. 16 | 17 | ## 1.6.5 - 2025-01-20 18 | 19 | - Feat: automatic detection of Inertia routes and addition of `inertia` attribute to `` tag in #89by @touqeershafi. 20 | 21 | ## 1.6.4 - 2024-12-06 22 | 23 | - Fix: do not check for image file existence if path is url. 24 | 25 | ## 1.6.3 - 2024-08-30 26 | 27 | - Fix: potential preventAccessingMissingAttributes() exception if someone had old database migration. 28 | 29 | ## 1.6.2 - 2024-06-15 30 | 31 | - Fix: do also not escape image URLs for OpenGraph and Twitter. 32 | 33 | ## 1.6.1 - 2024-06-15 34 | 35 | - Fix: duplicate Twitter cards issue (#83). 36 | - Fix: image URL escaping (#84). 37 | 38 | ## 1.6.0 - 2024-06-15 39 | 40 | - Feat: add support for alternate links in (#78) 41 | - Feat: fluent support for FaqPage schema (#77) 42 | - Feat: refactor JSON+LD schema & allow any type of custom schema (#81) 43 | 44 | ## 1.5.1 - 2024-05-04 45 | 46 | - Fix: inconsistency with escaping. 47 | 48 | ## 1.5.0 - 2024-03-14 49 | 50 | - Laravel 11 compatibility. 51 | 52 | ## 1.4.5 - 2024-02-29 53 | 54 | - Update: make article `datePublished` and `dateModified` optional, since it's only recommended properties. 55 | 56 | ## 1.4.4 - 2024-02-07 57 | 58 | - Fix: issue with Livewire morph markers. 59 | 60 | ## 1.4.3 - 2023-12-20 61 | 62 | - Allow overriding custom OpenGraph titles in #53. 63 | 64 | ## 1.4.2 - 2023-10-30 65 | 66 | - Fix locale casing in cases of locales like `en_US`. 67 | 68 | ## 1.4.1 - 2023-08-12 69 | 70 | - Add support for immutable timestamps in #43 by @standaniels 71 | 72 | ## 1.3.0 - 2023-02-17 73 | 74 | - Add Laravel 10 support in #30 75 | 76 | ## 1.2.2 - 2022-11-21 77 | 78 | - Add `down()` method to migration. 79 | 80 | ## 1.2.1 - 2022-10-06 81 | 82 | – Fix issue with incorrect key in ArticleSchema. 83 | 84 | ## 1.2.0 - 2022-09-27 85 | 86 | – Add full support for robots tags. 87 | 88 | ## 1.1.0 - 2022-09-19 89 | 90 | – Support passing SEOData directly from the controller to the layout file. 91 | 92 | ## 1.0.4 - 2022-06-01 93 | 94 | – Fix: remove accidental `dump()`. 95 | 96 | ## 1.0.3 - 2022-06-01 97 | 98 | – Feat: support use of models without the related SEO-model in the database (#5). 99 | 100 | ## 1.0.2 - 2022-06-01 101 | 102 | – Fix: OpenGraph specification 103 | – Fix: using the ->imageMeta with a custom override URL. 104 | 105 | ## 1.0.1 - 2022-05-24 106 | 107 | – Fix incorrect import #9. 108 | 109 | ## 0.7.0 - 2022-04-15 110 | 111 | - Dynamic SEO model. 112 | 113 | ## 0.6.1 - 2022-04-06 114 | 115 | - Fallback for models without ->seo. 116 | 117 | ## 0.6.0 - 2022-03-16 118 | 119 | - Add support for sitemap tags. 120 | 121 | ## 0.5.3 - 2022-03-09 122 | 123 | - Add support for canonical URLs. 124 | - Refactor `$SEOData->url` resolution if we should get it from the current url. 125 | 126 | ## 0.5.2 - 2022-03-09 127 | 128 | - Add support for image sizes on Twitter cards 129 | 130 | ## 0.5.1 - 2022-03-09 131 | 132 | - Fix case where image size wasn't retrieved when it could be retrieved 133 | 134 | ## 0.5.0 - 2022-03-09 135 | 136 | - Update implementation for handling of image paths: we now only accept public paths. 137 | 138 | ## 0.4.0 - 2022-03-04 139 | 140 | - Add support for automatic `og:locale` 141 | 142 | ## 0.3.1 - 2022-02-17 143 | 144 | - Use `https` for `@context` reference to schema.org. 145 | 146 | ## 0.3.0 - 2022-02-10 147 | 148 | - Feat: separate title for the homepage. 149 | 150 | ## 0.2.0 - 2022-02-08 151 | 152 | - Add articleBody 153 | 154 | ## 0.1.3 - 2022-02-08 155 | 156 | - Fix migration name 157 | 158 | ## 0.1.2 - 2022-02-08 159 | 160 | - Fix service provider name 161 | 162 | ## 0.1.1 - 2022-02-08 163 | 164 | - Fix service provider namespace 165 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) ralphjsmit <rjs@ralphjsmit.com> 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![laravel-seo](https://github.com/ralphjsmit/laravel-seo/blob/main/docs/images/seo.jpg) 2 | 3 | # Never worry about SEO in Laravel again! 4 | 5 | Currently there aren't that many SEO-packages for Laravel and the available ones are quite complex to set up and very decoupled from the database. They only provided you with helpers to generate the tags, but you still had to use those helpers: nothing was generated automatically and they almost do not work out of the box. 6 | 7 | This package generates **valid and useful meta tags straight out-of-the-box**, with limited initial configuration, while still providing a simple, but powerful API to work with. It can generate: 8 | 9 | 1. Title tag (with sitewide suffix) 10 | 2. Meta tags (author, description, image, robots, etc.) 11 | 3. OpenGraph Tags (Facebook, LinkedIn, etc.) 12 | 4. Twitter Tags 13 | 5. Structured data (Article, Breadcrumbs, FAQPage, or any custom schema) 14 | 6. Favicon 15 | 7. Robots tag 16 | 8. Alternates links tag 17 | 18 | If you're familiar with Spatie's media-library package, this package works in almost the same way, but then only for SEO. I'm sure it will be very helpful for you, as it's usually best for a website to have attention for SEO right from the beginning. 19 | 20 | Here are a few examples of what you can do: 21 | 22 | ```php 23 | $post = Post::find(1); 24 | 25 | $post->seo->update([ 26 | 'title' => 'My great post', 27 | 'description' => 'This great post will enhance your live.', 28 | ]); 29 | ``` 30 | 31 | It will render the SEO tags directly on your page: 32 | 33 | ```blade 34 | <!DOCTYPE html> 35 | <html> 36 | <head> 37 | {!! seo()->for($post) !!} 38 | 39 | {{-- No need to separately render a <title> tag or any other meta tags! --}} 40 | </head> 41 | ``` 42 | 43 | It even allows you to **dynamically retrieve SEO data from your model**, without having to save it manually to the SEO model. The below code will require zero additional work from you or from your users: 44 | 45 | ```php 46 | class Post extends Model 47 | { 48 | use HasSEO; 49 | 50 | public function getDynamicSEOData(): SEOData 51 | { 52 | $pathToFeaturedImageRelativeToPublicPath = // ..; 53 | 54 | // Override only the properties you want: 55 | return new SEOData( 56 | title: $this->title, 57 | description: $this->excerpt, 58 | image: $pathToFeaturedImageRelativeToPublicPath, 59 | ); 60 | } 61 | } 62 | ``` 63 | 64 | ## Installation 65 | 66 | Run the following command to install the package: 67 | 68 | ```shell 69 | composer require ralphjsmit/laravel-seo 70 | ``` 71 | 72 | Publish the migration and configuration file: 73 | 74 | ```sh 75 | php artisan vendor:publish --tag="seo-migrations" 76 | php artisan vendor:publish --tag="seo-config" 77 | ``` 78 | 79 | Next, go to the newly published config file in `config/seo.php` and make sure that all the settings are correct. Those settings are all sort of default values: 80 | 81 | ```php 82 | <?php 83 | 84 | return [ 85 | /** 86 | * Use this setting to specify the site name that will be used in OpenGraph tags. 87 | */ 88 | 'site_name' => null, 89 | 90 | /** 91 | * Use this setting to specify the path to the sitemap of your website. This exact path will outputted, so 92 | * you can use both a hardcoded url and a relative path. We recommend the latter. 93 | * 94 | * Example: '/storage/sitemap.xml' 95 | * Do not forget the slash at the start. This will tell the search engine that the path is relative 96 | * to the root domain and not relative to the current URL. The `spatie/laravel-sitemap` package 97 | * is a great package to generate sitemaps for your application. 98 | */ 99 | 'sitemap' => null, 100 | 101 | /** 102 | * Use this setting to specify whether you want self-referencing `<link rel="canonical" href="$url">` tags to 103 | * be added to the head of every page. There has been some debate whether this is a good practice, but experts 104 | * from Google and Yoast say that this is the best strategy. 105 | * See https://yoast.com/rel-canonical/. 106 | */ 107 | 'canonical_link' => true, 108 | 109 | 'robots' => [ 110 | /** 111 | * Use this setting to specify the default value of the robots meta tag. `<meta name="robots" content="noindex">` 112 | * Overwrite it with the robots attribute of the SEOData object. `SEOData->robots = 'noindex, nofollow'` 113 | * "max-snippet:-1" Use n chars (-1: Search engine chooses) as a search result snippet. 114 | * "max-image-preview:large" Max size of a preview in search results. 115 | * "max-video-preview:-1" Use max seconds (-1: There is no limit) as a video snippet in search results. 116 | * See https://developers.google.com/search/docs/advanced/robots/robots_meta_tag 117 | * Default: 'max-snippet:-1, max-image-preview:large, max-video-preview:-1' 118 | */ 119 | 'default' => 'max-snippet:-1,max-image-preview:large,max-video-preview:-1', 120 | 121 | /** 122 | * Force set the robots `default` value and make it impossible to overwrite it. (e.g. via SEOData->robots) 123 | * Use case: You need to set `noindex, nofollow` for the entire website without exception. 124 | * Default: false 125 | */ 126 | 'force_default' => false, 127 | ], 128 | 129 | /** 130 | * Use this setting to specify the path to the favicon for your website. The url to it will be generated using the `secure_url()` function, 131 | * so make sure to make the favicon accessible from the `public` folder. 132 | * 133 | * You can use the following filetypes: ico, png, gif, jpeg, svg. 134 | */ 135 | 'favicon' => null, 136 | 137 | 'title' => [ 138 | /** 139 | * Use this setting to let the package automatically infer a title from the url, if no other title 140 | * was given. This will be very useful on pages where you don't have an Eloquent model for, or where you 141 | * don't want to hardcode the title. 142 | * 143 | * For example, if you have an url with the path '/foo/about-me', we'll automatically set the title to 'About me' and append the site suffix. 144 | */ 145 | 'infer_title_from_url' => true, 146 | 147 | /** 148 | * Use this setting to provide a suffix that will be added after the title on each page. 149 | * If you don't want a suffix, you should specify an empty string. 150 | */ 151 | 'suffix' => '', 152 | 153 | /** 154 | * Use this setting to provide a custom title for the homepage. We will not use the suffix on the homepage, 155 | * so you'll need to add the suffix manually if you want that. If set to null, we'll determine the title 156 | * just like the other pages. 157 | */ 158 | 'homepage_title' => null, 159 | ], 160 | 161 | 'description' => [ 162 | /** 163 | * Use this setting to specify a fallback description, which will be used on places 164 | * where we don't have a description set via an associated ->seo model or via 165 | * the ->getDynamicSEOData() method. 166 | */ 167 | 'fallback' => null, 168 | ], 169 | 170 | 'image' => [ 171 | /** 172 | * Use this setting to specify a fallback image, which will be used on places where you 173 | * don't have an image set via an associated ->seo model or via the ->getDynamicSEOData() method. 174 | * This should be a path to an image. The url to the path is generated using the `secure_url()` function (`secure_url($yourProvidedPath)`). 175 | */ 176 | 'fallback' => null, 177 | ], 178 | 179 | 'author' => [ 180 | /** 181 | * Use this setting to specify a fallback author, which will be used on places where you 182 | * don't have an author set via an associated ->seo model or via the ->getDynamicSEOData() method. 183 | */ 184 | 'fallback' => null, 185 | ], 186 | 187 | 'twitter' => [ 188 | /** 189 | * Use this setting to enter your username and include that with the Twitter Card tags. 190 | * Enter the username like 'yourUserName', so without the '@'. 191 | */ 192 | '@username' => null, 193 | ], 194 | ]; 195 | ``` 196 | 197 | Now, add the following **Blade-code on every page** where you want your SEO-tags to appear: 198 | 199 | ```blade 200 | {!! seo() !!} 201 | ``` 202 | 203 | This will render a **lot of sensible tags by default**, already **greatly improving your SEO**. It will also render things like the `<title>` tag, so you don't have to render that manually. Additionally, it takes care of things automatically adding the `inertia` attribute to your `<title>` tag, allowing it to dynamically update whenever the user navigates to a different route on the frontend. 204 | 205 | To really profit from this package, you can **associate an Eloquent model with a SEO-model**. This will allow you to **dynamically fetch SEO data from your model** and this package will generate as much tags as possible for you, based on that data. 206 | 207 | To associate an Eloquent model with a SEO-model, add the `HasSEO` trait to your model: 208 | 209 | ```php 210 | use RalphJSmit\Laravel\SEO\Support\HasSEO; 211 | 212 | class Post extends Model 213 | { 214 | use HasSEO; 215 | 216 | // ... 217 | ``` 218 | 219 | This will automatically create and associate a SEO-model for you when a `Post` is created. You can also manually create a SEO-model for a Post, use the `->addSEO()` method for that (`$post->addSEO()`). 220 | 221 | You'll be able to retrieve the SEO-model via the Eloquent `seo` relationship: 222 | 223 | ```php 224 | $post = Post::find(1); 225 | 226 | $seo = $post->seo; 227 | ``` 228 | 229 | On the SEO model, you may **update the following properties**: 230 | 231 | 1. `title`: this will be used for the `<title>` tag and all the related tags (OpenGraph, Twitter, etc.) 232 | 2. `description`: this will be used for the `<meta>` description tag and all the related tags (OpenGraph, Twitter, etc.) 233 | 3. `author`: this should be the name of the author and it will be used for the `<meta>` author tag and all the related tags (OpenGraph, Twitter, etc.) 234 | 4. `image`: this should be the path to the image you want to use for the `<meta>` image tag and all the related tags (OpenGraph, Twitter, etc.). The url to the image is generated via the `secure_url()` function, so be sure to check that the image is publicly available and that you provide the right path. 235 | 5. `robots` 236 | - Overwrites the default robots value, which is set in the config. (See `'seo.robots.default'`). 237 | - String like `noindex,nofollow` [(Specifications)](https://developers.google.com/search/docs/advanced/robots/robots_meta_tag), which is added to `<meta name="robots">` 238 | 239 | ```php 240 | $post = Post::find(1); 241 | 242 | $post->seo->update([ 243 | 'title' => 'My title for the SEO tag', 244 | 'image' => 'images/posts/1.jpg', // Will point to `public_path('images/posts/1.jpg')` 245 | ]); 246 | ``` 247 | 248 | However, it can be a **bit cumbersome to manually update** the SEO-model every time you make a change. That's why I provided the `getDynamicSEOData()` method, which you can use to dynamically fetch the correct data from your own model and pass it to the SEO model: 249 | 250 | ```php 251 | public function getDynamicSEOData(): SEOData 252 | { 253 | return new SEOData( 254 | title: $this->title, 255 | description: $this->excerpt, 256 | author: $this->author->fullName, 257 | alternates: [ 258 | new AlternateTag( 259 | hreflang: 'en', 260 | href: "https://example.com/en", 261 | ), 262 | new AlternateTag( 263 | hreflang: 'fr', 264 | href: "https://example.com/fr", 265 | ), 266 | ], 267 | ); 268 | } 269 | ``` 270 | 271 | You are allowed to only override the properties you want and omit the other properties (or pass `null` to them). You can use the following properties: 272 | 273 | 1. `title` 274 | 2. `description` 275 | 3. `author` (should be the author's name) 276 | 4. `image` (should be the image path and be compatible with `$url = public_path($path)`) 277 | 5. `url` (by default it will be `url()->current()`) 278 | 6. `enableTitleSuffix` (should be `true` or `false`, this allows you to set a suffix in the `config/seo.php` file, which will be appended to every title) 279 | 7. `site_name` 280 | 8. `published_time` (should be a `Carbon` instance with the published time. By default, this will be the `created_at` property of your model) 281 | 9. `modified_time` (should be a `Carbon` instance with the published time. By default, this will be the `updated_at` property of your model) 282 | 10. `section` (should be the name of the section of your content. It is used for OpenGraph article tags and it could be something like the category of the post) 283 | 11. `tags` (should be an array with tags. It is used for the OpenGraph article tags) 284 | 12. `schema` (this should be a SchemaCollection instance, where you can configure the JSON-LD structured data schema tags) 285 | 13. `locale` (this should be the locale of the page. By default, this is derived from `app()->getLocale()` and it looks like `en` or `nl`.) 286 | 14. `robots` (should be a string with the content value of the robots meta tag, like `nofollow,noindex`). You can also use the `$SEOData->markAsNoIndex()` to prevent a page from being indexed. 287 | 15. `alternates` (should be an array of `AlternateTag`). Will render `<link rel="alternate" ... />` tags. 288 | 289 | Finally, you should update your Blade file, so that it can receive your model when generating the tags: 290 | 291 | ```blade 292 | {!! seo()->for($page) !!} 293 | {{-- Or pass it directly to the `seo()` method: --}} 294 | {!! seo($page ?? null) !!} 295 | ``` 296 | 297 | The following order is used when generating the tags (higher overwrites the lower): 298 | 299 | 1. Any overwrites from the `SEOManager::SEODataTransformer($closure)` (see below) 300 | 2. Data from the `getDynamicSEOData()` method 301 | 3. Data from the associated SEO model (`$post->seo`) 302 | 4. Default data from the `config/seo.php` file 303 | 304 | ### Passing SEOData directly from the controller 305 | 306 | Another option is to pass a SEOData-object directly from the controller to the layout file, into the `seo()` function. 307 | 308 | ```php 309 | use Illuminate\Contracts\View\View; 310 | use RalphJSmit\Laravel\SEO\Support\SEOData; 311 | 312 | class Homepage extends Controller 313 | { 314 | public function index(): View 315 | { 316 | return view('project.frontend.page.homepage.index', [ 317 | 'SEOData' => new SEOData( 318 | title: 'Awesome News - My Project', 319 | description: 'Lorem Ipsum', 320 | ), 321 | ]); 322 | } 323 | } 324 | ``` 325 | 326 | ```blade 327 | {!! seo($SEOData) !!} 328 | ``` 329 | 330 | ## Generating JSON-LD structured data 331 | 332 | This package can also **generate any structured data** for you (also called schema markup). 333 | Structured data is a very vast subject, so we highly recommend you to check the [Google documentation dedicated to it](https://developers.google.com/search/docs/appearance/structured-data/search-gallery). 334 | 335 | Structured data can be added in two ways: 336 | - Construct custom arrays of the structured data format, which is then rendered by the package in JSON on the correct place. 337 | - Use one of the 3 pre-defined templates to fluently build your structured data (`Article`, `BreadcrumbList`, `FaqPage`). 338 | 339 | ### Adding your first schema 340 | 341 | Let's add the FAQPage schema markup to our website as an example: 342 | 343 | ```php 344 | use RalphJSmit\Laravel\SEO\SchemaCollection; 345 | 346 | public function getDynamicSEOData(): SEOData 347 | { 348 | return new SEOData( 349 | // ... 350 | schema: SchemaCollection::make() 351 | ->add(fn (SEOData $SEOData) => [ 352 | // You could use the `$SEOData` to dynamically 353 | // fetch any data about the current page. 354 | '@context' => 'https://schema.org', 355 | '@type' => 'FAQPage', 356 | 'mainEntity' => [ 357 | '@type' => 'Question', 358 | 'name' => 'Your question goes here', 359 | 'acceptedAnswer' => [ 360 | '@type' => 'Answer', 361 | 'text' => 'Your answer goes here', 362 | ], 363 | ], 364 | ]), 365 | ); 366 | } 367 | ``` 368 | 369 | > [!TIP] 370 | > When adding a new schema, you can check the [documentation here](https://developers.google.com/search/docs/appearance/structured-data/faqpage) to know what keys to add. 371 | 372 | ### Pre-configured Schema: Article and BreadcrumbList 373 | 374 | To help you get started with structured data, we added 3 preconfigured schema that you can construct using fluent methods. The following types are available: 375 | 376 | 1. `Article` 377 | 2. `BreadcrumbList` 378 | 3. `FAQPage` 379 | 380 | ### Article schema markup 381 | 382 | In order to automatically and fluently generate `Article` schema markup, use the `->addArticle()` method: 383 | 384 | ```php 385 | 386 | use RalphJSmit\Laravel\SEO\SchemaCollection; 387 | 388 | public function getDynamicSEOData(): SEOData 389 | { 390 | return new SEOData( 391 | // ... 392 | schema: SchemaCollection::make()->addArticle(), 393 | ); 394 | } 395 | ``` 396 | 397 | This will construct an article schema using all data provided by the `SEOData` object. You can pass a closure to `->addArticle()` method to customize the individual schema markup. This closure will receive an instance of ArticleSchema as its argument. You can an additional author by using the `->addAuthor()` method. 398 | 399 | ```php 400 | use RalphJSmit\Laravel\SEO\Schema\ArticleSchema; 401 | use RalphJSmit\Laravel\SEO\SchemaCollection; 402 | use RalphJSmit\Laravel\SEO\Support\SEOData; 403 | use Illuminate\Support\Collection; 404 | 405 | public function getDynamicSEOData(): SEOData 406 | { 407 | return new SEOData( 408 | // ... 409 | title: "A boring title" 410 | schema: SchemaCollection::make() 411 | ->addArticle(function (ArticleSchema $article, SEOData $SEOData): ArticleSchema { 412 | return $article->addAuthor($this->moderator); 413 | }), 414 | ); 415 | } 416 | ``` 417 | 418 | You can completely customize the schema markup by using the `->markup()` method on the `ArticleSchema` instance: 419 | 420 | ```php 421 | SchemaCollection::initialize()->addArticle(function (ArticleSchema $article, SEOData $SEOData): ArticleSchema { 422 | return $article->markup(function (Collection $markup) use ($SEOData): Collection { 423 | return $markup->put('alternativeHeadline', "Not {$SEOData->title}"); // Set/overwrite alternative headline property to `Will be "Not A boring title"` :) 424 | }); 425 | }); 426 | ``` 427 | 428 | > [!TIP] 429 | > Check the Google documentation about [Article](https://developers.google.com/search/docs/appearance/structured-data/article) for more information. 430 | 431 | ### BreadcrumbList schema markup 432 | 433 | You can also add `BreadcrumbList` schema markup by using the `->addBreadcrumbList()` function on the `SchemaCollection`. 434 | 435 | By default, the schema will only contain the current url from `$SEOData->url`. 436 | 437 | ```php 438 | SchemaCollection::initialize() 439 | ->addBreadcrumbs(function (BreadcrumbListSchema $breadcrumbs, SEOData $SEOData): BreadcrumbListSchema { 440 | return $breadcrumbs 441 | ->prependBreadcrumbs([ 442 | 'Homepage' => 'https://example.com', 443 | 'Category' => 'https://example.com/test', 444 | ]) 445 | ->appendBreadcrumbs([ 446 | 'Subarticle' => 'https://example.com/test/article/2', 447 | ]) 448 | ->markup(function (Collection $markup): Collection { 449 | // ... 450 | }); 451 | }); 452 | ``` 453 | 454 | This code will generate `BreadcrumbList` JSON-LD structured data with the following four pages: 455 | 456 | 1. Homepage 457 | 2. Category 458 | 3. [Current page] 459 | 4. Subarticle 460 | 461 | > [!TIP] 462 | > Check the Google documentation about [BreadcrumbList](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb) for more information. 463 | 464 | ### FAQPage schema markup 465 | 466 | You can also add FAQPage schema markup by using the ->addFaqPage() function on the SchemaCollection: 467 | 468 | ```php 469 | SchemaCollection::initialize() 470 | ->addFaqPage(function (FaqPageSchema $faqPage, SEOData $SEOData): FaqPageSchema { 471 | return $faqPage 472 | ->addQuestion(name: "Can this package add FaqPage to the schema?", acceptedAnswer: "Yes!") 473 | ->addQuestion(name: "Does it support multiple questions?", acceptedAnswer: "Of course."); 474 | }); 475 | ``` 476 | 477 | > [!TIP] 478 | > Check the Google documentation about [Faq Page](https://developers.google.com/search/docs/appearance/structured-data/faqpage) for more information. 479 | 480 | > [!TIP] 481 | > After generating the structured data, it is always a good idea to [test your website with Google's rich result validator](https://search.google.com/test/rich-results). 482 | 483 | ## Advanced usage 484 | 485 | Sometimes you may have advanced needs that require you to apply your own logic to the `SEOData` class, just before it is used to generate the tags. 486 | 487 | To accomplish this, you can use the `SEODataTransformer()` function on the `SEOManager` facade to register one or multiple closures that will be able to modify the `SEOData` instance at the last moment: 488 | 489 | ```php 490 | // In the `boot()` method of a service provider somewhere 491 | use RalphJSmit\Laravel\SEO\Facades\SEOManager; 492 | 493 | SEOManager::SEODataTransformer(function (SEOData $SEOData): SEOData { 494 | // This will change the title on *EVERY* page. Do any logic you want here, e.g. based on the current request. 495 | $SEOData->title = 'Transformed Title'; 496 | 497 | return $SEOData; 498 | }); 499 | ``` 500 | 501 | > Make sure to return the `$SEOData` object in each closure. 502 | 503 | ### Modifying tags before they are rendered 504 | 505 | You can also **register closures that can modify the final collection of generated tags**, right before they are rendered. This is useful if you want to add custom tags to the output or if you want to modify the output of the tags. 506 | 507 | ```php 508 | SEOManager::tagTransformer(function (TagCollection $tags): TagCollection { 509 | $tags = $tags->reject(fn(Tag $tag) => $tag instanceof OpenGraphTag); 510 | 511 | $tags->push(new MetaTag(name: 'custom-tag', content: 'My custom content')); 512 | // Will render: <meta name="custom-tag" content="My custom content"> 513 | 514 | return $tags; 515 | }); 516 | ``` 517 | 518 | ## Roadmap 519 | 520 | I hope this package will be useful to you! If you have any ideas or suggestions on how to make it more useful, please let me know (rjs@ralphjsmit.com) or via the issues. 521 | 522 | PRs are welcome, so feel free to fork and submit a pull request. I'll be happy to review your changes, think along and add them to the package. 523 | 524 | ## General 525 | 526 | 🐞 If you spot a bug, please submit a detailed issue and I'll try to fix it as soon as possible. 527 | 528 | 🔐 If you discover a vulnerability, please review [our security policy](../../security/policy). 529 | 530 | 🙌 If you want to contribute, please submit a pull request. All PRs will be fully credited. If you're unsure whether I'd accept your idea, feel free to contact me! 531 | 532 | 🙋‍♂️ [Ralph J. Smit](https://ralphjsmit.com) 533 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ralphjsmit/laravel-seo", 3 | "description": "A package to handle the SEO in any Laravel application, big or small.", 4 | "keywords": [ 5 | "ralphjsmit", 6 | "laravel", 7 | "laravel-seo" 8 | ], 9 | "homepage": "https://github.com/ralphjsmit/laravel-seo", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Ralph J. Smit", 14 | "email": "rjs@ralphjsmit.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0", 20 | "illuminate/contracts": "^10.0|^11.0|^12.0", 21 | "ralphjsmit/laravel-helpers": "^1.10", 22 | "spatie/laravel-package-tools": "^1.9.2" 23 | }, 24 | "require-dev": { 25 | "laravel/pint": "^1.16", 26 | "nesbot/carbon": "^2.66|^3.0", 27 | "nunomaduro/collision": "^7.0|^8.0|^9.0", 28 | "orchestra/testbench": "^9.0|^10.0", 29 | "pestphp/pest": "^2.0|^3.0", 30 | "pestphp/pest-plugin-laravel": "^2.0|^3.0", 31 | "phpunit/phpunit": "^10.5|^11.5", 32 | "spatie/laravel-ray": "^1.39", 33 | "spatie/pest-plugin-test-time": "^2.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "RalphJSmit\\Laravel\\SEO\\": "src", 38 | "RalphJSmit\\Laravel\\SEO\\Database\\Factories\\": "database/factories" 39 | }, 40 | "files": [ 41 | "src/helpers.php" 42 | ] 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "RalphJSmit\\Laravel\\SEO\\Tests\\": "tests" 47 | } 48 | }, 49 | "scripts": { 50 | "test": "vendor/bin/pest", 51 | "test-coverage": "vendor/bin/pest --coverage", 52 | "format": "vendor/bin/pint" 53 | }, 54 | "config": { 55 | "sort-packages": true, 56 | "allow-plugins": { 57 | "pestphp/pest-plugin": true 58 | } 59 | }, 60 | "extra": { 61 | "laravel": { 62 | "providers": [ 63 | "RalphJSmit\\Laravel\\SEO\\LaravelSEOServiceProvider" 64 | ], 65 | "aliases": { 66 | "SEOManager": "RalphJSmit\\Laravel\\SEO\\Facades\\SEOManager" 67 | } 68 | } 69 | }, 70 | "minimum-stability": "dev", 71 | "prefer-stable": true 72 | } 73 | -------------------------------------------------------------------------------- /config/seo.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use RalphJSmit\Laravel\SEO\Models\SEO; 4 | 5 | return [ 6 | /** 7 | * The SEO model. You can use this setting to override the model used by the package. 8 | * Make sure to always extend the old model, so that you'll not lose functionality during upgrades. 9 | */ 10 | 'model' => SEO::class, 11 | 12 | /** 13 | * Use this setting to specify the site name that will be used in OpenGraph tags. 14 | */ 15 | 'site_name' => null, 16 | 17 | /** 18 | * Use this setting to specify the path to the sitemap of your website. This exact path will outputted, so 19 | * you can use both a hardcoded url and a relative path. We recommend the latter. 20 | * 21 | * Example: '/storage/sitemap.xml' 22 | * Do not forget the slash at the start. This will tell the search engine that the path is relative 23 | * to the root domain and not relative to the current URL. The `spatie/laravel-sitemap` package 24 | * is a great package to generate sitemaps for your application. 25 | */ 26 | 'sitemap' => null, 27 | 28 | /** 29 | * Use this setting to specify whether you want self-referencing `<link rel="canonical" href="$url">` tags to 30 | * be added to the head of every page. There has been some debate whether this a good practice, but experts 31 | * from Google and Yoast say that this is the best strategy. 32 | * See https://yoast.com/rel-canonical/. 33 | */ 34 | 'canonical_link' => true, 35 | 36 | 'robots' => [ 37 | /** 38 | * Use this setting to specify the default value of the robots meta tag. `<meta name="robots" content="noindex">` 39 | * Overwrite it with the robots attribute of the SEOData object. `SEOData->robots = 'noindex, nofollow'` 40 | * "max-snippet:-1" Use n chars (-1: Search engine chooses) as a search result snippet. 41 | * "max-image-preview:large" Max size of a preview in search results. 42 | * "max-video-preview:-1" Use max seconds (-1: There is no limit) as a video snippet in search results. 43 | * See https://developers.google.com/search/docs/advanced/robots/robots_meta_tag 44 | * Default: 'max-snippet:-1, max-image-preview:large, max-video-preview:-1' 45 | */ 46 | 'default' => 'max-snippet:-1,max-image-preview:large,max-video-preview:-1', 47 | 48 | /** 49 | * Force set the robots `default` value and make it impossible to overwrite it. (e.g. via SEOData->robots) 50 | * Use case: You need to set `noindex, nofollow` for the entire website without exception. 51 | * Default: false 52 | */ 53 | 'force_default' => false, 54 | ], 55 | 56 | /** 57 | * Use this setting to specify the path to the favicon for your website. The url to it will be generated using the `secure_url()` function, 58 | * so make sure to make the favicon accessibly from the `public` folder. 59 | * 60 | * You can use the following filetypes: ico, png, gif, jpeg, svg. 61 | */ 62 | 'favicon' => null, 63 | 64 | 'title' => [ 65 | /** 66 | * Use this setting to let the package automatically infer a title from the url, if no other title 67 | * was given. This will be very useful on pages where you don't have an Eloquent model for, or where you 68 | * don't want to hardcode the title. 69 | * 70 | * For example, if you have a page with the url '/foo/about-me', we'll automatically set the title to 'About me' and append the site suffix. 71 | */ 72 | 'infer_title_from_url' => true, 73 | 74 | /** 75 | * Use this setting to provide a suffix that will be added after the title on each page. 76 | * If you don't want a suffix, you should specify an empty string. 77 | */ 78 | 'suffix' => '', 79 | 80 | /** 81 | * Use this setting to provide a custom title for the homepage. We will not use the suffix on the homepage, 82 | * so you'll need to add the suffix manually if you want that. If set to null, we'll determine the title 83 | * just like the other pages. 84 | */ 85 | 'homepage_title' => null, 86 | ], 87 | 88 | 'description' => [ 89 | /** 90 | * Use this setting to specify a fallback description, which will be used on places 91 | * where we don't have a description set via an associated ->seo model or via 92 | * the ->getDynamicSEOData() method. 93 | */ 94 | 'fallback' => null, 95 | ], 96 | 97 | 'image' => [ 98 | /** 99 | * Use this setting to specify a fallback image, which will be used on places where you 100 | * don't have an image set via an associated ->seo model or via the ->getDynamicSEOData() method. 101 | * This should be a path to an image. The url to the path is generated using the `secure_url()` function 102 | * (`secure_url($yourProvidedPath)`), so make sure the image is accessible from the public folder. 103 | */ 104 | 'fallback' => null, 105 | ], 106 | 107 | 'author' => [ 108 | /** 109 | * Use this setting to specify a fallback author, which will be used on places where you 110 | * don't have an author set via an associated ->seo model or via the ->getDynamicSEOData() method. 111 | */ 112 | 'fallback' => null, 113 | ], 114 | 115 | 'twitter' => [ 116 | /** 117 | * Use this setting to enter your username and include that with the Twitter Card tags. 118 | * Enter the username like 'yourUserName', so without the '@'. 119 | */ 120 | '@username' => null, 121 | ], 122 | ]; 123 | -------------------------------------------------------------------------------- /database/factories/ModelFactory.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\SEO\Database\Factories; 4 | 5 | use Illuminate\Database\Eloquent\Factories\Factory; 6 | 7 | /* 8 | class ModelFactory extends Factory 9 | { 10 | protected $model = YourModel::class; 11 | 12 | public function definition() 13 | { 14 | return [ 15 | 16 | ]; 17 | } 18 | } 19 | */ 20 | -------------------------------------------------------------------------------- /database/migrations/create_seo_table.php.stub: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Illuminate\Database\Migrations\Migration; 4 | use Illuminate\Database\Schema\Blueprint; 5 | use Illuminate\Support\Facades\Schema; 6 | 7 | return new class extends Migration 8 | { 9 | public function up(): void 10 | { 11 | Schema::create('seo', function (Blueprint $table) { 12 | $table->id(); 13 | 14 | $table->morphs('model'); 15 | 16 | $table->longText('description')->nullable(); 17 | $table->string('title')->nullable(); 18 | $table->string('image')->nullable(); 19 | $table->string('author')->nullable(); 20 | $table->string('robots')->nullable(); 21 | $table->string('canonical_url')->nullable(); 22 | 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('seo'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "no_superfluous_phpdoc_tags": true, 5 | "concat_space": { 6 | "spacing": "one" 7 | }, 8 | "single_quote": true, 9 | "combine_consecutive_issets": true, 10 | "combine_consecutive_unsets": true, 11 | "explicit_string_variable": true, 12 | "global_namespace_import": true, 13 | "single_trait_insert_per_statement": true, 14 | "ordered_traits": true, 15 | "types_spaces": { 16 | "space": "single" 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphjsmit/laravel-seo/b6896afb61ee0494c214ed2ba60ce78e22b5d590/resources/views/.gitkeep -------------------------------------------------------------------------------- /resources/views/tags/tag.blade.php: -------------------------------------------------------------------------------- 1 | <{{ $tag }}<?php foreach($attributes as $name => $value) : ?> {{ $name }}<?php if(! is_bool($value)) : ?>="{{ $value }}"<?php endif ?><?php endforeach ?>><?php if ($inner) : ?>{{ $inner }}</{{ $tag }}><?php endif ?> -------------------------------------------------------------------------------- /src/Facades/SEOManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Facades; 4 | 5 | use Closure; 6 | use Illuminate\Support\Facades\Facade; 7 | 8 | /** 9 | * @method static array getSEODataTransformers() 10 | * @method static array getTagTransformers() 11 | * @method static \RalphJSmit\Laravel\SEO\SEOManager SEODataTransformer( Closure $transformer ) 12 | * @method static \RalphJSmit\Laravel\SEO\SEOManager tagTransformer( Closure $transformer ) 13 | */ 14 | class SEOManager extends Facade 15 | { 16 | protected static function getFacadeAccessor() 17 | { 18 | return \RalphJSmit\Laravel\SEO\SEOManager::class; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/LaravelSEOServiceProvider.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO; 4 | 5 | use Spatie\LaravelPackageTools\Package; 6 | use Spatie\LaravelPackageTools\PackageServiceProvider; 7 | 8 | class LaravelSEOServiceProvider extends PackageServiceProvider 9 | { 10 | public function configurePackage(Package $package): void 11 | { 12 | $package 13 | ->name('laravel-seo') 14 | ->hasConfigFile() 15 | ->hasViews('seo') 16 | ->hasMigration('create_seo_table'); 17 | } 18 | 19 | public function packageRegistered(): void 20 | { 21 | $this->app->singleton(SEOManager::class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Models/SEO.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Models; 4 | 5 | use Illuminate\Database\Eloquent\Model; 6 | use Illuminate\Database\Eloquent\Relations\MorphTo; 7 | use RalphJSmit\Laravel\SEO\Support\SEOData; 8 | 9 | class SEO extends Model 10 | { 11 | protected $guarded = []; 12 | 13 | public $table = 'seo'; 14 | 15 | public function model(): MorphTo 16 | { 17 | return $this->morphTo(); 18 | } 19 | 20 | public function prepareForUsage(): SEOData 21 | { 22 | if (method_exists($this->model, 'getDynamicSEOData')) { 23 | /** @var SEOData $overrides */ 24 | $overrides = $this->model->getDynamicSEOData(); 25 | } 26 | 27 | if (method_exists($this->model, 'enableTitleSuffix')) { 28 | $enableTitleSuffix = $this->model->enableTitleSuffix(); 29 | } elseif (property_exists($this->model, 'enableTitleSuffix')) { 30 | $enableTitleSuffix = $this->model->enableTitleSuffix; 31 | } 32 | 33 | return new SEOData( 34 | title: $overrides->title ?? $this->title, 35 | description: $overrides->description ?? $this->description, 36 | author: $overrides->author ?? $this->author, 37 | image: $overrides->image ?? $this->image, 38 | url: $overrides->url ?? null, 39 | enableTitleSuffix: $enableTitleSuffix ?? true, 40 | published_time: $overrides->published_time ?? ($this->model?->created_at ?? null), 41 | modified_time: $overrides->modified_time ?? ($this->model?->updated_at ?? null), 42 | articleBody: $overrides->articleBody ?? null, 43 | section: $overrides->section ?? null, 44 | tags: $overrides->tags ?? null, 45 | schema: $overrides->schema ?? null, 46 | type: $overrides->type ?? null, 47 | locale: $overrides->locale ?? null, 48 | // Cannot directly access the `$this->robots` attribute, since that could potentially trigger a `Model::preventAccessingMissingAttributes()` exception. 49 | robots: $overrides->robots ?? $this->getAttributes()['robots'] ?? null, 50 | // Cannot directly access the `$this->canonical_url` attribute, since that could potentially trigger a `Model::preventAccessingMissingAttributes()` exception. 51 | canonical_url: $overrides->canonical_url ?? $this->getAttributes()['canonical_url'] ?? null, 52 | openGraphTitle: $overrides->openGraphTitle ?? null, 53 | alternates: $overrides->alternates ?? null, 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/SEOManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO; 4 | 5 | use Closure; 6 | 7 | class SEOManager 8 | { 9 | protected array $tagTransformers = []; 10 | 11 | protected array $SEODataTransformers = []; 12 | 13 | public function SEODataTransformer(Closure $transformer): static 14 | { 15 | $this->SEODataTransformers[] = $transformer; 16 | 17 | return $this; 18 | } 19 | 20 | public function tagTransformer(Closure $transformer): static 21 | { 22 | $this->tagTransformers[] = $transformer; 23 | 24 | return $this; 25 | } 26 | 27 | public function getTagTransformers(): array 28 | { 29 | return $this->tagTransformers; 30 | } 31 | 32 | public function getSEODataTransformers(): array 33 | { 34 | return $this->SEODataTransformers; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Schema/ArticleSchema.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Schema; 4 | 5 | use Carbon\CarbonInterface; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\SEOData; 8 | 9 | class ArticleSchema extends CustomSchemaFluent 10 | { 11 | public array $authors = []; 12 | 13 | public ?CarbonInterface $datePublished = null; 14 | 15 | public ?CarbonInterface $dateModified = null; 16 | 17 | public ?string $description = null; 18 | 19 | public ?string $headline = null; 20 | 21 | public ?string $image = null; 22 | 23 | public string $type = 'Article'; 24 | 25 | public ?string $url = null; 26 | 27 | public ?string $articleBody = null; 28 | 29 | public function addAuthor(string $authorName): static 30 | { 31 | if (! $this->authors) { 32 | $this->authors = [ 33 | '@type' => 'Person', 34 | 'name' => $authorName, 35 | ]; 36 | 37 | return $this; 38 | } 39 | 40 | $this->authors = [ 41 | $this->authors, 42 | [ 43 | '@type' => 'Person', 44 | 'name' => $authorName, 45 | ], 46 | ]; 47 | 48 | return $this; 49 | } 50 | 51 | public function initializeMarkup(SEOData $SEOData): void 52 | { 53 | $this->url = $SEOData->url; 54 | 55 | $properties = [ 56 | 'headline' => 'title', 57 | 'description' => 'description', 58 | 'image' => 'image', 59 | 'datePublished' => 'published_time', 60 | 'dateModified' => 'modified_time', 61 | 'articleBody' => 'articleBody', 62 | ]; 63 | 64 | foreach ($properties as $markupProperty => $SEODataProperty) { 65 | if ($SEOData->{$SEODataProperty}) { 66 | $this->{$markupProperty} = $SEOData->{$SEODataProperty}; 67 | } 68 | } 69 | 70 | if ($SEOData->author) { 71 | $this->authors = [ 72 | '@type' => 'Person', 73 | 'name' => $SEOData->author, 74 | ]; 75 | } 76 | } 77 | 78 | public function generateInner(): Collection 79 | { 80 | return collect([ 81 | '@context' => 'https://schema.org', 82 | '@type' => $this->type, 83 | 'mainEntityOfPage' => [ 84 | '@type' => 'WebPage', 85 | '@id' => $this->url, 86 | ], 87 | ]) 88 | ->when($this->datePublished, fn (Collection $collection): Collection => $collection->put('datePublished', $this->datePublished->toIso8601String())) 89 | ->when($this->dateModified, fn (Collection $collection): Collection => $collection->put('dateModified', $this->dateModified->toIso8601String())) 90 | ->put('headline', $this->headline) 91 | ->when($this->authors, fn (Collection $collection): Collection => $collection->put('author', $this->authors)) 92 | ->when($this->description, fn (Collection $collection): Collection => $collection->put('description', $this->description)) 93 | ->when($this->image, fn (Collection $collection): Collection => $collection->put('image', $this->image)) 94 | ->when($this->articleBody, fn (Collection $collection): Collection => $collection->put('articleBody', $this->articleBody)) 95 | ->pipeThrough($this->markupTransformers); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Schema/BreadcrumbListSchema.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Schema; 4 | 5 | use Illuminate\Support\Collection; 6 | use RalphJSmit\Laravel\SEO\Support\SEOData; 7 | 8 | class BreadcrumbListSchema extends CustomSchemaFluent 9 | { 10 | public Collection $breadcrumbs; 11 | 12 | public string $type = 'BreadcrumbList'; 13 | 14 | public function appendBreadcrumbs(array $breadcrumbs): static 15 | { 16 | foreach ($breadcrumbs as $page => $url) { 17 | $this->breadcrumbs->put($page, $url); 18 | } 19 | 20 | return $this; 21 | } 22 | 23 | public function initializeMarkup(SEOData $SEOData): void 24 | { 25 | $this->breadcrumbs = collect([ 26 | $SEOData->title => $SEOData->url, 27 | ]); 28 | } 29 | 30 | public function generateInner(): Collection 31 | { 32 | return collect([ 33 | '@context' => 'https://schema.org', 34 | '@type' => $this->type, 35 | 'itemListElement' => $this->breadcrumbs 36 | ->reduce(function (Collection $carry, string $url, string $pagename): Collection { 37 | return $carry->push([ 38 | '@type' => 'ListItem', 39 | 'position' => $carry->count() + 1, 40 | 'name' => $pagename, 41 | 'item' => $url, 42 | ]); 43 | }, new Collection), 44 | ]) 45 | ->pipeThrough($this->markupTransformers); 46 | } 47 | 48 | public function prependBreadcrumbs(array $breadcrumbs): static 49 | { 50 | foreach (array_reverse($breadcrumbs) as $pagename => $url) { 51 | $this->breadcrumbs->prepend($url, $pagename); 52 | } 53 | 54 | return $this; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Schema/CustomSchema.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Schema; 4 | 5 | use Illuminate\Contracts\Support\Arrayable; 6 | use Illuminate\Support\HtmlString; 7 | use RalphJSmit\Helpers\Laravel\Pipe\Pipeable; 8 | use RalphJSmit\Laravel\SEO\Support\Tag; 9 | 10 | class CustomSchema extends Tag 11 | { 12 | use Pipeable; 13 | 14 | public string $tag = 'script'; 15 | 16 | public array $attributes = [ 17 | 'type' => 'application/ld+json', 18 | ]; 19 | 20 | public function __construct(iterable | Arrayable $inner) 21 | { 22 | $this->inner = new HtmlString( 23 | collect($inner)->toJson() 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Schema/CustomSchemaFluent.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Schema; 4 | 5 | use Closure; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\SEOData; 8 | 9 | abstract class CustomSchemaFluent extends CustomSchema 10 | { 11 | public array $markupTransformers = []; 12 | 13 | public function __construct(SEOData $SEOData, array $markupBuilders = []) 14 | { 15 | $this->initializeMarkup($SEOData); 16 | 17 | // `$markupBuilders` are closures that modify this fluent schema 18 | // tag object and can call methods on it to change items... 19 | foreach ($markupBuilders as $markupBuilder) { 20 | $markupBuilder($this, $SEOData); 21 | } 22 | 23 | parent::__construct($this->generateInner()); 24 | } 25 | 26 | abstract public function initializeMarkup(SEOData $SEOData): void; 27 | 28 | abstract public function generateInner(): Collection; 29 | 30 | public function markup(Closure $transformer): static 31 | { 32 | $this->markupTransformers[] = $transformer; 33 | 34 | return $this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Schema/FaqPageSchema.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Schema; 4 | 5 | use Illuminate\Support\Collection; 6 | use RalphJSmit\Laravel\SEO\Support\SEOData; 7 | 8 | /** 9 | * @see https://developers.google.com/search/docs/appearance/structured-data/faqpage 10 | */ 11 | class FaqPageSchema extends CustomSchemaFluent 12 | { 13 | public Collection $questions; 14 | 15 | public string $type = 'FAQPage'; 16 | 17 | public function addQuestion(string $name, string $acceptedAnswer): static 18 | { 19 | $this->questions[] = [ 20 | '@type' => 'Question', 21 | 'name' => $name, 22 | 'acceptedAnswer' => [ 23 | '@type' => 'Answer', 24 | 'text' => $acceptedAnswer, 25 | ], 26 | ]; 27 | 28 | return $this; 29 | } 30 | 31 | public function initializeMarkup(SEOData $SEOData): void 32 | { 33 | $this->questions = new Collection; 34 | } 35 | 36 | public function generateInner(): Collection 37 | { 38 | return collect([ 39 | '@context' => 'https://schema.org', 40 | '@type' => $this->type, 41 | 'mainEntity' => $this->questions, 42 | ]) 43 | ->pipeThrough($this->markupTransformers); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/SchemaCollection.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO; 4 | 5 | use Closure; 6 | use Illuminate\Contracts\Support\Arrayable; 7 | use Illuminate\Support\Collection; 8 | use RalphJSmit\Laravel\SEO\Schema\ArticleSchema; 9 | use RalphJSmit\Laravel\SEO\Schema\BreadcrumbListSchema; 10 | use RalphJSmit\Laravel\SEO\Schema\FaqPageSchema; 11 | use RalphJSmit\Laravel\SEO\Support\SEOData; 12 | 13 | /** 14 | * @template TKey of array-key 15 | * 16 | * @extends Collection<TKey, iterable|Arrayable|(Closure(SEOData $SEOData):iterable|Arrayable)> 17 | */ 18 | class SchemaCollection extends Collection 19 | { 20 | protected array $dictionary = [ 21 | 'article' => ArticleSchema::class, 22 | 'breadcrumb_list' => BreadcrumbListSchema::class, 23 | 'faq_page' => FaqPageSchema::class, 24 | ]; 25 | 26 | public array $markup = []; 27 | 28 | public function addArticle(?Closure $builder = null): static 29 | { 30 | $this->markup[$this->dictionary['article']][] = $builder ?: fn (ArticleSchema $schema): ArticleSchema => $schema; 31 | 32 | return $this; 33 | } 34 | 35 | public function addBreadcrumbs(?Closure $builder = null): static 36 | { 37 | $this->markup[$this->dictionary['breadcrumb_list']][] = $builder ?: fn (BreadcrumbListSchema $schema): BreadcrumbListSchema => $schema; 38 | 39 | return $this; 40 | } 41 | 42 | public function addFaqPage(?Closure $builder = null): static 43 | { 44 | $this->markup[$this->dictionary['faq_page']][] = $builder ?: fn (FaqPageSchema $schema): FaqPageSchema => $schema; 45 | 46 | return $this; 47 | } 48 | 49 | public static function initialize(): static 50 | { 51 | return new static; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Support/AlternateTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | class AlternateTag extends LinkTag 6 | { 7 | public function __construct( 8 | string $hreflang, 9 | string $href, 10 | ) { 11 | parent::__construct('alternate', $href); 12 | 13 | $this->attributes['hreflang'] = $hreflang; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Support/HasSEO.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Database\Eloquent\Relations\MorphOne; 6 | 7 | trait HasSEO 8 | { 9 | public function addSEO(): static 10 | { 11 | $this->seo()->create(); 12 | 13 | return $this; 14 | } 15 | 16 | protected static function bootHasSEO(): void 17 | { 18 | static::created(fn (self $model): self => $model->addSEO()); 19 | } 20 | 21 | public function seo(): MorphOne 22 | { 23 | return $this->morphOne(config('seo.model'), 'model')->withDefault(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Support/ImageMeta.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use const FILTER_VALIDATE_URL; 6 | 7 | use Exception; 8 | 9 | class ImageMeta 10 | { 11 | public ?int $width = null; 12 | 13 | public ?int $height = null; 14 | 15 | public function __construct(string $path) 16 | { 17 | $publicPath = public_path($path); 18 | 19 | if (filter_var($path, FILTER_VALIDATE_URL)) { 20 | return; 21 | } 22 | 23 | if (! is_file($publicPath)) { 24 | report(new Exception("Path {$publicPath} is not a file.")); 25 | 26 | return; 27 | } 28 | 29 | [$width, $height] = getimagesize($publicPath); 30 | 31 | $this->width = $width; 32 | $this->height = $height; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Support/LinkTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | class LinkTag extends Tag 6 | { 7 | public string $tag = 'link'; 8 | 9 | public function __construct( 10 | string $rel, 11 | string $href, 12 | ) { 13 | $this->attributes['rel'] = $rel; 14 | $this->attributes['href'] = $href; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Support/MetaContentTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | class MetaContentTag extends Tag 6 | { 7 | public string $tag = 'meta'; 8 | 9 | public function __construct( 10 | string $property, 11 | string $content, 12 | ) { 13 | $this->attributes['property'] = $property; 14 | $this->attributes['content'] = $content; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Support/MetaTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Support\HtmlString; 6 | 7 | class MetaTag extends Tag 8 | { 9 | public string $tag = 'meta'; 10 | 11 | public function __construct( 12 | string $name, 13 | string | HtmlString $content, 14 | ) { 15 | $this->attributes['name'] = $name; 16 | $this->attributes['content'] = $content; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Support/OpenGraphTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Support\Collection; 6 | use Illuminate\Support\HtmlString; 7 | 8 | class OpenGraphTag extends Tag 9 | { 10 | public string $tag = 'meta'; 11 | 12 | public function __construct( 13 | string $property, 14 | string | HtmlString $content, 15 | ) { 16 | $this->attributes['property'] = $property; 17 | $this->attributes['content'] = $content; 18 | 19 | $this->attributesPipeline[] = function (Collection $collection) { 20 | return $collection->mapWithKeys(function (mixed $value, string $key) { 21 | if ($key === 'property') { 22 | $value = 'og:' . $value; 23 | } 24 | 25 | return [$key => $value]; 26 | }); 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Support/RenderableCollection.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Str; 7 | 8 | trait RenderableCollection 9 | { 10 | public function render(): string 11 | { 12 | return $this->reduce(function (string $carry, Renderable $item): string { 13 | return $carry .= Str::of( 14 | $item->render() 15 | )->trim() . PHP_EOL; 16 | }, ''); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Support/SEOData.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Carbon\CarbonInterface; 6 | use RalphJSmit\Helpers\Laravel\Pipe\Pipeable; 7 | use RalphJSmit\Laravel\SEO\SchemaCollection; 8 | 9 | class SEOData 10 | { 11 | use Pipeable; 12 | 13 | /** 14 | * @param null|array<array-key, AlternateTag> $alternates 15 | */ 16 | public function __construct( 17 | public ?string $title = null, 18 | public ?string $description = null, 19 | public ?string $author = null, 20 | public ?string $image = null, 21 | public ?string $url = null, 22 | public bool $enableTitleSuffix = true, 23 | public ?ImageMeta $imageMeta = null, 24 | public ?CarbonInterface $published_time = null, 25 | public ?CarbonInterface $modified_time = null, 26 | public ?string $articleBody = null, 27 | public ?string $section = null, 28 | public ?array $tags = null, 29 | public ?string $twitter_username = null, 30 | public ?SchemaCollection $schema = null, 31 | public ?string $type = 'website', 32 | public ?string $site_name = null, 33 | public ?string $favicon = null, 34 | public ?string $locale = null, 35 | public ?string $robots = null, 36 | public ?string $canonical_url = null, 37 | public ?string $openGraphTitle = null, 38 | public ?array $alternates = null, 39 | ) { 40 | if ($this->locale === null) { 41 | $this->locale = app()->getLocale(); 42 | } 43 | } 44 | 45 | public function imageMeta(): ?ImageMeta 46 | { 47 | if ($this->image) { 48 | return $this->imageMeta ??= new ImageMeta($this->image); 49 | } 50 | 51 | return null; 52 | } 53 | 54 | public function markAsNoindex(): static 55 | { 56 | $this->robots = 'noindex, nofollow'; 57 | 58 | return $this; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Support/SchemaTagCollection.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Schema\CustomSchema; 8 | 9 | class SchemaTagCollection extends Collection implements Renderable 10 | { 11 | use RenderableCollection; 12 | 13 | public static function initialize(?SEOData $SEOData = null): ?static 14 | { 15 | $schemas = $SEOData?->schema; 16 | 17 | if (! $schemas) { 18 | return null; 19 | } 20 | 21 | $collection = new static; 22 | 23 | foreach ($schemas as $schema) { 24 | $collection->push(new CustomSchema(value($schema, $SEOData))); 25 | } 26 | 27 | foreach ($schemas->markup as $markupClass => $markupBuilders) { 28 | $collection->push(new $markupClass($SEOData, $markupBuilders)); 29 | } 30 | 31 | return $collection; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/SitemapTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | class SitemapTag extends LinkTag 6 | { 7 | public array $attributes = [ 8 | 'type' => 'application/xml', 9 | 'rel' => 'sitemap', 10 | 'title' => 'Sitemap', 11 | ]; 12 | 13 | public function __construct( 14 | string $href 15 | ) { 16 | $this->attributes['href'] = $href; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Support/Tag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Contracts\View\View; 7 | use Illuminate\Support\Collection; 8 | use Illuminate\Support\HtmlString; 9 | 10 | abstract class Tag implements Renderable 11 | { 12 | const ATTRIBUTES_ORDER = ['rel', 'hreflang', 'title', 'name', 'href', 'property', 'description', 'content']; 13 | 14 | /** 15 | * The HTML tag 16 | */ 17 | public string $tag; 18 | 19 | /** 20 | * The HTML attributes of the tag 21 | */ 22 | public array $attributes = []; 23 | 24 | /** 25 | * The content of the tag 26 | */ 27 | public null | string | HtmlString $inner = null; 28 | 29 | public array $attributesPipeline = []; 30 | 31 | public function render(): View 32 | { 33 | return view('seo::tags.tag', [ 34 | 'tag' => $this->tag, 35 | 'attributes' => $this->collectAttributes(), 36 | 'inner' => $this->getInner(), 37 | ]); 38 | } 39 | 40 | public function collectAttributes(): Collection 41 | { 42 | return collect($this->attributes) 43 | ->map(fn (string | bool | HtmlString $attribute) => is_string($attribute) ? trim($attribute) : $attribute) 44 | ->sortKeysUsing(function ($a, $b) { 45 | $indexA = array_search($a, static::ATTRIBUTES_ORDER); 46 | $indexB = array_search($b, static::ATTRIBUTES_ORDER); 47 | 48 | return match (true) { 49 | $indexB === $indexA => 0, // keep the order defined in $attributes if neither $a or $b are in ATTRIBUTES_ORDER 50 | $indexA === false => 1, 51 | $indexB === false => -1, 52 | default => $indexA - $indexB 53 | }; 54 | }) 55 | ->pipeThrough($this->attributesPipeline); 56 | } 57 | 58 | public function getInner(): null | string | HtmlString 59 | { 60 | return $this->inner; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Support/TwitterCardTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Support; 4 | 5 | use Illuminate\Support\Collection; 6 | use Illuminate\Support\HtmlString; 7 | 8 | class TwitterCardTag extends Tag 9 | { 10 | public string $tag = 'meta'; 11 | 12 | public function __construct( 13 | string $name, 14 | string | HtmlString $content, 15 | ) { 16 | $this->attributes['name'] = $name; 17 | $this->attributes['content'] = $content; 18 | 19 | $this->attributesPipeline[] = function (Collection $collection) { 20 | return $collection->mapWithKeys(function (mixed $value, string $key) { 21 | if ($key === 'name') { 22 | $value = 'twitter:' . $value; 23 | } 24 | 25 | return [$key => $value]; 26 | }); 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/TagCollection.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\SchemaTagCollection; 8 | use RalphJSmit\Laravel\SEO\Support\SEOData; 9 | use RalphJSmit\Laravel\SEO\Tags\AlternateTags; 10 | use RalphJSmit\Laravel\SEO\Tags\AuthorTag; 11 | use RalphJSmit\Laravel\SEO\Tags\CanonicalTag; 12 | use RalphJSmit\Laravel\SEO\Tags\DescriptionTag; 13 | use RalphJSmit\Laravel\SEO\Tags\FaviconTag; 14 | use RalphJSmit\Laravel\SEO\Tags\ImageTag; 15 | use RalphJSmit\Laravel\SEO\Tags\OpenGraphTags; 16 | use RalphJSmit\Laravel\SEO\Tags\RobotsTag; 17 | use RalphJSmit\Laravel\SEO\Tags\SitemapTag; 18 | use RalphJSmit\Laravel\SEO\Tags\TitleTag; 19 | use RalphJSmit\Laravel\SEO\Tags\TwitterCardTags; 20 | 21 | class TagCollection extends Collection 22 | { 23 | public static function initialize(?SEOData $SEOData = null): static 24 | { 25 | $collection = new static; 26 | 27 | $tags = collect([ 28 | RobotsTag::initialize($SEOData), 29 | CanonicalTag::initialize($SEOData), 30 | SitemapTag::initialize($SEOData), 31 | DescriptionTag::initialize($SEOData), 32 | AuthorTag::initialize($SEOData), 33 | TitleTag::initialize($SEOData), 34 | ImageTag::initialize($SEOData), 35 | FaviconTag::initialize($SEOData), 36 | OpenGraphTags::initialize($SEOData), 37 | TwitterCardTags::initialize($SEOData), 38 | AlternateTags::initialize($SEOData), 39 | SchemaTagCollection::initialize($SEOData), 40 | ])->reject(fn (?Renderable $item): bool => $item === null); 41 | 42 | foreach ($tags as $tag) { 43 | $collection->push($tag); 44 | } 45 | 46 | return $collection; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/TagManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO; 4 | 5 | use const FILTER_VALIDATE_URL; 6 | 7 | use Illuminate\Contracts\Support\Renderable; 8 | use Illuminate\Database\Eloquent\Model; 9 | use Illuminate\Support\Str; 10 | use RalphJSmit\Laravel\SEO\Facades\SEOManager; 11 | use RalphJSmit\Laravel\SEO\Support\SEOData; 12 | 13 | class TagManager implements Renderable 14 | { 15 | public Model $model; 16 | 17 | public SEOData $SEOData; 18 | 19 | public TagCollection $tags; 20 | 21 | public function __construct() 22 | { 23 | $this->tags = TagCollection::initialize( 24 | $this->fillSEOData() 25 | ); 26 | } 27 | 28 | public function fillSEOData(?SEOData $SEOData = null): SEOData 29 | { 30 | $SEOData ??= new SEOData; 31 | 32 | $defaults = [ 33 | 'title' => config('seo.title.infer_title_from_url') ? $this->inferTitleFromUrl() : null, 34 | 'description' => config('seo.description.fallback'), 35 | 'image' => config('seo.image.fallback'), 36 | 'site_name' => config('seo.site_name'), 37 | 'author' => config('seo.author.fallback'), 38 | 'twitter_username' => Str::of(config('seo.twitter.@username'))->start('@'), 39 | 'favicon' => config('seo.favicon'), 40 | ]; 41 | 42 | foreach ($defaults as $property => $defaultValue) { 43 | if ($SEOData->{$property} === null) { 44 | $SEOData->{$property} = $defaultValue; 45 | } 46 | } 47 | 48 | if ($SEOData->enableTitleSuffix) { 49 | $SEOData->title .= config('seo.title.suffix'); 50 | 51 | if ($SEOData->openGraphTitle) { 52 | $SEOData->openGraphTitle .= config('seo.title.suffix'); 53 | } 54 | } 55 | 56 | if ($SEOData->image && filter_var($SEOData->image, FILTER_VALIDATE_URL) === false) { 57 | $SEOData->imageMeta(); 58 | 59 | $SEOData->image = secure_url($SEOData->image); 60 | } 61 | 62 | if ($SEOData->favicon && filter_var($SEOData->favicon, FILTER_VALIDATE_URL) === false) { 63 | $SEOData->favicon = secure_url($SEOData->favicon); 64 | } 65 | 66 | if (! $SEOData->url) { 67 | $SEOData->url = url()->current(); 68 | } 69 | 70 | if ($SEOData->url === url('/') && ($homepageTitle = config('seo.title.homepage_title'))) { 71 | $SEOData->title = $homepageTitle; 72 | } 73 | 74 | return $SEOData->pipethrough( 75 | SEOManager::getSEODataTransformers() 76 | ); 77 | } 78 | 79 | public function for(Model | SEOData $source): static 80 | { 81 | if ($source instanceof Model) { 82 | $this->model = $source; 83 | unset($this->SEOData); 84 | } elseif ($source instanceof SEOData) { 85 | unset($this->model); 86 | $this->SEOData = $source; 87 | } 88 | 89 | // The tags collection is already initialized when constructing the manager. Here, we'll 90 | // initialize the collection again, but this time we pass the model to the initializer. 91 | // The initializes will pass the generated SEOData to all underlying initializers, ensuring that 92 | // the tags are always fully up-to-date and no remnants from previous initializations are present. 93 | $SEOData = isset($this->model) 94 | ? $this->model->seo?->prepareForUsage() 95 | : $this->SEOData; 96 | 97 | $this->tags = TagCollection::initialize( 98 | $this->fillSEOData($SEOData ?? new SEOData) 99 | ); 100 | 101 | return $this; 102 | } 103 | 104 | protected function inferTitleFromUrl(): string 105 | { 106 | return Str::of(url()->current()) 107 | ->afterLast('/') 108 | ->headline(); 109 | } 110 | 111 | public function render(): string 112 | { 113 | return $this->tags 114 | ->pipeThrough(SEOManager::getTagTransformers()) 115 | ->reduce(function (string $carry, Renderable $item) { 116 | return $carry .= Str::of($item->render())->trim() . PHP_EOL; 117 | }, ''); 118 | } 119 | 120 | public function __toString(): string 121 | { 122 | return $this->render(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Tags/AlternateTags.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 8 | use RalphJSmit\Laravel\SEO\Support\SEOData; 9 | 10 | class AlternateTags extends Collection implements Renderable 11 | { 12 | use RenderableCollection; 13 | 14 | public static function initialize(SEOData $SEOData): ?static 15 | { 16 | if (! $SEOData->alternates) { 17 | return null; 18 | } 19 | 20 | return new static($SEOData->alternates); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Tags/AuthorTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use RalphJSmit\Laravel\SEO\Support\MetaTag; 6 | use RalphJSmit\Laravel\SEO\Support\SEOData; 7 | 8 | class AuthorTag extends MetaTag 9 | { 10 | public static function initialize(?SEOData $SEOData): ?MetaTag 11 | { 12 | $author = $SEOData?->author; 13 | 14 | if (! $author) { 15 | return null; 16 | } 17 | 18 | return new MetaTag( 19 | name: 'author', 20 | content: $author 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Tags/CanonicalTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\LinkTag; 8 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 9 | use RalphJSmit\Laravel\SEO\Support\SEOData; 10 | 11 | class CanonicalTag extends Collection implements Renderable 12 | { 13 | use RenderableCollection; 14 | 15 | public static function initialize(?SEOData $SEOData = null): static 16 | { 17 | $collection = new static; 18 | 19 | if (config('seo.canonical_link')) { 20 | $collection->push(new LinkTag('canonical', $SEOData->canonical_url ?? $SEOData->url)); 21 | } 22 | 23 | return $collection; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Tags/DescriptionTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use RalphJSmit\Laravel\SEO\Support\MetaTag; 6 | use RalphJSmit\Laravel\SEO\Support\SEOData; 7 | 8 | class DescriptionTag extends MetaTag 9 | { 10 | public static function initialize(?SEOData $SEOData): ?MetaTag 11 | { 12 | $description = $SEOData?->description; 13 | 14 | if (! $description) { 15 | return null; 16 | } 17 | 18 | return new MetaTag( 19 | name: 'description', 20 | content: $description 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Tags/FaviconTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Support\Collection; 6 | use RalphJSmit\Laravel\SEO\Support\LinkTag; 7 | use RalphJSmit\Laravel\SEO\Support\SEOData; 8 | 9 | class FaviconTag extends LinkTag 10 | { 11 | public static function initialize(?SEOData $SEOData): ?static 12 | { 13 | $favicon = $SEOData?->favicon; 14 | 15 | if (! $favicon) { 16 | return null; 17 | } 18 | 19 | return new static( 20 | rel: 'shortcut icon', 21 | href: $favicon, 22 | ); 23 | } 24 | 25 | public function collectAttributes(): Collection 26 | { 27 | return parent::collectAttributes() 28 | ->sortKeys(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Tags/ImageTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Support\HtmlString; 6 | use RalphJSmit\Laravel\SEO\Support\MetaTag; 7 | use RalphJSmit\Laravel\SEO\Support\SEOData; 8 | 9 | class ImageTag extends MetaTag 10 | { 11 | public static function initialize(?SEOData $SEOData): ?MetaTag 12 | { 13 | $image = $SEOData?->image; 14 | 15 | if (! $image) { 16 | return null; 17 | } 18 | 19 | return new MetaTag( 20 | name: 'image', 21 | content: new HtmlString($image), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Tags/OpenGraphTags.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use Illuminate\Support\HtmlString; 8 | use RalphJSmit\Laravel\SEO\Support\MetaContentTag; 9 | use RalphJSmit\Laravel\SEO\Support\OpenGraphTag; 10 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 11 | use RalphJSmit\Laravel\SEO\Support\SEOData; 12 | 13 | class OpenGraphTags extends Collection implements Renderable 14 | { 15 | use RenderableCollection; 16 | 17 | public static function initialize(SEOData $SEOData): static 18 | { 19 | $collection = new static; 20 | 21 | if ($SEOData->openGraphTitle) { 22 | $collection->push(new OpenGraphTag('title', $SEOData->openGraphTitle)); 23 | } elseif ($SEOData->title) { 24 | $collection->push(new OpenGraphTag('title', $SEOData->title)); 25 | } 26 | 27 | if ($SEOData->description) { 28 | $collection->push(new OpenGraphTag('description', $SEOData->description)); 29 | } 30 | 31 | if ($SEOData->locale) { 32 | $collection->push(new OpenGraphTag('locale', $SEOData->locale)); 33 | } 34 | 35 | if ($SEOData->image) { 36 | $collection->push(new OpenGraphTag('image', new HtmlString($SEOData->image))); 37 | 38 | if ($SEOData->imageMeta) { 39 | $collection 40 | ->when($SEOData->imageMeta->width, fn (self $collection): self => $collection->push(new OpenGraphTag('image:width', $SEOData->imageMeta->width))) 41 | ->when($SEOData->imageMeta->height, fn (self $collection): self => $collection->push(new OpenGraphTag('image:height', $SEOData->imageMeta->height))); 42 | } 43 | } 44 | 45 | $collection->push(new OpenGraphTag('url', $SEOData->url)); 46 | 47 | if ($SEOData->site_name) { 48 | $collection->push(new OpenGraphTag('site_name', $SEOData->site_name)); 49 | } 50 | 51 | if ($SEOData->type) { 52 | $collection->push(new OpenGraphTag('type', $SEOData->type)); 53 | } 54 | 55 | if ($SEOData->published_time && $SEOData->type === 'article') { 56 | $collection->push(new MetaContentTag('article:published_time', $SEOData->published_time->toIso8601String())); 57 | } 58 | 59 | if ($SEOData->modified_time && $SEOData->type === 'article') { 60 | $collection->push(new MetaContentTag('article:modified_time', $SEOData->modified_time->toIso8601String())); 61 | } 62 | 63 | if ($SEOData->section && $SEOData->type === 'article') { 64 | $collection->push(new MetaContentTag('article:section', $SEOData->section)); 65 | } 66 | 67 | if ($SEOData->tags && $SEOData->type === 'article') { 68 | foreach ($SEOData->tags as $tag) { 69 | $collection->push(new MetaContentTag('article:tag', $tag)); 70 | } 71 | } 72 | 73 | return $collection; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Tags/RobotsTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\MetaTag; 8 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 9 | use RalphJSmit\Laravel\SEO\Support\SEOData; 10 | 11 | class RobotsTag extends Collection implements Renderable 12 | { 13 | use RenderableCollection; 14 | 15 | public static function initialize(?SEOData $SEOData = null): static 16 | { 17 | $collection = new static; 18 | 19 | $robotsContent = config('seo.robots.default'); 20 | 21 | if (! config('seo.robots.force_default')) { 22 | $robotsContent = $SEOData?->robots ?? $robotsContent; 23 | } 24 | 25 | $collection->push(new MetaTag('robots', $robotsContent)); 26 | 27 | return $collection; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Tags/SitemapTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 8 | use RalphJSmit\Laravel\SEO\Support\SEOData; 9 | use RalphJSmit\Laravel\SEO\Support\SitemapTag as SitemapTagSupport; 10 | 11 | class SitemapTag extends Collection implements Renderable 12 | { 13 | use RenderableCollection; 14 | 15 | public static function initialize(?SEOData $SEOData = null): static 16 | { 17 | $collection = new static; 18 | 19 | if ($sitemap = config('seo.sitemap')) { 20 | $collection->push(new SitemapTagSupport($sitemap)); 21 | } 22 | 23 | return $collection; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Tags/TitleTag.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Support\Facades\Route; 6 | use RalphJSmit\Laravel\SEO\Support\SEOData; 7 | use RalphJSmit\Laravel\SEO\Support\Tag; 8 | 9 | class TitleTag extends Tag 10 | { 11 | public string $tag = 'title'; 12 | 13 | public function __construct( 14 | string $inner, 15 | ) { 16 | $this->inner = trim($inner); 17 | 18 | if ($this->isCurrentRouteInertiaRoute()) { 19 | $this->attributes['inertia'] = true; 20 | } 21 | } 22 | 23 | public static function initialize(?SEOData $SEOData): ?Tag 24 | { 25 | $title = $SEOData?->title; 26 | 27 | if (! $title) { 28 | return null; 29 | } 30 | 31 | return new static( 32 | inner: $title, 33 | ); 34 | } 35 | 36 | protected function isCurrentRouteInertiaRoute(): bool 37 | { 38 | $currentRoute = Route::current(); 39 | 40 | if (! $currentRoute) { 41 | return false; 42 | } 43 | 44 | return collect(Route::gatherRouteMiddleware($currentRoute))->contains(function (string $middleware) { 45 | return is_subclass_of($middleware, \Inertia\Middleware::class); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Tags/TwitterCard/Summary.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags\TwitterCard; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use Illuminate\Support\HtmlString; 8 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 9 | use RalphJSmit\Laravel\SEO\Support\SEOData; 10 | use RalphJSmit\Laravel\SEO\Support\TwitterCardTag; 11 | 12 | class Summary extends Collection implements Renderable 13 | { 14 | use RenderableCollection; 15 | 16 | public static function initialize(SEOData $SEOData): static 17 | { 18 | $collection = new static; 19 | 20 | if ($SEOData->imageMeta) { 21 | if ($SEOData->imageMeta->width < 144) { 22 | return $collection; 23 | } 24 | 25 | if ($SEOData->imageMeta->height < 144) { 26 | return $collection; 27 | } 28 | 29 | if ($SEOData->imageMeta->width > 4096) { 30 | return $collection; 31 | } 32 | 33 | if ($SEOData->imageMeta->height > 4096) { 34 | return $collection; 35 | } 36 | } 37 | 38 | $collection->push(new TwitterCardTag('card', 'summary')); 39 | 40 | if ($SEOData->image) { 41 | $collection->push(new TwitterCardTag('image', new HtmlString($SEOData->image))); 42 | 43 | if ($SEOData->imageMeta) { 44 | $collection 45 | ->when($SEOData->imageMeta?->width, fn (self $collection): self => $collection->push(new TwitterCardTag('image:width', $SEOData->imageMeta->width))) 46 | ->when($SEOData->imageMeta?->height, fn (self $collection): self => $collection->push(new TwitterCardTag('image:height', $SEOData->imageMeta->height))); 47 | } 48 | } 49 | 50 | return $collection; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Tags/TwitterCard/SummaryLargeImage.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags\TwitterCard; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use Illuminate\Support\HtmlString; 8 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 9 | use RalphJSmit\Laravel\SEO\Support\SEOData; 10 | use RalphJSmit\Laravel\SEO\Support\TwitterCardTag; 11 | 12 | class SummaryLargeImage extends Collection implements Renderable 13 | { 14 | use RenderableCollection; 15 | 16 | public static function initialize(SEOData $SEOData): static 17 | { 18 | $collection = new static; 19 | 20 | if ($SEOData->imageMeta) { 21 | if ($SEOData->imageMeta->width < 300) { 22 | return $collection; 23 | } 24 | 25 | if ($SEOData->imageMeta->height < 157) { 26 | return $collection; 27 | } 28 | 29 | if ($SEOData->imageMeta->width > 4096) { 30 | return $collection; 31 | } 32 | 33 | if ($SEOData->imageMeta->height > 4096) { 34 | return $collection; 35 | } 36 | } 37 | 38 | $collection->push(new TwitterCardTag('card', 'summary_large_image')); 39 | 40 | if ($SEOData->image) { 41 | $collection->push(new TwitterCardTag('image', new HtmlString($SEOData->image))); 42 | 43 | if ($SEOData->imageMeta) { 44 | $collection 45 | ->when($SEOData->imageMeta?->width, fn (self $collection): self => $collection->push(new TwitterCardTag('image:width', $SEOData->imageMeta->width))) 46 | ->when($SEOData->imageMeta?->height, fn (self $collection): self => $collection->push(new TwitterCardTag('image:height', $SEOData->imageMeta->height))); 47 | } 48 | } 49 | 50 | return $collection; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Tags/TwitterCardTags.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace RalphJSmit\Laravel\SEO\Tags; 4 | 5 | use Illuminate\Contracts\Support\Renderable; 6 | use Illuminate\Support\Collection; 7 | use RalphJSmit\Laravel\SEO\Support\RenderableCollection; 8 | use RalphJSmit\Laravel\SEO\Support\SEOData; 9 | use RalphJSmit\Laravel\SEO\Support\TwitterCardTag; 10 | use RalphJSmit\Laravel\SEO\Tags\TwitterCard\Summary; 11 | use RalphJSmit\Laravel\SEO\Tags\TwitterCard\SummaryLargeImage; 12 | 13 | class TwitterCardTags extends Collection implements Renderable 14 | { 15 | use RenderableCollection; 16 | 17 | public static function initialize(SEOData $SEOData): ?static 18 | { 19 | $collection = new static; 20 | 21 | // No generic image that spans multiple pages 22 | if ($SEOData->image && $SEOData->image !== secure_url(config('seo.image.fallback')) && $SEOData->imageMeta?->height > 0) { 23 | // Only one Twitter card can be pushed. The `summary_large_image` card 24 | // is tried first, then it falls back to the normal `summary` card. 25 | $imageMetaWidthDividedByHeight = $SEOData->imageMeta->width / $SEOData->imageMeta->height; 26 | 27 | if ($imageMetaWidthDividedByHeight < 1.5) { 28 | // Summary large card has aspect ratio of 2:1. Aspect ratios of < 1 are closer to 1:1 29 | // then they are to 2:1. Assuming most images are landscape, so fallback to 2:1. 30 | $collection->push(Summary::initialize($SEOData)); 31 | } else { 32 | $collection->push(SummaryLargeImage::initialize($SEOData)); 33 | } 34 | } else { 35 | if ($SEOData->image && ! $SEOData->imageMeta) { 36 | // Image external URL... 37 | $collection->push(SummaryLargeImage::initialize($SEOData)); 38 | } else { 39 | $collection->push(new TwitterCardTag('card', 'summary')); 40 | } 41 | } 42 | 43 | if ($SEOData->openGraphTitle) { 44 | $collection->push(new TwitterCardTag('title', $SEOData->openGraphTitle)); 45 | } elseif ($SEOData->title) { 46 | $collection->push(new TwitterCardTag('title', $SEOData->title)); 47 | } 48 | 49 | if ($SEOData->description) { 50 | $collection->push(new TwitterCardTag('description', $SEOData->description)); 51 | } 52 | 53 | if ($SEOData->twitter_username && $SEOData->twitter_username !== '@') { 54 | $collection->push(new TwitterCardTag('site', $SEOData->twitter_username)); 55 | } 56 | 57 | return $collection; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | use Illuminate\Database\Eloquent\Model; 4 | use RalphJSmit\Laravel\SEO\Support\SEOData; 5 | use RalphJSmit\Laravel\SEO\TagManager; 6 | 7 | if (! function_exists('seo')) { 8 | function seo(Model | SEOData | null $source = null): TagManager 9 | { 10 | $tagManager = app(TagManager::class); 11 | 12 | if ($source) { 13 | $tagManager->for($source); 14 | } 15 | 16 | return $tagManager; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /todo: -------------------------------------------------------------------------------- 1 | - Add support for `twitter:creator` meta tag. --------------------------------------------------------------------------------