DEV Community

ahmet gedik
ahmet gedik

Posted on

Implementing Structured Data (VideoObject) for SEO

Structured data tells search engines exactly what your page content is. For video pages, the VideoObject schema type enables rich search results with video thumbnails, duration, and publication dates. Here's how I implemented it for every video page on DailyWatch.

What VideoObject Structured Data Looks Like

Google expects JSON-LD format in a <script> tag:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "VideoObject",
  "name": "Video Title Here",
  "description": "A description of the video content...",
  "thumbnailUrl": "https://i.ytimg.com/vi/VIDEO_ID/mqdefault.jpg",
  "uploadDate": "2026-02-15T10:30:00Z",
  "duration": "PT4M32S",
  "embedUrl": "https://www.youtube.com/embed/VIDEO_ID",
  "interactionStatistic": {
    "@type": "InteractionCounter",
    "interactionType": "https://schema.org/WatchAction",
    "userInteractionCount": 1500000
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

PHP Implementation

class VideoStructuredData {
    public function __construct(
        private readonly string $siteUrl,
    ) {}

    public function generate(array $video): string {
        $data = [
            '@context' => 'https://schema.org',
            '@type' => 'VideoObject',
            'name' => $video['title'],
            'description' => $this->getDescription($video),
            'thumbnailUrl' => $this->getThumbnail($video),
            'uploadDate' => $this->formatDate($video['published_at'] ?? ''),
            'embedUrl' => 'https://www.youtube.com/embed/' . $video['video_id'],
            'contentUrl' => $this->siteUrl . '/watch/' . $video['video_id'],
        ];

        // Duration in ISO 8601 format
        if (!empty($video['duration'])) {
            $data['duration'] = $video['duration']; // Already ISO 8601 from YouTube
        }

        // View count
        if (!empty($video['view_count'])) {
            $data['interactionStatistic'] = [
                '@type' => 'InteractionCounter',
                'interactionType' => 'https://schema.org/WatchAction',
                'userInteractionCount' => (int)$video['view_count'],
            ];
        }

        // Channel as publisher
        if (!empty($video['channel_title'])) {
            $data['author'] = [
                '@type' => 'Person',
                'name' => $video['channel_title'],
            ];
        }

        return '<script type="application/ld+json">' . "\n"
            . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
            . "\n" . '</script>';
    }

    private function getDescription(array $video): string {
        $desc = $video['description'] ?? '';
        if (empty($desc)) {
            $desc = "Watch {$video['title']} on DailyWatch.";
        }
        return mb_substr($desc, 0, 300);
    }

    private function getThumbnail(array $video): string {
        $thumb = $video['thumbnail_url'] ?? '';
        if (empty($thumb) && !empty($video['video_id'])) {
            return 'https://i.ytimg.com/vi/' . $video['video_id'] . '/mqdefault.jpg';
        }
        return $thumb;
    }

    private function formatDate(string $date): string {
        if (empty($date)) return date('c');
        try {
            return (new \DateTimeImmutable($date))->format('c');
        } catch (\Exception) {
            return date('c');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage in Templates

// In your watch page template
$structuredData = new VideoStructuredData(siteUrl: 'https://dailywatch.video');
?>
<head>
    <title><?= htmlspecialchars($video['title']) ?> | DailyWatch</title>
    <?= $structuredData->generate($video) ?>
</head>
Enter fullscreen mode Exit fullscreen mode

Adding WebSite Schema to Home Page

function generateWebsiteSchema(): string {
    $data = [
        '@context' => 'https://schema.org',
        '@type' => 'WebSite',
        'name' => 'DailyWatch',
        'url' => 'https://dailywatch.video',
        'description' => 'Your daily dose of trending videos from around the world.',
        'potentialAction' => [
            '@type' => 'SearchAction',
            'target' => 'https://dailywatch.video/search?q={search_term_string}',
            'query-input' => 'required name=search_term_string',
        ],
    ];

    return '<script type="application/ld+json">' . "\n"
        . json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        . "\n" . '</script>';
}
Enter fullscreen mode Exit fullscreen mode

BreadcrumbList for Navigation

function generateBreadcrumbs(array $items): string {
    $list = [
        '@context' => 'https://schema.org',
        '@type' => 'BreadcrumbList',
        'itemListElement' => [],
    ];

    foreach ($items as $position => $item) {
        $list['itemListElement'][] = [
            '@type' => 'ListItem',
            'position' => $position + 1,
            'name' => $item['name'],
            'item' => $item['url'],
        ];
    }

    return '<script type="application/ld+json">' . "\n"
        . json_encode($list, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        . "\n" . '</script>';
}

// Usage on a watch page:
echo generateBreadcrumbs([
    ['name' => 'Home', 'url' => 'https://dailywatch.video'],
    ['name' => 'Music', 'url' => 'https://dailywatch.video/category/music'],
    ['name' => $video['title'], 'url' => 'https://dailywatch.video/watch/' . $video['video_id']],
]);
Enter fullscreen mode Exit fullscreen mode

Validating Your Structured Data

Always validate using Google's Rich Results Test: https://search.google.com/test/rich-results

After implementing structured data on dailywatch.video, Google Search Console began showing rich result impressions within 2 weeks. Video pages now display with thumbnails in search results, significantly improving click-through rates.

Top comments (0)