imran/laravel-seo-engine

Model-level SEO engine for Laravel. Head tags, JSON-LD, sitemaps, redirects.

Maintainers

Package info

github.com/grim-reapper/laravel-seo-engine

pkg:composer/imran/laravel-seo-engine

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-19 19:45 UTC

This package is auto-updated.

Last update: 2026-07-19 19:50:04 UTC


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, image
  • BreadcrumbListSchema — Breadcrumb trail with home → category → current
  • ProductSchema — 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 source regex pattern is evaluated against every matching request. This package has no HTTP surface for creating redirects (only Artisan commands and direct Eloquent use), so source values 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.