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.
- Installation
- Quick start
- Blade components
- Facade and helpers
- Livewire components
- Redirects
- Sitemap
- Advanced features
- PHP
^8.2 - Laravel
^11.0or^12.0or^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.
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-mainStable Packagist install after release:
composer require step2dev/lazy-seo-toolsInstall optional integrations only when you enable the matching advanced layer:
composer require livewire/livewire
composer require intervention/imagePublish the config:
php artisan vendor:publish --tag="lazy-seo-config"Publish migrations:
php artisan vendor:publish --tag="lazy-seo-migrations"Run migrations:
php artisan migrateThe package is auto-discovered by Laravel through:
Step2dev\LazySeoTools\LazySeoServiceProvider::classRender 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'),
]) !!}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 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.
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,
],{!! seo_meta() !!}<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.
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();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');SeoManager::resolve() merges data in this order:
- config defaults;
- URL SEO;
- model SEO;
- template data;
- fluent API values;
- manual overrides.
Example:
$data = seo()
->title('Manual title')
->resolve(model: $post, url: '/blog/example', overrides: [
'description' => 'Custom description',
]);SEO templates are stored in the seo_templates table.
Supported translatable fields:
titledescriptionkeywords
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,
]);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:
301302307308410
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),
],Import:
php artisan lazy-seo-redirects:import redirects.csvImport without updating existing rows:
php artisan lazy-seo-redirects:import redirects.csv --no-updateExport:
php artisan lazy-seo-redirects:export redirects.csvCSV 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,0Generate sitemap files:
php artisan lazy-seo:sitemapCustom public path:
php artisan lazy-seo:sitemap --path=sitemaps/sitemap.xmlWarm sitemap cache:
php artisan lazy-seo:sitemap --warm
php artisan lazy-seo:sitemap:warmClear cache before generating:
php artisan lazy-seo:sitemap --clear-cache
php artisan lazy-seo:sitemap:warm --clearJSON output:
php artisan lazy-seo:sitemap --json'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.
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.
Run a crawl from CLI:
php artisan lazy-seo:crawl https://example.comLimit pages/depth and slow down requests:
php artisan lazy-seo:crawl https://example.com --max-pages=100 --max-depth=5 --rate-limit-ms=250Check external links:
php artisan lazy-seo:crawl https://example.com --check-external --max-external-links=100Save JSON report:
php artisan lazy-seo:crawl https://example.com --output=storage/app/seo-report.jsonCrawler 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.
Run a monitoring scan and save results to the database:
php artisan lazy-seo:monitor https://example.comUse configured URL:
php artisan lazy-seo:monitorQueue a scan:
php artisan lazy-seo:monitor https://example.com --queueUse custom queue connection/name:
php artisan lazy-seo:monitor https://example.com --queue --connection=redis --queue-name=seoFail CI/deployment when score is too low:
php artisan lazy-seo:monitor https://example.com --fail-under=80Create a pending scan and dispatch it:
php artisan lazy-seo:crawl-queue https://example.com --queue=seoShow scan history summary:
php artisan lazy-seo:historyFilter by URL/domain:
php artisan lazy-seo:history https://example.com --limit=20JSON output:
php artisan lazy-seo:history https://example.com --jsonAnalyze an HTML file:
php artisan lazy-seo:content storage/app/page.htmlWith 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.comJSON output:
php artisan lazy-seo:content storage/app/page.html --jsonContent intelligence checks:
- word count;
- headings;
- readability;
- keyword density;
- image alt attributes;
- internal links;
- external links;
- suggestions and warnings.
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-2Submit URLs from file:
php artisan lazy-seo:indexnow --file=urls.txtSubmit configured sitemap URL:
php artisan lazy-seo:indexnow --sitemapOverride key/endpoint:
php artisan lazy-seo:indexnow https://example.com/page --key=YOUR_KEY --endpoint=https://api.indexnow.org/indexnowDisable database logging for this command:
php artisan lazy-seo:indexnow https://example.com/page --no-logBlade 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
The package includes OGImageService. It is optional and requires Intervention Image v3 when lazy-seo.features.og_image is enabled.
composer require intervention/imageConfig:
'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');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
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.
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 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=seoThen run a worker:
php artisan queue:work redis --queue=seoSet 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=trueMake sure Laravel scheduler is running:
php artisan schedule:workor via cron:
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1Alerts 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'),
],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.htmlFacades:
use Step2dev\LazySeoTools\Facades\Seo;
use Step2dev\LazySeoTools\Facades\LazySeo;Helpers:
seo();
seo_meta();
seo_schema('article', []);
seo_jsonld('article', []);composer install
vendor/bin/pest
vendor/bin/pint
vendor/bin/phpstan analyseRecommended setup:
- enable redirect middleware only after redirects are reviewed;
- protect admin routes with
author 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.
MIT.
Lazy SEO Tools keeps dangerous surfaces closed by default:
- admin routes are disabled by default and require
web,auth, andcan:manage-lazy-seowhen enabled; - API read/write routes are disabled by default and require
auth:sanctumwhen enabled; - crawler blocks private/reserved networks by default to reduce SSRF risk;
- crawler rejects oversized
Content-Lengthresponses and non-HTML content types before parsing; - regex redirects are disabled by default;
- crawler respects
robots.txtby 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.
Before tagging a beta or stable release, follow docs/release-checklist.md.
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.