Skip to content

Symfony Integration

Rumen Damyanov edited this page Sep 22, 2025 · 2 revisions

Symfony Framework Examples

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.

Installation and Setup

Package Installation

Install the package via Composer:

composer require rumenx/php-seo

Bundle Configuration

Create 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;
    }
}

Service Registration

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

Entity Integration

Blog Post Entity with SEO

<?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...
}

SEO Service

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;
    }
}

Controller Examples

Blog Controller with SEO

<?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',
        ];
    }
}

API Controller for SEO Operations

<?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);
        }
    }
}

Event Subscribers

SEO Event Subscriber

<?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);
        }
    }
}

Twig Extensions

SEO Twig Extension

<?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);
    }
}

Console Commands

SEO Analysis Command

<?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;
    }
}

Sitemap Generation Command

<?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;
        }
    }
}

Form Integration

BlogPost Form Type with SEO

<?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,
        ]);
    }
}

Templates

Base Template with SEO

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>&copy; {{ '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>

Blog Post Template

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 %}

Next Steps

Clone this wiki locally