-
-
Notifications
You must be signed in to change notification settings - Fork 1
Laravel Integration
This guide provides comprehensive examples for integrating the PHP-SEO package with Laravel applications, following Laravel best practices and conventions.
Install the package via Composer:
composer require rumenx/php-seoCreate a custom service provider for the PHP-SEO package:
php artisan make:provider SeoServiceProviderRegister the service provider in app/Providers/SeoServiceProvider.php:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Rumenx\PhpSeo\Config\SeoConfig;
use Rumenx\PhpSeo\SeoManager;
class SeoServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(SeoConfig::class, function ($app) {
return new SeoConfig([
'ai' => [
'enabled' => config('seo.ai.enabled', false),
'provider' => config('seo.ai.provider', 'openai'),
],
'providers' => [
'openai' => [
'api_key' => config('seo.providers.openai.api_key'),
'model' => config('seo.providers.openai.model', 'gpt-3.5-turbo'),
],
],
'cache' => [
'enabled' => config('seo.cache.enabled', true),
'ttl' => config('seo.cache.ttl', 3600),
],
]);
});
$this->app->singleton(SeoManager::class, function ($app) {
return new SeoManager($app->make(SeoConfig::class));
});
}
public function boot(): void
{
$this->publishes([
__DIR__.'/../../config/seo.php' => config_path('seo.php'),
], 'seo-config');
}
}Register the provider in config/app.php:
'providers' => [
// Other providers...
App\Providers\SeoServiceProvider::class,
],Create a configuration file config/seo.php:
<?php
return [
'ai' => [
'enabled' => env('SEO_AI_ENABLED', false),
'provider' => env('SEO_AI_PROVIDER', 'openai'),
'fallback_enabled' => env('SEO_AI_FALLBACK_ENABLED', true),
],
'providers' => [
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-3.5-turbo'),
],
'anthropic' => [
'api_key' => env('ANTHROPIC_API_KEY'),
'model' => env('ANTHROPIC_MODEL', 'claude-3-sonnet-20240229'),
],
],
'cache' => [
'enabled' => env('SEO_CACHE_ENABLED', true),
'ttl' => env('SEO_CACHE_TTL', 3600),
'driver' => env('SEO_CACHE_DRIVER', 'redis'),
],
'sitemap' => [
'enabled' => env('SEO_SITEMAP_ENABLED', true),
'path' => env('SEO_SITEMAP_PATH', 'sitemap.xml'),
'auto_generate' => env('SEO_SITEMAP_AUTO_GENERATE', true),
],
];Add to your .env file:
SEO_AI_ENABLED=true
SEO_AI_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key
SEO_CACHE_ENABLED=true
SEO_SITEMAP_ENABLED=trueCreate a trait for automatic SEO generation:
<?php
namespace App\Traits;
use Rumenx\PhpSeo\SeoManager;
trait HasSeo
{
protected static function bootHasSeo(): void
{
static::saving(function ($model) {
if ($model->auto_generate_seo ?? true) {
$seoManager = app(SeoManager::class);
$model->generateSeoContent($seoManager);
}
});
}
public function generateSeoContent(SeoManager $seoManager): void
{
$content = $this->getSeoContent();
$analysis = $seoManager->analyze($content);
if (empty($this->meta_title)) {
$this->meta_title = $seoManager->generateTitle($analysis);
}
if (empty($this->meta_description)) {
$this->meta_description = $seoManager->generateDescription($analysis);
}
if (empty($this->meta_keywords)) {
$keywords = $seoManager->generateKeywords($analysis);
$this->meta_keywords = implode(', ', $keywords);
}
}
abstract protected function getSeoContent(): string;
public function getSeoTitle(): string
{
return $this->meta_title ?: $this->title;
}
public function getSeoDescription(): string
{
return $this->meta_description ?: str_limit(strip_tags($this->content), 160);
}
public function getSeoKeywords(): array
{
return $this->meta_keywords ? explode(', ', $this->meta_keywords) : [];
}
}<?php
namespace App\Models;
use App\Traits\HasSeo;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Article extends Model
{
use HasFactory, HasSeo;
protected $fillable = [
'title',
'slug',
'content',
'excerpt',
'published_at',
'meta_title',
'meta_description',
'meta_keywords',
'auto_generate_seo',
];
protected $casts = [
'published_at' => 'datetime',
'auto_generate_seo' => 'boolean',
];
protected function getSeoContent(): string
{
return $this->title . ' ' . $this->excerpt . ' ' . strip_tags($this->content);
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
}Create a migration to add SEO fields:
php artisan make:migration add_seo_fields_to_articles_table<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->string('meta_title')->nullable();
$table->text('meta_description')->nullable();
$table->text('meta_keywords')->nullable();
$table->boolean('auto_generate_seo')->default(true);
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->dropColumn(['meta_title', 'meta_description', 'meta_keywords', 'auto_generate_seo']);
});
}
};<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\Request;
use Rumenx\PhpSeo\SeoManager;
class BlogController extends Controller
{
public function __construct(
private SeoManager $seoManager
) {}
public function index(Request $request)
{
$articles = Article::published()
->orderBy('published_at', 'desc')
->paginate(10);
// Generate SEO for blog index
$seoData = $this->generateIndexSeo($request);
return view('blog.index', compact('articles', 'seoData'));
}
public function show(Article $article)
{
// Generate or get SEO data for the article
$seoData = $this->generateArticleSeo($article);
return view('blog.show', compact('article', 'seoData'));
}
private function generateIndexSeo(Request $request): array
{
$page = $request->get('page', 1);
$baseTitle = 'Blog - Latest Articles and Insights';
if ($page > 1) {
$title = "{$baseTitle} - Page {$page}";
} else {
$title = $baseTitle;
}
return [
'title' => $title,
'description' => 'Discover the latest articles, insights, and industry news on our blog.',
'canonical' => url()->current(),
'og_type' => 'website',
];
}
private function generateArticleSeo(Article $article): array
{
$content = $article->getSeoContent();
$analysis = $this->seoManager->analyze($content);
return [
'title' => $article->getSeoTitle(),
'description' => $article->getSeoDescription(),
'keywords' => $article->getSeoKeywords(),
'canonical' => route('blog.show', $article),
'og_type' => 'article',
'og_image' => $article->featured_image,
'article_published_time' => $article->published_at?->toISOString(),
'article_author' => $article->author?->name,
'schema_org' => $this->generateArticleSchema($article, $analysis),
];
}
private function generateArticleSchema(Article $article, array $analysis): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $article->title,
'description' => $article->getSeoDescription(),
'image' => $article->featured_image,
'datePublished' => $article->published_at?->toISOString(),
'dateModified' => $article->updated_at?->toISOString(),
'author' => [
'@type' => 'Person',
'name' => $article->author?->name,
],
'publisher' => [
'@type' => 'Organization',
'name' => config('app.name'),
'logo' => [
'@type' => 'ImageObject',
'url' => asset('images/logo.png'),
],
],
'wordCount' => $analysis['word_count'] ?? 0,
'keywords' => implode(', ', $article->getSeoKeywords()),
];
}
}<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Article;
use Illuminate\Http\Request;
use Rumenx\PhpSeo\SeoManager;
class SeoController extends Controller
{
public function __construct(
private SeoManager $seoManager
) {}
public function analyze(Request $request)
{
$request->validate([
'content' => 'required|string',
'url' => 'nullable|url',
]);
$analysis = $this->seoManager->analyze($request->content, [
'url' => $request->url,
]);
return response()->json([
'success' => true,
'data' => $analysis,
]);
}
public function generateTitle(Request $request)
{
$request->validate([
'content' => 'required|string',
'max_length' => 'nullable|integer|min:30|max:100',
'use_ai' => 'nullable|boolean',
]);
$analysis = $this->seoManager->analyze($request->content);
$title = $this->seoManager->generateTitle($analysis, [
'max_length' => $request->max_length ?? 60,
'use_ai' => $request->use_ai ?? true,
]);
return response()->json([
'success' => true,
'data' => [
'title' => $title,
'length' => strlen($title),
],
]);
}
public function generateDescription(Request $request)
{
$request->validate([
'content' => 'required|string',
'max_length' => 'nullable|integer|min:120|max:200',
'use_ai' => 'nullable|boolean',
]);
$analysis = $this->seoManager->analyze($request->content);
$description = $this->seoManager->generateDescription($analysis, [
'max_length' => $request->max_length ?? 160,
'use_ai' => $request->use_ai ?? true,
]);
return response()->json([
'success' => true,
'data' => [
'description' => $description,
'length' => strlen($description),
],
]);
}
public function generateBulk(Request $request)
{
$request->validate([
'articles' => 'required|array|max:10',
'articles.*.id' => 'required|integer|exists:articles,id',
'articles.*.force_regenerate' => 'nullable|boolean',
]);
$results = [];
foreach ($request->articles as $item) {
$article = Article::find($item['id']);
$forceRegenerate = $item['force_regenerate'] ?? false;
if ($forceRegenerate || empty($article->meta_title) || empty($article->meta_description)) {
$content = $article->getSeoContent();
$analysis = $this->seoManager->analyze($content);
$results[] = [
'id' => $article->id,
'title' => $this->seoManager->generateTitle($analysis),
'description' => $this->seoManager->generateDescription($analysis),
'keywords' => $this->seoManager->generateKeywords($analysis),
];
}
}
return response()->json([
'success' => true,
'data' => $results,
]);
}
}Create a Blade component for SEO head tags:
php artisan make:component SeoHead<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class SeoHead extends Component
{
public function __construct(
public array $seo = []
) {}
public function render(): View|Closure|string
{
return view('components.seo-head');
}
public function getTitle(): string
{
$title = $this->seo['title'] ?? config('app.name');
$siteName = config('app.name');
return $title === $siteName ? $title : "{$title} - {$siteName}";
}
public function getDescription(): string
{
return $this->seo['description'] ?? 'Default site description';
}
public function getCanonical(): string
{
return $this->seo['canonical'] ?? request()->url();
}
public function getOgImage(): string
{
return $this->seo['og_image'] ?? asset('images/default-og-image.jpg');
}
}Create the component view resources/views/components/seo-head.blade.php:
<title>{{ $getTitle() }}</title>
<meta name="description" content="{{ $getDescription() }}">
@if(!empty($seo['keywords']))
<meta name="keywords" content="{{ is_array($seo['keywords']) ? implode(', ', $seo['keywords']) : $seo['keywords'] }}">
@endif
<link rel="canonical" href="{{ $getCanonical() }}">
<!-- Open Graph Tags -->
<meta property="og:title" content="{{ $seo['title'] ?? config('app.name') }}">
<meta property="og:description" content="{{ $getDescription() }}">
<meta property="og:type" content="{{ $seo['og_type'] ?? 'website' }}">
<meta property="og:url" content="{{ $getCanonical() }}">
<meta property="og:image" content="{{ $getOgImage() }}">
<meta property="og:site_name" content="{{ config('app.name') }}">
<!-- Twitter Card Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $seo['title'] ?? config('app.name') }}">
<meta name="twitter:description" content="{{ $getDescription() }}">
<meta name="twitter:image" content="{{ $getOgImage() }}">
@if(!empty($seo['article_published_time']))
<meta property="article:published_time" content="{{ $seo['article_published_time'] }}">
@endif
@if(!empty($seo['article_author']))
<meta property="article:author" content="{{ $seo['article_author'] }}">
@endif
@if(!empty($seo['schema_org']))
<script type="application/ld+json">
{!! json_encode($seo['schema_org'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) !!}
</script>
@endifUpdate your main layout resources/views/layouts/app.blade.php:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<x-seo-head :seo="$seoData ?? []" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
@yield('content')
</body>
</html>Create an Artisan command for bulk SEO analysis:
php artisan make:command AnalyzeSeoCommand<?php
namespace App\Console\Commands;
use App\Models\Article;
use Illuminate\Console\Command;
use Rumenx\PhpSeo\SeoManager;
class AnalyzeSeoCommand extends Command
{
protected $signature = 'seo:analyze
{--model=Article : The model to analyze}
{--force : Force regenerate existing SEO data}
{--chunk=50 : Number of records to process at once}';
protected $description = 'Analyze and generate SEO data for models';
public function handle(SeoManager $seoManager): int
{
$modelClass = "App\\Models\\{$this->option('model')}";
if (!class_exists($modelClass)) {
$this->error("Model {$modelClass} does not exist");
return self::FAILURE;
}
$force = $this->option('force');
$chunkSize = (int) $this->option('chunk');
$query = $modelClass::query();
if (!$force) {
$query->where(function ($q) {
$q->whereNull('meta_title')
->orWhereNull('meta_description')
->orWhere('meta_title', '')
->orWhere('meta_description', '');
});
}
$total = $query->count();
if ($total === 0) {
$this->info('No records need SEO generation');
return self::SUCCESS;
}
$this->info("Processing {$total} records...");
$bar = $this->output->createProgressBar($total);
$query->chunk($chunkSize, function ($models) use ($seoManager, $bar) {
foreach ($models as $model) {
try {
$model->generateSeoContent($seoManager);
$model->save();
$bar->advance();
} catch (\Exception $e) {
$this->error("Error processing {$model->id}: {$e->getMessage()}");
}
}
});
$bar->finish();
$this->newLine();
$this->info('SEO analysis completed!');
return self::SUCCESS;
}
}php artisan make:command GenerateSitemapCommand<?php
namespace App\Console\Commands;
use App\Models\Article;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Rumenx\PhpSeo\SeoManager;
class GenerateSitemapCommand extends Command
{
protected $signature = 'seo:sitemap {--output=public/sitemap.xml}';
protected $description = 'Generate XML sitemap';
public function handle(SeoManager $seoManager): int
{
$sitemap = $seoManager->generateSitemap([
'domain' => config('app.url'),
'routes' => $this->getRoutes(),
]);
$outputPath = $this->option('output');
file_put_contents($outputPath, $sitemap);
$this->info("Sitemap generated: {$outputPath}");
return self::SUCCESS;
}
private function getRoutes(): array
{
$routes = [];
// Static pages
$routes[] = [
'url' => route('home'),
'lastmod' => now()->toISOString(),
'changefreq' => 'daily',
'priority' => '1.0',
];
// Blog articles
Article::published()->chunk(100, function ($articles) use (&$routes) {
foreach ($articles as $article) {
$routes[] = [
'url' => route('blog.show', $article),
'lastmod' => $article->updated_at->toISOString(),
'changefreq' => 'weekly',
'priority' => '0.8',
];
}
});
return $routes;
}
}Create middleware for automatic SEO handling:
php artisan make:middleware SeoMiddleware<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Rumenx\PhpSeo\SeoManager;
use Symfony\Component\HttpFoundation\Response;
class SeoMiddleware
{
public function __construct(
private SeoManager $seoManager
) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Only process HTML responses
if (!$response instanceof \Illuminate\Http\Response ||
!str_contains($response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
// Analyze current page content
$analysis = $this->seoManager->analyze($content);
// Add SEO analysis to view data if needed
if (view()->exists('partials.seo-debug') && config('app.debug')) {
$debugContent = view('partials.seo-debug', compact('analysis'))->render();
$content = str_replace('</body>', $debugContent . '</body>', $content);
$response->setContent($content);
}
return $response;
}
}Register in app/Http/Kernel.php:
protected $middlewareGroups = [
'web' => [
// Other middleware...
\App\Http\Middleware\SeoMiddleware::class,
],
];Create tests for SEO functionality:
php artisan make:test SeoTest<?php
namespace Tests\Feature;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Rumenx\PhpSeo\SeoManager;
use Tests\TestCase;
class SeoTest extends TestCase
{
use RefreshDatabase;
private SeoManager $seoManager;
protected function setUp(): void
{
parent::setUp();
$this->seoManager = app(SeoManager::class);
}
public function test_article_generates_seo_on_save(): void
{
$article = Article::create([
'title' => 'Test Article',
'content' => '<p>This is test content for SEO generation.</p>',
'slug' => 'test-article',
'auto_generate_seo' => true,
]);
$this->assertNotNull($article->meta_title);
$this->assertNotNull($article->meta_description);
$this->assertNotEmpty($article->meta_keywords);
}
public function test_blog_show_page_has_proper_seo(): void
{
$article = Article::factory()->create([
'title' => 'Laravel SEO Best Practices',
'meta_title' => 'Laravel SEO Guide - Best Practices',
'meta_description' => 'Learn Laravel SEO best practices for better search rankings.',
]);
$response = $this->get(route('blog.show', $article));
$response->assertStatus(200);
$response->assertSee('Laravel SEO Guide - Best Practices', false);
$response->assertSee('Learn Laravel SEO best practices', false);
}
public function test_seo_api_endpoints(): void
{
$content = '<h1>Test Title</h1><p>Test content for analysis.</p>';
// Test analyze endpoint
$response = $this->postJson('/api/seo/analyze', [
'content' => $content,
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'word_count',
'title_tags',
'meta_description',
'headings',
],
]);
// Test title generation
$response = $this->postJson('/api/seo/generate-title', [
'content' => $content,
'max_length' => 60,
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'title',
'length',
],
]);
}
public function test_sitemap_generation(): void
{
Article::factory()->count(5)->create();
$this->artisan('seo:sitemap')
->expectsOutput('Sitemap generated: public/sitemap.xml')
->assertExitCode(0);
$this->assertFileExists(public_path('sitemap.xml'));
$sitemap = file_get_contents(public_path('sitemap.xml'));
$this->assertStringContains('<?xml version="1.0" encoding="UTF-8"?>', $sitemap);
$this->assertStringContains('<urlset', $sitemap);
}
}php artisan make:test SeoManagerTest --unit<?php
namespace Tests\Unit;
use Rumenx\PhpSeo\Config\SeoConfig;
use Rumenx\PhpSeo\SeoManager;
use Tests\TestCase;
class SeoManagerTest extends TestCase
{
private SeoManager $seoManager;
protected function setUp(): void
{
parent::setUp();
$config = new SeoConfig([
'ai' => ['enabled' => false], // Disable AI for unit tests
]);
$this->seoManager = new SeoManager($config);
}
public function test_content_analysis(): void
{
$content = '<h1>Test Article</h1><p>This is a test article with some content.</p>';
$analysis = $this->seoManager->analyze($content);
$this->assertIsArray($analysis);
$this->assertArrayHasKey('word_count', $analysis);
$this->assertArrayHasKey('title_tags', $analysis);
$this->assertEquals(1, count($analysis['title_tags']));
$this->assertEquals('Test Article', $analysis['title_tags'][0]['text']);
}
public function test_title_generation_without_ai(): void
{
$content = '<h1>Laravel Best Practices</h1><p>Learn Laravel development best practices.</p>';
$analysis = $this->seoManager->analyze($content);
$title = $this->seoManager->generateTitle($analysis, ['use_ai' => false]);
$this->assertIsString($title);
$this->assertLessThanOrEqual(60, strlen($title));
}
public function test_description_generation_without_ai(): void
{
$content = '<h1>Laravel Guide</h1><p>Complete guide to Laravel development.</p>';
$analysis = $this->seoManager->analyze($content);
$description = $this->seoManager->generateDescription($analysis, ['use_ai' => false]);
$this->assertIsString($description);
$this->assertLessThanOrEqual(160, strlen($description));
}
}Production .env settings:
# SEO Configuration
SEO_AI_ENABLED=true
SEO_AI_PROVIDER=openai
SEO_CACHE_ENABLED=true
SEO_CACHE_DRIVER=redis
SEO_SITEMAP_ENABLED=true
# API Keys (use secure storage)
OPENAI_API_KEY=your-production-openai-key
ANTHROPIC_API_KEY=your-production-anthropic-key
# Cache Settings
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379Add to app/Console/Kernel.php:
protected function schedule(Schedule $schedule): void
{
// Generate sitemap daily
$schedule->command('seo:sitemap')
->daily()
->at('02:00');
// Analyze new content hourly
$schedule->command('seo:analyze --model=Article')
->hourly();
}For high-volume sites, consider queueing SEO generation:
php artisan make:job GenerateSeoJob<?php
namespace App\Jobs;
use App\Models\Article;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Rumenx\PhpSeo\SeoManager;
class GenerateSeoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private int $articleId
) {}
public function handle(SeoManager $seoManager): void
{
$article = Article::find($this->articleId);
if ($article) {
$article->generateSeoContent($seoManager);
$article->save();
}
}
}Dispatch the job in your model:
protected static function bootHasSeo(): void
{
static::saved(function ($model) {
if ($model->auto_generate_seo ?? true) {
GenerateSeoJob::dispatch($model->id);
}
});
}Implement caching for SEO data:
// In your controller
public function show(Article $article)
{
$cacheKey = "article_seo_{$article->id}_{$article->updated_at->timestamp}";
$seoData = Cache::remember($cacheKey, 3600, function () use ($article) {
return $this->generateArticleSeo($article);
});
return view('blog.show', compact('article', 'seoData'));
}Add indexes for SEO queries:
Schema::table('articles', function (Blueprint $table) {
$table->index(['published_at', 'meta_title']);
$table->index('slug');
});- Configuration Guide - Advanced configuration options
- AI Integration - Setup AI providers
- Performance Optimization - Optimize for production
- Troubleshooting - Common issues and solutions