imran / laravel-seo-engine
Model-level SEO engine for Laravel. Head tags, JSON-LD, sitemaps, redirects.
Requires
- php: ^8.2
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- spatie/laravel-data: ^4.0
Requires (Dev)
- larastan/larastan: ^2.11
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0
- pestphp/pest: ^2.0
- phpstan/phpstan: ^1.10
- phpstan/phpstan-strict-rules: ^1.6
README
A powerful, database-driven SEO package for Laravel. Replace Yoast, RankMath, and Redirection with one Eloquent-native solution.
Features:
- Dynamic meta tags with placeholder resolution
{title},{category.name} - Auto JSON-LD schemas: Article, BreadcrumbList, Product
- Database redirects: exact, wildcard, regex, 410 Gone
- XML sitemaps with queue jobs + taxonomy grouping
- SEO audit command for missing meta, duplicates, broken links
- Headless/API support for Next.js, Nuxt, React (SSR)
Installation
composer require imran/laravel-seo-engine php artisan vendor:publish --tag="seo-migrations" php artisan migrate php artisan vendor:publish --tag="seo-config"
Quick Start
1. Add the Seoable trait to your model
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Imran\SeoEngine\Seoable; use Imran\SeoEngine\SeoConfig; use Imran\SeoEngine\Schemas\ArticleSchema; class Post extends Model { use Seoable; public function seoConfig(): SeoConfig { return SeoConfig::make() ->title('{title} | {category.name|Blog}') ->description('{excerpt|Read more on ' . config('app.name') . '}') ->schema([ArticleSchema::class]) ->sitemap(priority: 0.8, changefreq: 'weekly'); } public function category() { return $this->belongsTo(Category::class); } }
2. Render SEO meta tags in your Blade layout
<!DOCTYPE html> <html> <head> <x-seo::head :model="$post" /> </head> <body> {{ $slot }} </body> </html>
Placeholder Syntax
Placeholders are resolved from model attributes, relations, and custom methods.
| Syntax | Description | Example |
|---|---|---|
{title} |
Model attribute | {title} → "My Post" |
{category.name} |
Relation path | {category.name} → "Laravel" |
{expert|Default} |
Fallback if null/empty | {excerpt|No excerpt} |
{word_count} |
Custom placeholder from method | From seoPlaceholders() |
You can define custom placeholders on your model:
public function seoPlaceholders(): array { return [ 'word_count' => fn($post) => str_word_count(strip_tags($post->content)), 'site_name' => config('app.name'), ]; }
JSON-LD Schemas
Built-in schema types with automatic placeholder resolution:
use Imran\SeoEngine\Schemas\ArticleSchema; use Imran\SeoEngine\Schemas\BreadcrumbListSchema; use Imran\SeoEngine\Schemas\ProductSchema; SeoConfig::make() ->schema([ArticleSchema::class]) ->schema(new BreadcrumbListSchema( relationPath: 'category.parent', nameField: 'name' ));
Available schemas:
ArticleSchema— Article with author, publisher, datePublished, imageBreadcrumbListSchema— Breadcrumb trail with home → category → currentProductSchema— Product with SKU, brand, offers, price
SEO Overrides
Override SEO meta for individual records via the database:
$post->seoOverrides()->create([ 'title' => 'Custom SEO Title', 'description' => 'Custom description', 'robots' => 'noindex,follow', 'canonical' => 'https://example.com/custom-url', ]);
Redirects (301, 302, 410)
Manage redirects with exact, prefix/wildcard, and regex matching:
use Imran\SeoEngine\Models\SeoRedirect; // Exact 301 redirect SeoRedirect::create([ 'source' => '/old-page', 'destination' => '/new-page', 'status_code' => 301, ]); // Prefix wildcard: /blog/* → /news/* SeoRedirect::create([ 'source' => '/blog/*', 'destination' => '/news/*', 'type' => 'prefix', 'status_code' => 301, ]); // Regex with capture: /post/123 → /articles/123 SeoRedirect::create([ 'source' => '#^/post/(\d+)$#', 'destination' => '/articles/$1', 'type' => 'regex', 'status_code' => 301, ]); // 410 Gone — tell search engines it's permanently deleted SeoRedirect::create([ 'source' => '/deleted-page', 'destination' => null, 'status_code' => 410, ]); // Preserve query parameters SeoRedirect::create([ 'source' => '/campaign', 'destination' => '/landing', 'preserve_query' => true, 'status_code' => 302, ]);
Import from .htaccess
php artisan seo:import-redirects storage/old.htaccess php artisan seo:flush-redirects
Redirect Resolution Order
| Type | Query | Speed | Use Case |
|---|---|---|---|
| exact | Indexed WHERE source = ? |
~1ms | 99% of redirects |
| prefix | Load all, str_starts_with |
~3ms | /blog/* → /news/* |
| regex | Load all, preg_match |
~5ms | Legacy ID patterns |
Prefix and regex lists are cached as a whole (see redirects.cache_ttl) rather than re-queried per request, so redirect table size mainly affects cache-refresh cost, not per-request cost.
Security note: a
sourceregex pattern is evaluated against every matching request. This package has no HTTP surface for creating redirects (only Artisan commands and direct Eloquent use), sosourcevalues are only ever developer/admin-supplied. If your application exposes redirect management to less-trusted users, validate patterns before saving them - a catastrophic-backtracking regex can hang the request that triggers it.
Sitemaps
Define sitemap groups in your AppServiceProvider:
use Imran\SeoEngine\Sitemap\SitemapManager; public function boot(): void { app(SitemapManager::class)->group('posts', function ($group) { $group->model(Post::class, fn($q) => $q->published()) ->priority(0.8) ->changefreq('weekly') ->taxonomyGroup(fn($post) => $post->category?->slug) ->url(fn($post) => route('posts.show', $post)) ->lastmod(fn($post) => $post->updated_at); }); app(SitemapManager::class)->group('pages', function ($group) { $group->model(Page::class) ->priority(0.5) ->changefreq('monthly'); }); }
Generate sitemaps:
php artisan seo:generate-sitemap
The sitemap index is automatically served at /sitemap.xml.
/sitemap.xml → index
/storage/sitemaps/posts-tech-1.xml
/storage/sitemaps/posts-news-1.xml
/storage/sitemaps/pages-default-1.xml
SEO Audit
Find and fix SEO issues across your content:
# Find all issues php artisan seo:audit # Auto-generate missing descriptions from content php artisan seo:audit --fix # Check for broken internal links php artisan seo:audit --check-links # Audit a specific model php artisan seo:audit --model="App\Models\Post" # Limit records checked php artisan seo:audit --model="App\Models\Post" --limit=10000
What the audit checks:
| Issue | Level | Auto-Fix |
|---|---|---|
| Missing title | Error | No |
| Missing meta description | Error | --fix from content |
| Title < 30 or > 60 chars | Warning | No |
| Description < 70 or > 160 | Warning | No |
| Duplicate titles | Error | No |
| Duplicate descriptions | Warning | No |
| Images without alt | Warning | No |
| Multiple H1 tags | Warning | No |
| Important page = noindex | Error | No |
| Broken internal links | Error | --check-links |
Headless / API Support (Next.js, Nuxt, React)
Use the API endpoint to fetch SEO data for any model:
// config/seo-engine.php 'api' => [ 'enabled' => true, 'middleware' => ['api'], 'models' => [ 'post' => \App\Models\Post::class, 'page' => \App\Models\Page::class, 'product' => \App\Models\Product::class, ], ],
Fetch SEO data:
GET /api/seo/post/123
Response:
{
"title": "My Post | Laravel",
"description": "Meta description here...",
"robots": "index,follow",
"canonical": "https://site.com/posts/123",
"open_graph": {
"title": "My Post | Laravel",
"description": "Meta description here...",
"type": "article",
"url": "https://site.com/posts/123",
"image": "https://site.com/featured.jpg",
"site_name": "My Site"
},
"twitter": {
"card": "summary_large_image",
"title": "My Post | Laravel",
"description": "Meta description here...",
"image": "https://site.com/featured.jpg"
},
"json_ld": [
{"@context": "https://schema.org", "@type": "Article", ...}
]
}
Resolve by path:
GET /api/seo/route?path=/posts/hello-world
Next.js example:
// app/posts/[slug]/page.tsx import { Metadata } from 'next'; async function getSeo(id: number) { const res = await fetch(`${process.env.LARAVEL_API}/api/seo/post/${id}`, { next: { revalidate: 3600 } }); return res.json(); } export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> { const post = await fetch(`${process.env.LARAVEL_API}/api/posts/${params.slug}`).then(r => r.json()); const seo = await getSeo(post.id); return { title: seo.title, description: seo.description, robots: seo.robots, alternates: { canonical: seo.canonical }, openGraph: seo.open_graph, twitter: seo.twitter, }; }
Export Redirects for Edge (Vercel, Netlify, CloudFlare):
# Vercel/Next.js redirects (creates redirects.json) php artisan seo:export-redirects --format=vercel # Netlify _redirects file php artisan seo:export-redirects --format=netlify # CloudFlare bulk redirects php artisan seo:export-redirects --format=cloudflare # Nginx config php artisan seo:export-redirects --format=nginx
Then in your Next.js config:
const redirects = require('./redirects.json'); module.exports = { async redirects() { return redirects; }, }
404 Logging
Non-existent URLs are automatically logged to the seo_404_logs table. Query it directly to find broken links, then create redirects for the ones worth fixing:
use Imran\SeoEngine\Models\Seo404Log; use Imran\SeoEngine\Models\SeoRedirect; $topMisses = Seo404Log::orderByDesc('hits')->limit(20)->get();
Artisan Commands
| Command | Description |
|---|---|
php artisan seo:generate-sitemap |
Dispatch sitemap queue jobs |
php artisan seo:audit |
Find SEO issues |
php artisan seo:audit --fix |
Auto-generate descriptions |
php artisan seo:import-redirects {file} |
Import redirects from .htaccess/CSV |
php artisan seo:flush-redirects |
Clear redirect cache |
php artisan seo:export-redirects |
Export redirects for Next.js/Vercel |
Configuration
Publish and customize the config:
php artisan vendor:publish --tag="seo-config"
return [ // Used as a fallback (resolved against the model, so {title} etc. still // works) whenever a model's seoConfig() doesn't set title/description/ // canonical itself. Note: 'robots' always defaults to 'index,follow' at // the SeoConfig level - override it per-model via ->robots(...) instead. 'defaults' => [ 'title' => '{title} | ' . config('app.name'), 'description' => null, 'canonical' => null, 'publisher_name' => config('app.name'), 'publisher_logo' => null, // e.g. '/images/logo.png' - used by ArticleSchema ], 'sitemap' => [ 'enabled' => true, 'cache_ttl' => 3600, 'chunk_size' => 10000, 'path' => 'storage/app/public/sitemaps', // relative to base_path() 'public_path' => '/storage/sitemaps', ], 'redirects' => [ 'enabled' => true, 'log_404s' => env('SEO_LOG_404S', true), 'cache_ttl' => 3600, 'track_hits' => true, ], 'api' => [ 'enabled' => env('SEO_API_ENABLED', true), 'middleware' => ['api'], 'models' => [ // 'post' => \App\Models\Post::class, // 'page' => \App\Models\Page::class, // 'product' => \App\Models\Product::class, ], ], 'audit' => [ 'title_min_length' => 30, 'title_max_length' => 60, 'description_min_length' => 70, 'description_max_length' => 160, 'check_images_alt' => true, 'check_h1_count' => true, ], // Emitted as og:* / twitter:* meta tags by <x-seo::head /> and included // in the /api/seo/{model}/{id} response. 'twitter' => [ 'site' => null, // @yourtwitter 'creator' => null, 'card' => 'summary_large_image', ], 'opengraph' => [ 'type' => 'website', 'site_name' => config('app.name'), ], ];
Testing
composer test
Architecture
┌─────────────────────────────────────────────┐
│ Seoable trait │
│ getSeoAttribute() → SeoManager::forModel() │
└─────────────────────┬───────────────────────┘
│
┌─────────────────────▼───────────────────────┐
│ SeoManager │
│ - Merges DB overrides with seoConfig() │
│ - Resolves placeholders │
└──────┬──────────┬───────────┬───────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌──────────┐
│ SeoConfig│ │Schema │ │Override │
│ (config) │ │Classes │ │(DB rows) │
└──────────┘ └────────┘ └──────────┘
License
The MIT License (MIT). Please see License File for more information.