` 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
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 | 
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 |
35 |
36 |
37 | {!! seo()->for($post) !!}
38 |
39 | {{-- No need to separately render a tag or any other meta tags! --}}
40 |
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 | 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 `` 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. ``
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 `` tag, so you don't have to render that manually. Additionally, it takes care of things automatically adding the `inertia` attribute to your `` 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 `` tag and all the related tags (OpenGraph, Twitter, etc.)
232 | 2. `description`: this will be used for the `` 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 `` 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 `` 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 ``
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 `` 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:
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 | 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 `` 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. ``
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 | 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 }} $value) : ?> {{ $name }}="{{ $value }}">{{ $inner }}{{ $tag }}>
--------------------------------------------------------------------------------
/src/Facades/SEOManager.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | 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 | 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 | $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 | '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 | 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 | 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 |
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 | attributes['hreflang'] = $hreflang;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/Support/HasSEO.php:
--------------------------------------------------------------------------------
1 | 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 | width = $width;
32 | $this->height = $height;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Support/LinkTag.php:
--------------------------------------------------------------------------------
1 | attributes['rel'] = $rel;
14 | $this->attributes['href'] = $href;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Support/MetaContentTag.php:
--------------------------------------------------------------------------------
1 | attributes['property'] = $property;
14 | $this->attributes['content'] = $content;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Support/MetaTag.php:
--------------------------------------------------------------------------------
1 | attributes['name'] = $name;
16 | $this->attributes['content'] = $content;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Support/OpenGraphTag.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | $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 | 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 | '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 | $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 | 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 | 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 | 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 | alternates) {
17 | return null;
18 | }
19 |
20 | return new static($SEOData->alternates);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Tags/AuthorTag.php:
--------------------------------------------------------------------------------
1 | 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 | push(new LinkTag('canonical', $SEOData->canonical_url ?? $SEOData->url));
21 | }
22 |
23 | return $collection;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Tags/DescriptionTag.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | 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 | 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 | robots ?? $robotsContent;
23 | }
24 |
25 | $collection->push(new MetaTag('robots', $robotsContent));
26 |
27 | return $collection;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Tags/SitemapTag.php:
--------------------------------------------------------------------------------
1 | push(new SitemapTagSupport($sitemap));
21 | }
22 |
23 | return $collection;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Tags/TitleTag.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | 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 | 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 | for($source);
14 | }
15 |
16 | return $tagManager;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/todo:
--------------------------------------------------------------------------------
1 | - Add support for `twitter:creator` meta tag.
--------------------------------------------------------------------------------