-
-
Notifications
You must be signed in to change notification settings - Fork 1
Symfony Integration
Rumen Damyanov edited this page Sep 22, 2025
·
2 revisions
This comprehensive guide shows how to integrate the PHP-SEO package with Symfony applications, following Symfony best practices and utilizing framework-specific features like dependency injection, events, and Twig extensions.
Install the package via Composer:
composer require rumenx/php-seoCreate a bundle configuration class in src/DependencyInjection/Configuration.php:
<?php
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('seo');
$rootNode = $treeBuilder->getRootNode();
$rootNode
->children()
->arrayNode('ai')
->children()
->booleanNode('enabled')->defaultFalse()->end()
->scalarNode('provider')->defaultValue('openai')->end()
->booleanNode('fallback_enabled')->defaultTrue()->end()
->arrayNode('fallback_providers')
->scalarPrototype()->end()
->defaultValue(['manual'])
->end()
->end()
->end()
->arrayNode('providers')
->children()
->arrayNode('openai')
->children()
->scalarNode('api_key')->defaultValue('%env(OPENAI_API_KEY)%')->end()
->scalarNode('model')->defaultValue('gpt-3.5-turbo')->end()
->integerNode('timeout')->defaultValue(30)->end()
->end()
->end()
->arrayNode('anthropic')
->children()
->scalarNode('api_key')->defaultValue('%env(ANTHROPIC_API_KEY)%')->end()
->scalarNode('model')->defaultValue('claude-3-sonnet-20240229')->end()
->integerNode('timeout')->defaultValue(30)->end()
->end()
->end()
->end()
->end()
->arrayNode('cache')
->children()
->booleanNode('enabled')->defaultTrue()->end()
->integerNode('ttl')->defaultValue(3600)->end()
->scalarNode('driver')->defaultValue('redis')->end()
->end()
->end()
->arrayNode('sitemap')
->children()
->booleanNode('enabled')->defaultTrue()->end()
->scalarNode('path')->defaultValue('sitemap.xml')->end()
->booleanNode('auto_generate')->defaultTrue()->end()
->end()
->end()
->end();
return $treeBuilder;
}
}Register services in config/services.yaml:
parameters:
seo.ai.enabled: '%env(bool:SEO_AI_ENABLED)%'
seo.ai.provider: '%env(SEO_AI_PROVIDER)%'
services:
_defaults:
autowire: true
autoconfigure: true
# SEO Configuration
Rumenx\PhpSeo\Config\SeoConfig:
arguments:
$config:
ai:
enabled: '%seo.ai.enabled%'
provider: '%seo.ai.provider%'
fallback_enabled: true
providers:
openai:
api_key: '%env(OPENAI_API_KEY)%'
model: '%env(OPENAI_MODEL)%'
anthropic:
api_key: '%env(ANTHROPIC_API_KEY)%'
model: '%env(ANTHROPIC_MODEL)%'
cache:
enabled: true
ttl: 3600
driver: 'redis'
# SEO Manager
Rumenx\PhpSeo\SeoManager:
arguments:
$config: '@Rumenx\PhpSeo\Config\SeoConfig'
# Alias for easier injection
seo_manager:
alias: Rumenx\PhpSeo\SeoManager
# SEO Services
App\Service\SeoService:
arguments:
$seoManager: '@seo_manager'
$cache: '@cache.app'
# Event Subscribers
App\EventSubscriber\SeoEventSubscriber:
tags:
- { name: kernel.event_subscriber }
# Twig Extensions
App\Twig\SeoExtension:
arguments:
$seoService: '@App\Service\SeoService'
tags:
- { name: twig.extension }Add environment variables to .env:
# SEO Configuration
SEO_AI_ENABLED=true
SEO_AI_PROVIDER=openai
# API Keys
OPENAI_API_KEY=your-openai-api-key
OPENAI_MODEL=gpt-3.5-turbo
ANTHROPIC_API_KEY=your-anthropic-api-key
ANTHROPIC_MODEL=claude-3-sonnet-20240229<?php
namespace App\Entity;
use App\Repository\BlogPostRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
#[ORM\HasLifecycleCallbacks]
class BlogPost
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(length: 255, unique: true)]
#[Assert\NotBlank]
private ?string $slug = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]
private ?string $content = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $excerpt = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $metaTitle = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $metaDescription = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $metaKeywords = null;
#[ORM\Column]
private bool $autoGenerateSeo = true;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $publishedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $featuredImage = null;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTime();
}
// Getters and setters...
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(string $slug): static
{
$this->slug = $slug;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getMetaTitle(): ?string
{
return $this->metaTitle ?: $this->title;
}
public function setMetaTitle(?string $metaTitle): static
{
$this->metaTitle = $metaTitle;
return $this;
}
public function getMetaDescription(): ?string
{
return $this->metaDescription ?: $this->excerpt;
}
public function setMetaDescription(?string $metaDescription): static
{
$this->metaDescription = $metaDescription;
return $this;
}
public function getMetaKeywords(): ?string
{
return $this->metaKeywords;
}
public function setMetaKeywords(?string $metaKeywords): static
{
$this->metaKeywords = $metaKeywords;
return $this;
}
public function isAutoGenerateSeo(): bool
{
return $this->autoGenerateSeo;
}
public function setAutoGenerateSeo(bool $autoGenerateSeo): static
{
$this->autoGenerateSeo = $autoGenerateSeo;
return $this;
}
public function getKeywordsArray(): array
{
return $this->metaKeywords ? explode(', ', $this->metaKeywords) : [];
}
public function getSeoContent(): string
{
return implode(' ', array_filter([
$this->title,
$this->excerpt,
strip_tags($this->content)
]));
}
public function isPublished(): bool
{
return $this->publishedAt !== null && $this->publishedAt <= new \DateTime();
}
// Additional getters and setters...
}Create a dedicated SEO service in src/Service/SeoService.php:
<?php
namespace App\Service;
use App\Entity\BlogPost;
use Rumenx\PhpSeo\SeoManager;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class SeoService
{
public function __construct(
private SeoManager $seoManager,
private AdapterInterface $cache,
#[Autowire('%kernel.project_dir%')] private string $projectDir
) {}
public function generateSeoForBlogPost(BlogPost $blogPost, bool $forceRegenerate = false): void
{
if (!$blogPost->isAutoGenerateSeo() && !$forceRegenerate) {
return;
}
$content = $blogPost->getSeoContent();
$analysis = $this->seoManager->analyze($content);
if (empty($blogPost->getMetaTitle()) || $forceRegenerate) {
$title = $this->seoManager->generateTitle($analysis, [
'max_length' => 60,
'include_brand' => true,
'brand_name' => 'My Blog',
]);
$blogPost->setMetaTitle($title);
}
if (empty($blogPost->getMetaDescription()) || $forceRegenerate) {
$description = $this->seoManager->generateDescription($analysis, [
'max_length' => 160,
'call_to_action' => true,
]);
$blogPost->setMetaDescription($description);
}
if (empty($blogPost->getMetaKeywords()) || $forceRegenerate) {
$keywords = $this->seoManager->generateKeywords($analysis, [
'max_keywords' => 10,
]);
$blogPost->setMetaKeywords(implode(', ', $keywords));
}
}
public function analyzeBlogPost(BlogPost $blogPost): array
{
$cacheKey = "seo_analysis_{$blogPost->getId()}_{$blogPost->getUpdatedAt()->getTimestamp()}";
$cachedItem = $this->cache->getItem($cacheKey);
if ($cachedItem->isHit()) {
return $cachedItem->get();
}
$content = $blogPost->getSeoContent();
$analysis = $this->seoManager->analyze($content);
$cachedItem->set($analysis);
$cachedItem->expiresAfter(3600); // Cache for 1 hour
$this->cache->save($cachedItem);
return $analysis;
}
public function generateBlogPostSchema(BlogPost $blogPost): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $blogPost->getTitle(),
'description' => $blogPost->getMetaDescription(),
'image' => $blogPost->getFeaturedImage() ?
$this->getAbsoluteUrl($blogPost->getFeaturedImage()) : null,
'datePublished' => $blogPost->getPublishedAt()?->format('c'),
'dateModified' => $blogPost->getUpdatedAt()?->format('c'),
'author' => [
'@type' => 'Person',
'name' => $blogPost->getAuthor()?->getName(),
],
'publisher' => [
'@type' => 'Organization',
'name' => 'My Blog',
'logo' => [
'@type' => 'ImageObject',
'url' => $this->getAbsoluteUrl('/images/logo.png'),
],
],
'mainEntityOfPage' => [
'@type' => 'WebPage',
'@id' => $this->generateBlogPostUrl($blogPost),
],
'keywords' => $blogPost->getMetaKeywords(),
];
}
public function generateSitemap(array $blogPosts = null): string
{
$routes = $this->prepareSitemapRoutes($blogPosts);
return $this->seoManager->generateSitemap([
'domain' => 'https://myblog.com',
'routes' => $routes,
]);
}
private function prepareSitemapRoutes(?array $blogPosts): array
{
$routes = [
// Static pages
[
'url' => 'https://myblog.com/',
'lastmod' => (new \DateTime())->format('c'),
'changefreq' => 'daily',
'priority' => '1.0',
],
[
'url' => 'https://myblog.com/blog',
'lastmod' => (new \DateTime())->format('c'),
'changefreq' => 'weekly',
'priority' => '0.9',
],
];
// Add blog posts
if ($blogPosts) {
foreach ($blogPosts as $blogPost) {
if ($blogPost->isPublished()) {
$routes[] = [
'url' => $this->generateBlogPostUrl($blogPost),
'lastmod' => $blogPost->getUpdatedAt()->format('c'),
'changefreq' => 'weekly',
'priority' => '0.8',
];
}
}
}
return $routes;
}
private function generateBlogPostUrl(BlogPost $blogPost): string
{
return "https://myblog.com/blog/{$blogPost->getSlug()}";
}
private function getAbsoluteUrl(string $path): string
{
return 'https://myblog.com' . $path;
}
}<?php
namespace App\Controller;
use App\Entity\BlogPost;
use App\Repository\BlogPostRepository;
use App\Service\SeoService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/blog')]
class BlogController extends AbstractController
{
public function __construct(
private SeoService $seoService,
private BlogPostRepository $blogPostRepository
) {}
#[Route('/', name: 'blog_index')]
public function index(Request $request): Response
{
$page = $request->query->getInt('page', 1);
$limit = 10;
$blogPosts = $this->blogPostRepository->findPublishedPaginated($page, $limit);
$totalPosts = $this->blogPostRepository->countPublished();
$seoData = $this->generateIndexSeoData($page);
return $this->render('blog/index.html.twig', [
'blog_posts' => $blogPosts,
'current_page' => $page,
'total_pages' => ceil($totalPosts / $limit),
'seo_data' => $seoData,
]);
}
#[Route('/{slug}', name: 'blog_show')]
public function show(BlogPost $blogPost): Response
{
if (!$blogPost->isPublished()) {
throw $this->createNotFoundException('Blog post not found.');
}
$seoData = $this->generateBlogPostSeoData($blogPost);
$analysis = $this->seoService->analyzeBlogPost($blogPost);
return $this->render('blog/show.html.twig', [
'blog_post' => $blogPost,
'seo_data' => $seoData,
'seo_analysis' => $analysis,
]);
}
#[Route('/category/{category}', name: 'blog_category')]
public function category(string $category, Request $request): Response
{
$page = $request->query->getInt('page', 1);
$limit = 10;
$blogPosts = $this->blogPostRepository->findByCategory($category, $page, $limit);
$seoData = $this->generateCategorySeoData($category, $page);
return $this->render('blog/category.html.twig', [
'blog_posts' => $blogPosts,
'category' => $category,
'current_page' => $page,
'seo_data' => $seoData,
]);
}
private function generateIndexSeoData(int $page): array
{
$baseTitle = 'Blog - Latest Articles and Insights';
$title = $page > 1 ? "{$baseTitle} - Page {$page}" : $baseTitle;
return [
'title' => $title,
'description' => 'Discover the latest articles, insights, and industry news on our blog.',
'canonical' => $this->generateUrl('blog_index', $page > 1 ? ['page' => $page] : []),
'og_type' => 'website',
];
}
private function generateBlogPostSeoData(BlogPost $blogPost): array
{
return [
'title' => $blogPost->getMetaTitle(),
'description' => $blogPost->getMetaDescription(),
'keywords' => $blogPost->getKeywordsArray(),
'canonical' => $this->generateUrl('blog_show', ['slug' => $blogPost->getSlug()]),
'og_type' => 'article',
'og_image' => $blogPost->getFeaturedImage(),
'article_published_time' => $blogPost->getPublishedAt()?->format('c'),
'article_author' => $blogPost->getAuthor()?->getName(),
'schema_org' => $this->seoService->generateBlogPostSchema($blogPost),
];
}
private function generateCategorySeoData(string $category, int $page): array
{
$baseTitle = ucfirst($category) . ' Articles';
$title = $page > 1 ? "{$baseTitle} - Page {$page}" : $baseTitle;
return [
'title' => $title,
'description' => "Browse all articles in the {$category} category.",
'canonical' => $this->generateUrl('blog_category', [
'category' => $category,
'page' => $page > 1 ? $page : null,
]),
'og_type' => 'website',
];
}
}<?php
namespace App\Controller\Api;
use App\Entity\BlogPost;
use App\Service\SeoService;
use Doctrine\ORM\EntityManagerInterface;
use Rumenx\PhpSeo\SeoManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/seo', name: 'api_seo_')]
class SeoApiController extends AbstractController
{
public function __construct(
private SeoManager $seoManager,
private SeoService $seoService,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator
) {}
#[Route('/analyze', name: 'analyze', methods: ['POST'])]
public function analyze(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['content'])) {
return $this->json(['error' => 'Content is required'], 400);
}
try {
$analysis = $this->seoManager->analyze($data['content'], [
'url' => $data['url'] ?? null,
'target_keywords' => $data['target_keywords'] ?? [],
]);
return $this->json([
'success' => true,
'data' => $analysis,
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/generate/title', name: 'generate_title', methods: ['POST'])]
public function generateTitle(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['content'])) {
return $this->json(['error' => 'Content is required'], 400);
}
try {
$analysis = $this->seoManager->analyze($data['content']);
$title = $this->seoManager->generateTitle($analysis, [
'max_length' => $data['max_length'] ?? 60,
'use_ai' => $data['use_ai'] ?? true,
'style' => $data['style'] ?? 'default',
]);
return $this->json([
'success' => true,
'data' => [
'title' => $title,
'length' => strlen($title),
],
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/generate/description', name: 'generate_description', methods: ['POST'])]
public function generateDescription(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['content'])) {
return $this->json(['error' => 'Content is required'], 400);
}
try {
$analysis = $this->seoManager->analyze($data['content']);
$description = $this->seoManager->generateDescription($analysis, [
'max_length' => $data['max_length'] ?? 160,
'use_ai' => $data['use_ai'] ?? true,
'call_to_action' => $data['call_to_action'] ?? false,
]);
return $this->json([
'success' => true,
'data' => [
'description' => $description,
'length' => strlen($description),
],
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/bulk/generate', name: 'bulk_generate', methods: ['POST'])]
public function bulkGenerate(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['blog_post_ids']) || !is_array($data['blog_post_ids'])) {
return $this->json(['error' => 'Blog post IDs array is required'], 400);
}
if (count($data['blog_post_ids']) > 50) {
return $this->json(['error' => 'Maximum 50 blog posts per request'], 400);
}
$results = [];
$errors = [];
foreach ($data['blog_post_ids'] as $id) {
try {
$blogPost = $this->entityManager->getRepository(BlogPost::class)->find($id);
if (!$blogPost) {
$errors[] = "Blog post with ID {$id} not found";
continue;
}
$forceRegenerate = $data['force_regenerate'] ?? false;
$this->seoService->generateSeoForBlogPost($blogPost, $forceRegenerate);
$this->entityManager->persist($blogPost);
$results[] = [
'id' => $blogPost->getId(),
'title' => $blogPost->getMetaTitle(),
'description' => $blogPost->getMetaDescription(),
'keywords' => $blogPost->getKeywordsArray(),
];
} catch (\Exception $e) {
$errors[] = "Error processing blog post {$id}: " . $e->getMessage();
}
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'data' => $results,
'errors' => $errors,
]);
}
#[Route('/blog-post/{id}/regenerate', name: 'regenerate_blog_post', methods: ['POST'])]
public function regenerateBlogPost(BlogPost $blogPost): JsonResponse
{
try {
$this->seoService->generateSeoForBlogPost($blogPost, true);
$this->entityManager->persist($blogPost);
$this->entityManager->flush();
return $this->json([
'success' => true,
'data' => [
'id' => $blogPost->getId(),
'title' => $blogPost->getMetaTitle(),
'description' => $blogPost->getMetaDescription(),
'keywords' => $blogPost->getKeywordsArray(),
],
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
}<?php
namespace App\EventSubscriber;
use App\Entity\BlogPost;
use App\Service\SeoService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
#[AsEntityListener(event: Events::prePersist, method: 'prePersist', entity: BlogPost::class)]
#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: BlogPost::class)]
class SeoEventSubscriber
{
public function __construct(private SeoService $seoService) {}
public function prePersist(BlogPost $blogPost, LifecycleEventArgs $event): void
{
if ($blogPost->isAutoGenerateSeo()) {
$this->seoService->generateSeoForBlogPost($blogPost);
}
}
public function preUpdate(BlogPost $blogPost, LifecycleEventArgs $event): void
{
// Only regenerate if content has changed and auto-generate is enabled
$uow = $event->getObjectManager()->getUnitOfWork();
$changeSet = $uow->getEntityChangeSet($blogPost);
$contentChanged = isset($changeSet['title']) ||
isset($changeSet['content']) ||
isset($changeSet['excerpt']);
if ($blogPost->isAutoGenerateSeo() && $contentChanged) {
$this->seoService->generateSeoForBlogPost($blogPost);
}
}
}<?php
namespace App\Twig;
use App\Service\SeoService;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class SeoExtension extends AbstractExtension
{
public function __construct(private SeoService $seoService) {}
public function getFunctions(): array
{
return [
new TwigFunction('seo_meta_tags', [$this, 'renderMetaTags'], ['is_safe' => ['html']]),
new TwigFunction('seo_schema_org', [$this, 'renderSchemaOrg'], ['is_safe' => ['html']]),
new TwigFunction('seo_breadcrumbs', [$this, 'renderBreadcrumbs'], ['is_safe' => ['html']]),
];
}
public function getFilters(): array
{
return [
new TwigFilter('seo_truncate', [$this, 'seoTruncate']),
new TwigFilter('seo_keywords', [$this, 'formatKeywords']),
];
}
public function renderMetaTags(array $seoData): string
{
$html = '';
// Basic meta tags
if (isset($seoData['title'])) {
$html .= '<title>' . htmlspecialchars($seoData['title']) . '</title>' . "\n";
}
if (isset($seoData['description'])) {
$html .= '<meta name="description" content="' . htmlspecialchars($seoData['description']) . '">' . "\n";
}
if (isset($seoData['keywords']) && is_array($seoData['keywords'])) {
$html .= '<meta name="keywords" content="' . htmlspecialchars(implode(', ', $seoData['keywords'])) . '">' . "\n";
}
if (isset($seoData['canonical'])) {
$html .= '<link rel="canonical" href="' . htmlspecialchars($seoData['canonical']) . '">' . "\n";
}
// Open Graph tags
if (isset($seoData['title'])) {
$html .= '<meta property="og:title" content="' . htmlspecialchars($seoData['title']) . '">' . "\n";
}
if (isset($seoData['description'])) {
$html .= '<meta property="og:description" content="' . htmlspecialchars($seoData['description']) . '">' . "\n";
}
if (isset($seoData['og_type'])) {
$html .= '<meta property="og:type" content="' . htmlspecialchars($seoData['og_type']) . '">' . "\n";
}
if (isset($seoData['canonical'])) {
$html .= '<meta property="og:url" content="' . htmlspecialchars($seoData['canonical']) . '">' . "\n";
}
if (isset($seoData['og_image'])) {
$html .= '<meta property="og:image" content="' . htmlspecialchars($seoData['og_image']) . '">' . "\n";
}
// Twitter Card tags
$html .= '<meta name="twitter:card" content="summary_large_image">' . "\n";
if (isset($seoData['title'])) {
$html .= '<meta name="twitter:title" content="' . htmlspecialchars($seoData['title']) . '">' . "\n";
}
if (isset($seoData['description'])) {
$html .= '<meta name="twitter:description" content="' . htmlspecialchars($seoData['description']) . '">' . "\n";
}
if (isset($seoData['og_image'])) {
$html .= '<meta name="twitter:image" content="' . htmlspecialchars($seoData['og_image']) . '">' . "\n";
}
// Article-specific tags
if (isset($seoData['article_published_time'])) {
$html .= '<meta property="article:published_time" content="' . htmlspecialchars($seoData['article_published_time']) . '">' . "\n";
}
if (isset($seoData['article_author'])) {
$html .= '<meta property="article:author" content="' . htmlspecialchars($seoData['article_author']) . '">' . "\n";
}
return $html;
}
public function renderSchemaOrg(array $schemaData): string
{
return '<script type="application/ld+json">' . "\n" .
json_encode($schemaData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" .
'</script>';
}
public function renderBreadcrumbs(array $breadcrumbs): string
{
$schemaData = [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => [],
];
foreach ($breadcrumbs as $position => $breadcrumb) {
$schemaData['itemListElement'][] = [
'@type' => 'ListItem',
'position' => $position + 1,
'name' => $breadcrumb['name'],
'item' => $breadcrumb['url'] ?? null,
];
}
return $this->renderSchemaOrg($schemaData);
}
public function seoTruncate(string $text, int $length = 160, string $suffix = '...'): string
{
if (strlen($text) <= $length) {
return $text;
}
return substr($text, 0, $length - strlen($suffix)) . $suffix;
}
public function formatKeywords(array $keywords): string
{
return implode(', ', $keywords);
}
}<?php
namespace App\Command;
use App\Entity\BlogPost;
use App\Service\SeoService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seo:analyze',
description: 'Analyze and generate SEO data for blog posts',
)]
class SeoAnalyzeCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private SeoService $seoService
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('entity', InputArgument::OPTIONAL, 'Entity class to analyze', 'BlogPost')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force regeneration of existing SEO data')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit number of entities to process', 100)
->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'Batch size for processing', 10);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$entity = $input->getArgument('entity');
$force = $input->getOption('force');
$limit = (int) $input->getOption('limit');
$batchSize = (int) $input->getOption('batch-size');
if ($entity !== 'BlogPost') {
$io->error('Currently only BlogPost entity is supported');
return Command::FAILURE;
}
$repository = $this->entityManager->getRepository(BlogPost::class);
$queryBuilder = $repository->createQueryBuilder('bp');
if (!$force) {
$queryBuilder->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->isNull('bp.metaTitle'),
$queryBuilder->expr()->eq('bp.metaTitle', $queryBuilder->expr()->literal('')),
$queryBuilder->expr()->isNull('bp.metaDescription'),
$queryBuilder->expr()->eq('bp.metaDescription', $queryBuilder->expr()->literal(''))
)
);
}
$queryBuilder->setMaxResults($limit);
$blogPosts = $queryBuilder->getQuery()->getResult();
if (empty($blogPosts)) {
$io->success('No blog posts need SEO generation');
return Command::SUCCESS;
}
$io->info(sprintf('Processing %d blog posts...', count($blogPosts)));
$io->progressStart(count($blogPosts));
$processed = 0;
$errors = 0;
foreach (array_chunk($blogPosts, $batchSize) as $batch) {
foreach ($batch as $blogPost) {
try {
$this->seoService->generateSeoForBlogPost($blogPost, $force);
$this->entityManager->persist($blogPost);
$processed++;
} catch (\Exception $e) {
$io->error(sprintf(
'Error processing blog post %d: %s',
$blogPost->getId(),
$e->getMessage()
));
$errors++;
}
$io->progressAdvance();
}
$this->entityManager->flush();
$this->entityManager->clear();
}
$io->progressFinish();
$io->newLine();
$io->success(sprintf(
'SEO analysis completed! Processed: %d, Errors: %d',
$processed,
$errors
));
return Command::SUCCESS;
}
}<?php
namespace App\Command;
use App\Entity\BlogPost;
use App\Service\SeoService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:seo:sitemap',
description: 'Generate XML sitemap',
)]
class SitemapGenerateCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private SeoService $seoService,
#[Autowire('%kernel.project_dir%')] private string $projectDir
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file path', 'public/sitemap.xml')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'Domain for URLs', 'https://myblog.com');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$outputPath = $input->getOption('output');
$domain = $input->getOption('domain');
// Get published blog posts
$blogPosts = $this->entityManager
->getRepository(BlogPost::class)
->findBy(['publishedAt' => ['not' => null]], ['publishedAt' => 'DESC']);
$publishedPosts = array_filter($blogPosts, fn(BlogPost $post) => $post->isPublished());
$io->info(sprintf('Generating sitemap for %d blog posts...', count($publishedPosts)));
try {
$sitemap = $this->seoService->generateSitemap($publishedPosts);
$fullOutputPath = $this->projectDir . '/' . $outputPath;
$outputDir = dirname($fullOutputPath);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($fullOutputPath, $sitemap);
$io->success(sprintf('Sitemap generated successfully: %s', $fullOutputPath));
return Command::SUCCESS;
} catch (\Exception $e) {
$io->error(sprintf('Error generating sitemap: %s', $e->getMessage()));
return Command::FAILURE;
}
}
}<?php
namespace App\Form;
use App\Entity\BlogPost;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BlogPostType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Title',
'attr' => ['class' => 'form-control'],
])
->add('slug', TextType::class, [
'label' => 'URL Slug',
'attr' => ['class' => 'form-control'],
])
->add('excerpt', TextareaType::class, [
'label' => 'Excerpt',
'required' => false,
'attr' => [
'class' => 'form-control',
'rows' => 3,
],
])
->add('content', TextareaType::class, [
'label' => 'Content',
'attr' => [
'class' => 'form-control',
'rows' => 15,
],
])
->add('featuredImage', TextType::class, [
'label' => 'Featured Image URL',
'required' => false,
'attr' => ['class' => 'form-control'],
])
->add('autoGenerateSeo', CheckboxType::class, [
'label' => 'Auto-generate SEO data',
'required' => false,
'attr' => [
'class' => 'form-check-input',
'data-toggle' => 'seo-fields',
],
])
->add('metaTitle', TextType::class, [
'label' => 'Meta Title',
'required' => false,
'attr' => [
'class' => 'form-control seo-field',
'maxlength' => 60,
'data-counter' => 'true',
],
])
->add('metaDescription', TextareaType::class, [
'label' => 'Meta Description',
'required' => false,
'attr' => [
'class' => 'form-control seo-field',
'rows' => 3,
'maxlength' => 160,
'data-counter' => 'true',
],
])
->add('metaKeywords', TextareaType::class, [
'label' => 'Meta Keywords (comma-separated)',
'required' => false,
'attr' => [
'class' => 'form-control seo-field',
'rows' => 2,
'placeholder' => 'keyword1, keyword2, keyword3',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BlogPost::class,
]);
}
}Create templates/base.html.twig:
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{# SEO Meta Tags #}
{% if seo_data is defined %}
{{ seo_meta_tags(seo_data) }}
{% else %}
<title>{% block title %}My Blog{% endblock %}</title>
<meta name="description" content="{% block description %}Default blog description{% endblock %}">
{% endif %}
{# Schema.org data #}
{% if seo_data.schema_org is defined %}
{{ seo_schema_org(seo_data.schema_org) }}
{% endif %}
{% block stylesheets %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ path('blog_index') }}">My Blog</a>
<div class="navbar-nav">
<a class="nav-link" href="{{ path('blog_index') }}">Blog</a>
<a class="nav-link" href="#">About</a>
</div>
</div>
</nav>
<main class="container mt-4">
{% block body %}{% endblock %}
</main>
<footer class="bg-dark text-light mt-5 py-4">
<div class="container">
<p>© {{ 'now'|date('Y') }} My Blog. All rights reserved.</p>
</div>
</footer>
{% block javascripts %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
{% endblock %}
</body>
</html>Create templates/blog/show.html.twig:
{% extends 'base.html.twig' %}
{% block body %}
<article class="blog-post">
<header class="mb-4">
<h1>{{ blog_post.title }}</h1>
<div class="text-muted mb-3">
<time datetime="{{ blog_post.publishedAt|date('c') }}">
{{ blog_post.publishedAt|date('F j, Y') }}
</time>
{% if blog_post.author %}
by {{ blog_post.author.name }}
{% endif %}
</div>
{% if blog_post.featuredImage %}
<img src="{{ blog_post.featuredImage }}"
alt="{{ blog_post.title }}"
class="img-fluid mb-4">
{% endif %}
</header>
<div class="content">
{{ blog_post.content|raw }}
</div>
<footer class="mt-4">
{% if blog_post.keywordsArray %}
<div class="keywords">
<strong>Tags:</strong>
{% for keyword in blog_post.keywordsArray %}
<span class="badge bg-secondary me-1">{{ keyword }}</span>
{% endfor %}
</div>
{% endif %}
</footer>
</article>
{% if app.environment == 'dev' and seo_analysis is defined %}
<div class="seo-debug mt-5 p-3 bg-light border">
<h3>SEO Analysis (Debug)</h3>
<div class="row">
<div class="col-md-6">
<h4>Content Stats</h4>
<ul>
<li>Word Count: {{ seo_analysis.word_count }}</li>
<li>Character Count: {{ seo_analysis.char_count }}</li>
<li>Reading Time: {{ seo_analysis.reading_time }} min</li>
<li>SEO Score: {{ seo_analysis.seo_score }}/100</li>
</ul>
</div>
<div class="col-md-6">
<h4>Structure</h4>
<ul>
<li>Headings: {{ seo_analysis.headings|length }}</li>
<li>Images: {{ seo_analysis.images.total }}</li>
<li>Internal Links: {{ seo_analysis.links.internal }}</li>
<li>External Links: {{ seo_analysis.links.external }}</li>
</ul>
</div>
</div>
{% if seo_analysis.recommendations %}
<h4>Recommendations</h4>
<ul>
{% for recommendation in seo_analysis.recommendations %}
<li>{{ recommendation }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
{% endblock %}- Configuration Guide - Detailed configuration options
- AI Integration - Setup AI providers for automated SEO
- Performance Optimization - Optimize for production
- Laravel Examples - Laravel framework integration
- Plain PHP Examples - Framework-agnostic usage