laravel-seo-engine maintained by imran
Laravel SEO Engine
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.