achyutn / laravel-seo
An opinionated Laravel package to manage SEO easily.
Requires
- php: >=8.2
- ralphjsmit/laravel-seo: ^1.7
Requires (Dev)
- larastan/larastan: ^3.8
- laravel/pint: ^1.26
- orchestra/testbench: ^10.8
- pestphp/pest: ^3.8
- pestphp/pest-plugin-laravel: ^3.2
- pestphp/pest-plugin-livewire: ^3
- phpstan/phpstan: ^2.1
- rector/rector: ^2.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An opinionated Laravel SEO package to manage SEO on Eloquent models. This package makes use of an un-opinionated package ralphjsmit/laravel-seo and adds a pattern of auto-generating SEO metadata and sitemaps for Eloquent models with customization.
This package lets you generate SEO metadata and sitemap directly from Eloquent models without manually wiring SEO data in controllers or views. It supports multiple schema types including Blog, Product, and generic Page schema.
Installation
You can install the package via composer:
composer require achyutn/laravel-seo
You must publish package config and migration stubs to your application using the following command:
php artisan vendor:publish --tag="laravel-seo"
This generates a config/seo.php configuration file and a migration file
for the seo table in your database.
ralphjsmit/laravel-seowill also be installed as a dependency. Publishing the configuration and migration of that package will break the functionality of this package.
If you want full customization, I recommend you to check out that package directly from its GitHub repository.
Next, run the migration to create the seo table:
php artisan migrate
Usage
To use the package, simply add the AchyutN\LaravelSEO\Traits\InteractsWithSEO trait to any Eloquent model
you want to manage SEO for.
use AchyutN\LaravelSEO\Traits\InteractsWithSEO; class Post extends Model { use InteractsWithSEO; // ... }
This will automatically use the default columns (title, description, etc). You can also auto-generate breadcrumbs and schema markup for your model by implementing the respective methods or traits.
Generate SEO Entries
After adding the trait to your model, you need to generate SEO entries for existing records. You can do this by running the following Artisan command:
php artisan seo:generate
It also supports re-generating SEO entries for models that already have SEO data.
You can use the --regenerate flag to update existing entries:
php artisan seo:generate --regenerate
Schema Types
This package supports multiple schema types using traits:
- BlogSchema for blog posts
- ProductSchema for products (e-commerce)
- PageSchema for generic pages
Each schema trait implements the buildSchema(SchemaCollection $schema): SchemaCollection method required by the AchyutN\LaravelSEO\Contracts\HasMarkup interface and resolves SEO data from your model internally. To use any schema, add the corresponding trait to your model along with the interface AchyutN\LaravelSEO\Contracts\HasMarkup.
Blog Schema
To use the Blog schema, add the AchyutN\LaravelSEO\Schemas\BlogSchema trait to your model:
use AchyutN\LaravelSEO\Contracts\HasMarkup; use AchyutN\LaravelSEO\Schemas\BlogSchema; class Post extends Model implements HasMarkup { use InteractsWithSEO; use BlogSchema; // ... }
Product Schema
To use the Product schema, add the AchyutN\LaravelSEO\Schemas\ProductSchema trait to your model:
use AchyutN\LaravelSEO\Contracts\HasMarkup; use AchyutN\LaravelSEO\Schemas\ProductSchema; class Product extends Model implements HasMarkup { use InteractsWithSEO; use ProductSchema; // ... }
Page Schema
To use the Page schema, add the AchyutN\LaravelSEO\Schemas\PageSchema trait to your model:
use AchyutN\LaravelSEO\Contracts\HasMarkup; use AchyutN\LaravelSEO\Schemas\PageSchema; class Page extends Model implements HasMarkup { use InteractsWithSEO; use PageSchema; // ... }
Breadcrumbs
You can also manage breadcrumbs by defining a breadcrumbs() method on your model that returns an array of Breadcrumb items.
use AchyutN\LaravelSEO\Data\Breadcrumb; class Post extends Model { use InteractsWithSEO; public function breadcrumbs(): array { return [ new Breadcrumb(label: 'Home', url: route('home')), new Breadcrumb(label: 'Blog', url: route('blog.index')), new Breadcrumb(label: $this->title, url: route('blog.show', $this)), ]; } }
Rendering SEO Tags
To render the SEO tags in your views, you can use the following Blade directive:
{!! seo($model) !!}
Replace $model with the instance of your Eloquent model.
Sitemaps
Once you have models using the InteractsWithSEO trait, you automatically have them included in your sitemap.
You can access the sitemap in two formats:
- XML Sitemap:
/sitemap.xml - TXT Sitemap:
/sitemap.txt
The XML sitemap will be auto-injected in your blade layout along with the metadata.
Sitemap Images
By default, the XML sitemap includes a single image per URL using the model's imageValue() or $imageColumn.
To include multiple images for a URL, define a sitemapImages(): array method on your model. You can return:
- An array of string URLs or storage paths:
['https://example.com/images/1.jpg', 'images/2.jpg'] - An array of strongly-typed
SitemapImageobjects:[SitemapImage::make(url: 'https://...', title: '...', caption: '...')] - An array of associative arrays:
[['loc' => 'https://...', 'title' => '...']]
When multiple plain string images are provided, only the <image:loc> tag is output for each image, following Google's recommended practice. If custom titles, captions, geo-locations, or licenses are provided via SitemapImage DTOs or associative arrays, they are rendered accordingly.
use AchyutN\LaravelSEO\Data\SitemapImage; class Post extends Model { use InteractsWithSEO; public function sitemapImages(): array { return [ SitemapImage::make( url: 'https://example.com/images/post-1.jpg', title: 'Featured Image', caption: 'Post preview image' ), 'https://example.com/images/post-2.jpg', ]; } }
Sitemap Videos
To include video information (such as YouTube embeds or direct MP4 files) in your XML sitemap, define a sitemapVideos(): array method on your model.
You can return either SitemapVideo DTOs or raw associative arrays.
Each video entry must include thumbnail_loc, title, description, and either player_loc (e.g. YouTube embed URL) or content_loc (direct media file URL).
use AchyutN\LaravelSEO\Data\SitemapVideo; class Post extends Model { use InteractsWithSEO; public function sitemapVideos(): array { return [ SitemapVideo::make( thumbnailLoc: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg', title: 'How to cook pasta', description: 'A step-by-step guide to cooking perfect pasta.', playerLoc: 'https://www.youtube.com/embed/dQw4w9WgXcQ', duration: 600, publicationDate: '2024-01-15T08:00:00+00:00', familyFriendly: true, ), ]; } }
Supported video fields:
| Field | Required | Type | Description |
|---|---|---|---|
thumbnail_loc |
Yes | string |
URL to the video thumbnail image |
title |
Yes | string |
Title of the video (max 100 chars) |
description |
Yes | string |
Description of the video (max 2048 chars) |
player_loc |
Yes* | string |
URL to video player / embed (e.g. YouTube embed URL) |
content_loc |
Yes* | string |
Direct URL to the video file (.mp4, .mov, etc.) |
duration |
No | int |
Duration in seconds (1 - 28800) |
publication_date |
No | string |
ISO 8601 publication date |
expiration_date |
No | string |
ISO 8601 expiration date |
rating |
No | float |
Video rating (0.0 - 5.0) |
view_count |
No | int |
Number of views |
family_friendly |
No | bool |
Whether the video is family-friendly |
requires_subscription |
No | bool |
Whether a subscription is required |
live |
No | bool |
Whether the video is a live stream |
* At least one of player_loc or content_loc is required by Google. Entries missing both are skipped, and SitemapVideo DTOs throw an InvalidArgumentException.
Google's length and range limits are enforced: title and description are truncated to 100 and 2048 characters respectively, and out-of-range duration, rating, and view_count values are omitted from raw array entries (SitemapVideo DTOs reject them).
Site-level Schema (GEO)
The package can emit site-level Organization and WebSite JSON-LD on every page so AI search engines can identify and cite your brand. It is opt-in; enable it once in config/seo.php:
'schema' => [ 'organization' => [ 'enabled' => true, 'name' => config('app.name'), 'url' => config('app.url'), 'logo' => 'https://example.com/logo.png', 'same_as' => [ 'https://x.com/acme', 'https://www.linkedin.com/company/acme', ], ], 'website' => [ 'enabled' => true, 'search_url' => '/blog?search={search_term_string}', ], ],
Set enabled to true to activate it, or leave it false if your application already emits these entities (for example through a SEOManager transformer).
Answer Engine Schema (AEO)
Define any of the following optional methods on your model and the matching schema is added automatically:
/** @return array<int, array{question: string, answer: string}> */ public function seoFaqs(): array { return [ ['question' => 'What is Laravel SEO?', 'answer' => 'A package that generates metadata and schema from models.'], ]; } /** @return array{name: string, description: string, steps: array<int, array{name: string, text: string}>} */ public function seoHowTo(): array { return [ 'name' => 'Install the package', 'description' => 'Install the package and generate SEO metadata.', 'steps' => [ ['name' => 'Require', 'text' => 'Run composer require achyutn/laravel-seo.'], ['name' => 'Backfill', 'text' => 'Run php artisan seo:generate.'], ], ]; } /** @return array<int, string> */ public function seoSpeakable(): array { return ['#summary', '.article-body']; }
seoFaqs()emitsFAQPageschema (featured snippets / People Also Ask).seoHowTo()emitsHowToschema.seoSpeakable()emitsSpeakableSpecificationfor voice assistants.
Internationalisation and Article Depth
public function seoLocale(): string { return app()->getLocale(); } /** @return array<int, array{hreflang: string, url: string}> */ public function seoAlternates(): array { return [ ['hreflang' => 'en', 'url' => url('/en/blog/'.$this->getKey())], ['hreflang' => 'fr', 'url' => url('/fr/blog/'.$this->getKey())], ]; } public function seoArticleBody(): string { return strip_tags((string) $this->content); }
seoAlternates() is emitted both as <link rel="alternate" hreflang> tags and as <xhtml:link> entries in the XML sitemap. seoArticleBody() feeds the articleBody field so AI engines can cite the full text.
Managed robots.txt, Model Discovery and Description Limits
config/seo.php exposes a few operational extras:
// Directories scanned by `php artisan seo:generate` for InteractsWithSEO models. 'model_paths' => [ app_path('Models'), ], // Serve a managed /robots.txt that always includes a Sitemap directive. 'robots_txt' => [ 'enabled' => true, 'user_agent' => '*', 'allow' => ['/'], 'disallow' => ['/admin', '/pulse'], ], // Optionally trim long meta descriptions on a word boundary. 'description' => [ 'limit' => 160, ],
The robots_txt endpoint is disabled by default so it never conflicts with an application-defined robots file. Enable it on greenfield projects.
Customization
This package resolves SEO data using a simple priority order:
- A custom
*Value()method, if defined on the model - A custom
$*Columnproperty, if defined on the model - A default column name
You can customize each field independently.
Resolution Priority
For each field:
*Value()method$*Columnproperty- Default column name
Methods always take precedence over properties.
Supported Override Methods
Define these on your model when the value is computed or derived. Organized by context:
Common Methods
| Method | Return Type | When to use |
|---|---|---|
titleValue() |
?string |
Computed or dynamic title |
descriptionValue() |
?string |
Generated description |
categoryValue() |
?string |
Derived category |
imageValue() |
?string |
Media URL |
authorValue() |
?string |
Relation-based author |
authorUrlValue() |
?string |
Author profile link |
publisherValue() |
?string |
Brand or company name |
publisherUrlValue() |
?string |
Publisher homepage |
tagsValue() |
array<string> |
Normalized tags |
urlValue() |
?string |
The page URL |
publishedAtValue() |
?Carbon |
Computed publish date |
modifiedAtValue() |
?Carbon |
Computed updated date |
pageTypeValue() |
?string |
Custom page type |
Product-Specific Methods
| Method | Return Type | When to use |
|---|---|---|
brandValue() |
?string |
Product brand |
priceValue() |
?float |
Product price |
discountPriceValue() |
?float |
Discounted price |
currencyValue() |
?string |
Price currency |
availabilityValue() |
bool |
Product availability |
skuValue() |
?string |
Product SKU |
Page-Specific Methods
| Method | Return Type | When to use |
|---|---|---|
pageTypeValue() |
?string |
Type of page (landing, about, etc.) |
Example
class Product extends Model { use InteractsWithSEO; protected function priceValue(): ?float { return $this->price; } protected function availabilityValue(): bool { return $this->is_available; } }
Supported Properties
Define these on your Eloquent model to change which column is used. Organized by context:
Common Properties
| Property | Default | Purpose |
|---|---|---|
$titleColumn |
title |
Page title |
$descriptionColumn |
description |
Meta description |
$categoryColumn |
category |
Content category |
$imageColumn |
image |
Open Graph image |
$authorColumn |
author |
Author name |
$authorUrlColumn |
author_url |
Author profile URL |
$publisherColumn |
publisher |
Publisher name |
$publisherUrlColumn |
publisher_url |
Publisher URL |
$tagsColumn |
tags |
Tags or keywords |
$urlColumn |
url |
The page URL |
$publishedAtColumn |
created_at |
Publish date |
$modifiedAtColumn |
updated_at |
Updated date |
$pageTypeColumn |
page_type |
Page type |
Product-Specific Properties
| Property | Default | Purpose |
|---|---|---|
$brandColumn |
brand |
Product brand |
$priceColumn |
price |
Product price |
$discountPriceColumn |
discount_price |
Discounted price |
$currencyColumn |
currency |
Price currency |
$availabilityColumn |
is_available |
Product availability |
$skuColumn |
sku |
Product SKU |
Example
class Product extends Model { use InteractsWithSEO; protected string $priceColumn = 'product_price'; protected string $brandColumn = 'product_brand'; }
Notes
- All overrides are optional
- Do not define both a property and a method unless intentional
- IDEs and static analyzers understand all extension points via PHPDoc
License
This package is open-sourced software licensed under the MIT license.
Contributing
Contributions are welcome! Please create a pull request or open an issue if you find any bugs or have feature requests.
Support
If you find this package useful, please consider starring the repository on GitHub to show your support.