Skip to content

step2dev/lazy-seo-tools

Repository files navigation

Lazy SEO Tools

Core-first SEO toolkit for Laravel 11/12/13.

Lazy SEO Tools starts small: page meta, model SEO, redirects, sitemaps and JSON-LD. Heavier modules like crawler, monitoring, IndexNow, content intelligence, OpenGraph image generation, Livewire admin and API are opt-in layers.

Built with spatie/laravel-package-tools.

Documentation

Requirements

  • PHP ^8.2
  • Laravel ^11.0 or ^12.0 or ^13.0
  • spatie/laravel-translatable

Optional integrations:

  • livewire/livewire ^3.6|^4.0 — only for Livewire/admin UI.
  • intervention/image ^3.11 — only for OpenGraph image generation.
  • spatie/laravel-sitemap — optional companion package if your app already uses Spatie sitemap tooling.

Installation

Current GitHub source install:

composer config repositories.lazy-seo-tools vcs https://github.com/step2dev/lazy-seo-tools
composer require step2dev/lazy-seo-tools:dev-main

Stable Packagist install after release:

composer require step2dev/lazy-seo-tools

Install optional integrations only when you enable the matching advanced layer:

composer require livewire/livewire
composer require intervention/image

Publish the config:

php artisan vendor:publish --tag="lazy-seo-config"

Publish migrations:

php artisan vendor:publish --tag="lazy-seo-migrations"

Run migrations:

php artisan migrate

The package is auto-discovered by Laravel through:

Step2dev\LazySeoTools\LazySeoServiceProvider::class

Quick start

Render SEO tags in your layout:

<head>
    {!! seo_meta() !!}
</head>

seo_meta() is a small helper around seo()->renderMetaTags() for the common layout use case.

Set SEO data for the current response:

seo()
    ->title('Laravel SEO Tools')
    ->description('SEO toolkit for Laravel applications')
    ->canonical(url()->current())
    ->image(asset('storage/og/seo-tools.jpg'))
    ->type('article')
    ->robots(['index', 'follow']);

Use presets for common page types:

seo()->preset('article', $post);
seo()->preset('product', $product);
seo()->preset('homepage');

Select a model or URL and merge runtime overrides:

seo()
    ->for($post)
    ->with([
        'title' => $post->title,
        'description' => $post->excerpt,
    ]);

You can also resolve and render directly:

$data = seo()->resolve(url: request()->path());

return seo()->meta();

One-off render with overrides:

{!! seo_meta(overrides: [
    'title' => 'About us',
    'description' => 'Learn more about our company.',
    'canonical_url' => route('about'),
]) !!}

Configuration

The config file is published to:

config/lazy-seo.php

The published config is intentionally compact. Advanced defaults for sitemap chunking, audit weights, crawler limits, queue settings and integrations are merged internally, so you only add those keys when your application needs to override them.

Table names

Table names are configured directly in the published config file and intentionally do not use env().

'tables' => [
    'seo' => 'seo',
    'seo_redirects' => 'seo_redirects',
    'seo_templates' => 'seo_templates',
    'seo_scans' => 'seo_scans',
    'seo_scan_issues' => 'seo_scan_issues',
    'seo_indexing_logs' => 'seo_indexing_logs',
],

Change them before running migrations if your application needs custom names.

Runtime settings like routes, sitemap path, crawler limits, queue settings, alerts, IndexNow and AI settings may use env() inside the config. AI calls are disabled by default and return safe empty fallbacks until explicitly enabled with a token.

Feature flags

The package is split into a small core and opt-in advanced layers. New installs should keep only the required modules enabled.

Core defaults:

'features' => [
    'meta' => true,
    'schema' => true,
    'redirects' => true,
    'sitemap' => true,

    // Advanced opt-in layers
    'crawler' => false,
    'monitoring' => false,
    'indexnow' => false,
    'content_intelligence' => false,
    'og_image' => false,
    'livewire' => false,
    'admin' => false,
    'api' => false,
],

Enable an advanced layer only when the app actively uses it. For example, monitoring needs the crawler, and the admin UI needs Livewire:

'features' => [
    'crawler' => true,
    'monitoring' => true,
    'livewire' => true,
    'admin' => true,
],

Blade usage

Render all meta tags

{!! seo_meta() !!}

Components

<x-lazy-seo-title />
<x-lazy-seo-meta />
<x-lazy-seo-og />
<x-lazy-seo-twitter />
<x-lazy-seo-jsonld type="article" :data="$schema" />

Supported component aliases:

<x-lazy-seo-schema type="article" :data="$schema" />
<x-lazy-seo::json-ld type="article" :data="$schema" />
<x-lazy-seo::schema type="article" :data="$schema" />

Package views are loaded under the lazy-seo namespace. Publish them only when the application needs to customize package Blade output:

php artisan vendor:publish --tag="lazy-seo-views"

Published views are placed in resources/views/vendor/lazy-seo.

Model SEO

Add the trait to an Eloquent model:

use Illuminate\Database\Eloquent\Model;
use Step2dev\LazySeoTools\Concerns\HasSeo;

class Post extends Model
{
    use HasSeo;
}

Save SEO data:

$post->updateSeo([
    'title' => ['en' => 'Post title', 'uk' => 'Назва статті'],
    'description' => ['en' => 'Post description', 'uk' => 'Опис статті'],
    'keywords' => ['en' => 'laravel, seo'],
    'canonical_url' => route('posts.show', $post),
    'robots' => ['index', 'follow'],
    'indexable' => true,
]);

Resolve SEO data:

$data = $post->resolvedSeo();

$data->title;
$data->description;
$data->robotsContent();

Array output:

$seo = $post->seoData();

URL SEO

Create SEO data for a URL:

use Step2dev\LazySeoTools\Models\Seo;

Seo::query()->create([
    'url' => '/pricing',
    'title' => ['en' => 'Pricing'],
    'description' => ['en' => 'Simple pricing for your product'],
    'robots' => ['index', 'follow'],
    'indexable' => true,
]);

Resolve URL SEO:

$seo = seo()->forUrl('/pricing');
$data = seo()->resolve(url: '/pricing');

Resolver priority

SeoManager::resolve() merges data in this order:

  1. config defaults;
  2. URL SEO;
  3. model SEO;
  4. template data;
  5. fluent API values;
  6. manual overrides.

Example:

$data = seo()
    ->title('Manual title')
    ->resolve(model: $post, url: '/blog/example', overrides: [
        'description' => 'Custom description',
    ]);

SEO templates

SEO templates are stored in the seo_templates table.

Supported translatable fields:

  • title
  • description
  • keywords

Placeholders use {key} syntax.

use Step2dev\LazySeoTools\Models\SeoTemplate;

SeoTemplate::query()->create([
    'name' => 'post',
    'title' => ['en' => '{title} | {site_name}'],
    'description' => ['en' => '{excerpt}'],
    'payload' => [
        'type' => 'article',
    ],
    'enabled' => true,
]);

Use a template:

seo()->template('post', [
    'title' => $post->title,
    'excerpt' => $post->excerpt,
]);

Redirects

Redirect implementation is extracted to step2dev/lazy-seo-redirects. lazy-seo-tools keeps only backward-compatible wrappers and legacy command aliases.

Register it in your application middleware stack or route middleware:

use Step2dev\LazySeoRedirect\Http\Middleware\HandleSeoRedirects;

Example in bootstrap/app.php:

->withMiddleware(function ($middleware) {
    $middleware->web(append: [
        \Step2dev\LazySeoRedirect\Http\Middleware\HandleSeoRedirects::class,
    ]);
})

Create a redirect:

use Step2dev\LazySeoRedirect\Models\SeoRedirect;

SeoRedirect::query()->create([
    'old_url' => '/old-page',
    'new_url' => '/new-page',
    'status_code' => 301,
    'enabled' => true,
]);

Supported status codes:

  • 301
  • 302
  • 307
  • 308
  • 410

Supported redirect types:

// Exact
'old_url' => '/old-page'

// Wildcard, enabled by default
'old_url' => '/blog/*'

// Regex, disabled by default for safety
'old_url' => '#^old/(post-[0-9]+)$#',
'is_regex' => true,
'new_url' => '/new/$1'

Enable regex redirects only after reviewing imported/admin-created patterns:

'redirects' => [
    'regex_enabled' => env('LAZY_SEO_REDIRECT_REGEX_ENABLED', false),
    'wildcard_enabled' => env('LAZY_SEO_REDIRECT_WILDCARD_ENABLED', true),
],

Redirect CSV import/export

Import:

php artisan lazy-seo-redirects:import redirects.csv

Import without updating existing rows:

php artisan lazy-seo-redirects:import redirects.csv --no-update

Export:

php artisan lazy-seo-redirects:export redirects.csv

CSV format:

old_url,new_url,status_code,enabled,is_regex
old-page,/new-page,301,1,0
#^old/(post-[0-9]+)$#,/new/$1,301,1,1
removed-page,,410,1,0

Sitemap

Generate sitemap files:

php artisan lazy-seo:sitemap

Custom public path:

php artisan lazy-seo:sitemap --path=sitemaps/sitemap.xml

Warm sitemap cache:

php artisan lazy-seo:sitemap --warm
php artisan lazy-seo:sitemap:warm

Clear cache before generating:

php artisan lazy-seo:sitemap --clear-cache
php artisan lazy-seo:sitemap:warm --clear

JSON output:

php artisan lazy-seo:sitemap --json

Sitemap config

'sitemap' => [
    'path' => 'sitemap.xml',
    'index_path' => 'sitemap.xml',
    'chunk_size' => 50000,
    'gzip' => false,
    'force_index' => false,
    'exclude' => [
        'admin/*',
        'nova/*',
        'horizon/*',
        'telescope/*',
    ],
    'static_urls' => [
        [
            'loc' => '/',
            'changefreq' => 'daily',
            'priority' => 1.0,
        ],
    ],
    'models' => [
        App\Models\Post::class => [
            'enabled' => true,
            'url' => 'getSeoUrl',
            'scope' => 'published',
            'lastmod_column' => 'updated_at',
            'changefreq' => 'weekly',
            'priority' => 0.8,
            'images' => 'getSeoImages',
            'alternates' => 'getSeoAlternates',
        ],
    ],
],

When URL count exceeds chunk_size, the package writes sitemap chunks and a sitemap index automatically.

SEO analyzer

Analyze prepared page data:

use Step2dev\LazySeoTools\Services\SeoAnalyzerService;

$result = app(SeoAnalyzerService::class)->analyzePage([
    'title' => 'Laravel SEO Tools',
    'description' => 'Production SEO toolkit for Laravel applications.',
    'canonical_url' => 'https://example.com/page',
    'robots' => ['index', 'follow'],
    'image' => 'https://example.com/og.jpg',
    'html' => $html,
]);

$result->score;
$result->grade();
$result->toArray();

Checks include:

  • title length;
  • meta description length;
  • canonical URL;
  • robots directives;
  • content length;
  • H1/H2 structure;
  • missing image alt attributes;
  • internal/external links;
  • OpenGraph readiness;
  • Twitter card readiness;
  • JSON-LD presence.

Advanced: site crawler

Run a crawl from CLI:

php artisan lazy-seo:crawl https://example.com

Limit pages/depth and slow down requests:

php artisan lazy-seo:crawl https://example.com --max-pages=100 --max-depth=5 --rate-limit-ms=250

Check external links:

php artisan lazy-seo:crawl https://example.com --check-external --max-external-links=100

Save JSON report:

php artisan lazy-seo:crawl https://example.com --output=storage/app/seo-report.json

Crawler security defaults block private/reserved networks and manual redirects are validated before every follow-up request. Override only for trusted internal apps:

'crawler' => [
    'allow_private_networks' => false,
    'allowed_hosts' => [],
    'blocked_hosts' => [],
    'max_depth' => 5,
    'rate_limit_ms' => 250,
    'respect_robots_txt' => true,
    'queue_only' => false,
    'max_redirects' => 5,
    'max_body_kb' => 1024,
    'allowed_content_types' => ['text/html', 'application/xhtml+xml'],
],

For a strict production crawler, set allowed_hosts to your public domain list and enable queue_only for large/scheduled scans.

Advanced: SEO monitoring

Run a monitoring scan and save results to the database:

php artisan lazy-seo:monitor https://example.com

Use configured URL:

php artisan lazy-seo:monitor

Queue a scan:

php artisan lazy-seo:monitor https://example.com --queue

Use custom queue connection/name:

php artisan lazy-seo:monitor https://example.com --queue --connection=redis --queue-name=seo

Fail CI/deployment when score is too low:

php artisan lazy-seo:monitor https://example.com --fail-under=80

Create a pending scan and dispatch it:

php artisan lazy-seo:crawl-queue https://example.com --queue=seo

SEO history

Show scan history summary:

php artisan lazy-seo:history

Filter by URL/domain:

php artisan lazy-seo:history https://example.com --limit=20

JSON output:

php artisan lazy-seo:history https://example.com --json

Advanced: content intelligence

Analyze an HTML file:

php artisan lazy-seo:content storage/app/page.html

With target keywords:

php artisan lazy-seo:content storage/app/page.html --keywords="laravel,seo,package"

With base URL:

php artisan lazy-seo:content storage/app/page.html --base-url=https://example.com

JSON output:

php artisan lazy-seo:content storage/app/page.html --json

Content intelligence checks:

  • word count;
  • headings;
  • readability;
  • keyword density;
  • image alt attributes;
  • internal links;
  • external links;
  • suggestions and warnings.

Advanced: IndexNow

Enable IndexNow in config:

'indexnow' => [
    'enabled' => true,
    'key' => env('LAZY_SEO_INDEXNOW_KEY'),
    'key_location' => env('LAZY_SEO_INDEXNOW_KEY_LOCATION'),
    'host' => env('LAZY_SEO_INDEXNOW_HOST'),
],

Submit URLs:

php artisan lazy-seo:indexnow https://example.com/page-1 https://example.com/page-2

Submit URLs from file:

php artisan lazy-seo:indexnow --file=urls.txt

Submit configured sitemap URL:

php artisan lazy-seo:indexnow --sitemap

Override key/endpoint:

php artisan lazy-seo:indexnow https://example.com/page --key=YOUR_KEY --endpoint=https://api.indexnow.org/indexnow

Disable database logging for this command:

php artisan lazy-seo:indexnow https://example.com/page --no-log

JSON-LD / Schema.org

Blade component:

<x-lazy-seo-jsonld type="article" :data="[
    'title' => $post->title,
    'description' => $post->excerpt,
    'image' => $post->cover_url,
    'url' => route('posts.show', $post),
]" />

Helper returning array schema:

$schema = seo_schema('article', [
    'title' => $post->title,
    'description' => $post->excerpt,
]);

Helper returning script tag:

echo seo_jsonld('article', [
    'title' => $post->title,
]);

Supported schema types depend on SchemaService / JsonLdService, including common types such as:

  • Article
  • BlogPosting
  • Product
  • Organization
  • LocalBusiness
  • WebSite
  • BreadcrumbList
  • FAQPage
  • WebPage

OpenGraph image generation

The package includes OGImageService. It is optional and requires Intervention Image v3 when lazy-seo.features.og_image is enabled.

composer require intervention/image

Config:

'og_image' => [
    'disk' => 'public',
    'directory' => 'og',
    'width' => 1200,
    'height' => 630,
],

Use the service from the container:

use Step2dev\LazySeoTools\Services\OGImageService;

$path = app(OGImageService::class)->generate('Laravel SEO Tools');

Optional web admin routes

Web routes are disabled by default.

Enable them in config/lazy-seo.php:

'features' => [
    'admin' => true,
    'livewire' => true,
],

'routes' => [
    'web' => true,
    'admin_prefix' => 'lazy-seo',
    'admin_middleware' => ['web', 'auth'],
],

Available pages:

/lazy-seo/dashboard
/lazy-seo/issues
/lazy-seo/scans/{scan}
/lazy-seo/redirects

Named routes:

lazy-seo.dashboard
lazy-seo.issues
lazy-seo.scans.show
lazy-seo.redirects

Optional API routes

API routes are disabled by default.

Enable them in config:

'features' => [
    'api' => true,
],

'routes' => [
    'api' => true,
    'api_prefix' => 'seo',
    'api_middleware' => ['api'],
    'api_read_middleware' => ['auth:sanctum'],
    'api_write_middleware' => ['auth:sanctum'],
    'api_allow_morph_binding' => false,
    'api_allowed_seoable_types' => [
        // App\Models\Post::class,
        // App\Models\Page::class,
    ],
],

Routes:

GET    /seo
GET    /seo/{seo}
POST   /seo
PUT    /seo/{seo}
DELETE /seo/{seo}

Read and write routes are protected by auth:sanctum by default. If you intentionally expose read routes publicly, remove api_read_middleware explicitly in your app config.

Morph binding is disabled by default. If you enable it, whitelist allowed model classes with api_allowed_seoable_types; never accept arbitrary seoable_type values from clients.

Livewire components

The package registers these Livewire components when Livewire exists:

<livewire:lazy-seo-form />
<livewire:lazy-seo-analyzer />
<livewire:lazy-seo-redirect-table />
<livewire:lazy-seo-monitoring-dashboard />
<livewire:lazy-seo-issues-table />
<livewire:lazy-seo-scan-detail :scan="$scan" />

Queue configuration

Queue settings:

'queue' => [
    'enabled' => true,
    'connection' => env('LAZY_SEO_QUEUE_CONNECTION'),
    'queue' => env('LAZY_SEO_QUEUE_NAME', 'default'),
    'chunk_pages' => 25,
    'tries' => 2,
    'timeout' => 600,
],

Recommended for production:

QUEUE_CONNECTION=redis
LAZY_SEO_QUEUE_NAME=seo

Then run a worker:

php artisan queue:work redis --queue=seo

Scheduled monitoring

Set a cron expression in config/env:

LAZY_SEO_MONITORING_SCHEDULE="0 */6 * * *"
LAZY_SEO_MONITORING_URL="https://example.com"

If scheduled_queue is enabled, the scheduled command dispatches scans to queue:

LAZY_SEO_MONITORING_SCHEDULED_QUEUE=true

Make sure Laravel scheduler is running:

php artisan schedule:work

or via cron:

* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1

Alerts

Alerts are controlled by:

'alerts' => [
    'enabled' => false,
    'score_threshold' => 75,
    'critical_issues_threshold' => 1,
    'new_issues_threshold' => 1,
    'failed_scans' => true,
    'cooldown_minutes' => 60,
    'webhook_url' => env('LAZY_SEO_ALERT_WEBHOOK_URL'),
],

Commands

php artisan lazy-seo:about
php artisan lazy-seo:sitemap
php artisan lazy-seo:sitemap:warm
php artisan lazy-seo-redirects:import redirects.csv
php artisan lazy-seo-redirects:export redirects.csv
php artisan lazy-seo:crawl https://example.com
php artisan lazy-seo:crawl-queue https://example.com
php artisan lazy-seo:monitor https://example.com
php artisan lazy-seo:history
php artisan lazy-seo:indexnow https://example.com/page
php artisan lazy-seo:content storage/app/page.html

Facades and helpers

Facades:

use Step2dev\LazySeoTools\Facades\Seo;
use Step2dev\LazySeoTools\Facades\LazySeo;

Helpers:

seo();
seo_meta();
seo_schema('article', []);
seo_jsonld('article', []);

Testing in package development

composer install
vendor/bin/pest
vendor/bin/pint
vendor/bin/phpstan analyse

Notes for production

Recommended setup:

  • enable redirect middleware only after redirects are reviewed;
  • protect admin routes with auth or custom middleware;
  • keep API read/write routes protected unless public read access is intentional;
  • use Redis queue for crawler/monitoring jobs;
  • set crawl page limits in production;
  • enable external link checks carefully because they make real HTTP requests;
  • configure sitemap cache for large sites;
  • keep table names stable after migrations.

License

MIT.

Security defaults

Lazy SEO Tools keeps dangerous surfaces closed by default:

  • admin routes are disabled by default and require web, auth, and can:manage-lazy-seo when enabled;
  • API read/write routes are disabled by default and require auth:sanctum when enabled;
  • crawler blocks private/reserved networks by default to reduce SSRF risk;
  • crawler rejects oversized Content-Length responses and non-HTML content types before parsing;
  • regex redirects are disabled by default;
  • crawler respects robots.txt by default and supports max depth, retries, rate limiting, and queue-only mode;
  • AI is disabled by default and requires an explicit token;
  • runtime config validation fails fast on unsafe route/crawler/AI settings.

See docs/security.md.

Release safety

Before tagging a beta or stable release, follow docs/release-checklist.md.

Tailwind 4-first Blade UI

The optional admin and Livewire views use Tailwind utility classes and never load Tailwind from CDN. Register the package views in your app CSS entry file:

@import "tailwindcss";
@source "../../vendor/step2dev/lazy-seo-tools/resources/views";

Adjust the relative path based on where your compiled CSS entry file lives.

About

No description, website, or topics provided.

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

  •  

Packages

 
 
 

Contributors