feat: implement structured data support for blog and page components, enhancing SEO and user experience through JSON-LD integration

This commit is contained in:
Ümit Tunç
2026-05-21 21:54:12 +03:00
parent 8675325f50
commit 6d9ffc808b
15 changed files with 509 additions and 176 deletions
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Support;
use App\Models\Blog;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str;
class BlogStructuredData extends StructuredData
{
public static function forIndex(LengthAwarePaginator $posts, string $pageUrl): array
{
$pageName = __('blog.meta-index-title');
$pageDescription = __('blog.meta-index-description');
$itemListElements = [];
$offset = ($posts->currentPage() - 1) * $posts->perPage();
foreach ($posts as $index => $post) {
$itemListElements[] = self::listItem(
$offset + $index + 1,
self::postSummarySchema($post),
);
}
return self::wrap([
self::websiteNode(),
self::organizationNode(),
self::webPageNode($pageUrl, $pageName, $pageDescription, [
'@type' => 'CollectionPage',
'mainEntity' => ['@id' => $pageUrl . '#itemlist'],
]),
self::breadcrumbNode($pageUrl, [
['name' => __('blog.breadcrumb_home'), 'item' => self::siteUrl()],
['name' => $pageName, 'item' => $pageUrl],
]),
[
'@type' => 'ItemList',
'@id' => $pageUrl . '#itemlist',
'name' => $pageName,
'numberOfItems' => $posts->total(),
'itemListElement' => $itemListElements,
],
]);
}
public static function forShow(Blog $post, string $pageUrl): array
{
$title = $post->translate('title');
$description = Str::limit(strip_tags((string) $post->translate('excerpt')), 160);
$imageUrl = $post->featured_image_url ?: self::defaultImageUrl();
$publishedAt = $post->published_at ?? $post->created_at;
$blogPosting = [
'@type' => 'BlogPosting',
'@id' => $pageUrl . '#article',
'mainEntityOfPage' => ['@id' => $pageUrl . '#webpage'],
'headline' => $title,
'description' => $description,
'image' => [$imageUrl],
'inLanguage' => app()->getLocale(),
'author' => [
'@type' => 'Person',
'name' => $post->author->name ?? __('blog.default_author'),
],
'publisher' => self::publisherNode(),
'datePublished' => $publishedAt->toIso8601String(),
'dateModified' => $post->updated_at->toIso8601String(),
'url' => $pageUrl,
];
$content = strip_tags((string) $post->translate('content'));
if ($content !== '') {
$blogPosting['articleBody'] = Str::limit($content, 5000);
}
return self::wrap([
self::websiteNode(),
self::organizationNode(),
self::webPageNode($pageUrl, $title, $description, [
'mainEntity' => ['@id' => $pageUrl . '#article'],
'dateModified' => $post->updated_at->toIso8601String(),
]),
self::breadcrumbNode($pageUrl, [
['name' => __('blog.breadcrumb_home'), 'item' => self::siteUrl()],
['name' => __('blog.meta-index-title'), 'item' => route('blog.index')],
['name' => $title, 'item' => $pageUrl],
]),
$blogPosting,
]);
}
/**
* @return array<string, mixed>
*/
protected static function postSummarySchema(Blog $post): array
{
$schema = [
'@type' => 'BlogPosting',
'headline' => $post->translate('title'),
'url' => route('blog.show', $post->slug),
];
if ($post->featured_image_url) {
$schema['image'] = [$post->featured_image_url];
}
$publishedAt = $post->published_at ?? $post->created_at;
if ($publishedAt) {
$schema['datePublished'] = $publishedAt->toIso8601String();
}
return $schema;
}
/**
* @return array<string, mixed>
*/
protected static function publisherNode(): array
{
$publisher = [
'@type' => 'Organization',
'@id' => self::siteUrl() . '#organization',
'name' => self::siteName(),
];
$logo = setting('site_logo');
if ($logo) {
$logoUrl = str_starts_with($logo, 'http') ? $logo : asset('storage/' . ltrim($logo, '/'));
$publisher['logo'] = [
'@type' => 'ImageObject',
'url' => $logoUrl,
];
}
return $publisher;
}
protected static function defaultImageUrl(): string
{
$defaultImage = setting('default_meta_image');
if ($defaultImage) {
return str_starts_with($defaultImage, 'http') ? $defaultImage : asset($defaultImage);
}
return asset('assets/img/logo.png');
}
}
+34 -140
View File
@@ -6,12 +6,10 @@ use App\Models\MusicProduction;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str;
class MusicProductionStructuredData
class MusicProductionStructuredData extends StructuredData
{
public static function forIndex(LengthAwarePaginator $productions, string $pageUrl): array
{
$siteName = setting('site_name', config('app.name'));
$siteUrl = url('/');
$pageName = __('music_productions.meta-index-title');
$pageDescription = __('music_productions.meta-index-description');
@@ -19,45 +17,23 @@ class MusicProductionStructuredData
$offset = ($productions->currentPage() - 1) * $productions->perPage();
foreach ($productions as $index => $production) {
$itemListElements[] = [
'@type' => 'ListItem',
'position' => $offset + $index + 1,
'item' => self::productionSchema($production),
];
$itemListElements[] = self::listItem(
$offset + $index + 1,
self::productionSchema($production),
);
}
$graph = [
self::websiteNode($siteUrl, $siteName),
self::organizationNode($siteUrl, $siteName),
[
return self::wrap([
self::websiteNode(route('music-productions.index') . '?q={search_term_string}'),
self::organizationNode(),
self::webPageNode($pageUrl, $pageName, $pageDescription, [
'@type' => 'CollectionPage',
'@id' => $pageUrl . '#webpage',
'url' => $pageUrl,
'name' => $pageName,
'description' => $pageDescription,
'inLanguage' => app()->getLocale(),
'isPartOf' => ['@id' => $siteUrl . '#website'],
'breadcrumb' => ['@id' => $pageUrl . '#breadcrumb'],
'mainEntity' => ['@id' => $pageUrl . '#itemlist'],
],
[
'@type' => 'BreadcrumbList',
'@id' => $pageUrl . '#breadcrumb',
'itemListElement' => [
[
'@type' => 'ListItem',
'position' => 1,
'name' => __('music_productions.breadcrumb_home'),
'item' => $siteUrl,
],
[
'@type' => 'ListItem',
'position' => 2,
'name' => __('music_productions.nav-music-productions'),
'item' => route('music-productions.index'),
],
],
],
]),
self::breadcrumbNode($pageUrl, [
['name' => __('music_productions.breadcrumb_home'), 'item' => self::siteUrl()],
['name' => __('music_productions.nav-music-productions'), 'item' => route('music-productions.index')],
]),
[
'@type' => 'ItemList',
'@id' => $pageUrl . '#itemlist',
@@ -65,72 +41,36 @@ class MusicProductionStructuredData
'numberOfItems' => $productions->total(),
'itemListElement' => $itemListElements,
],
];
return [
'@context' => 'https://schema.org',
'@graph' => $graph,
];
]);
}
public static function forShow(MusicProduction $production, string $pageUrl): array
{
$siteName = setting('site_name', config('app.name'));
$siteUrl = url('/');
$title = $production->translate('title');
$description = Str::limit(strip_tags((string) $production->translate('content')), 160);
$graph = [
self::websiteNode($siteUrl, $siteName),
self::organizationNode($siteUrl, $siteName),
[
'@type' => 'BreadcrumbList',
'@id' => $pageUrl . '#breadcrumb',
'itemListElement' => [
[
'@type' => 'ListItem',
'position' => 1,
'name' => __('music_productions.breadcrumb_home'),
'item' => $siteUrl,
],
[
'@type' => 'ListItem',
'position' => 2,
'name' => __('music_productions.nav-music-productions'),
'item' => route('music-productions.index'),
],
[
'@type' => 'ListItem',
'position' => 3,
'name' => $title,
'item' => $pageUrl,
],
],
],
array_merge(
[
'@type' => 'WebPage',
'@id' => $pageUrl . '#webpage',
'url' => $pageUrl,
'name' => $title,
'description' => $description,
'inLanguage' => app()->getLocale(),
'isPartOf' => ['@id' => $siteUrl . '#website'],
'breadcrumb' => ['@id' => $pageUrl . '#breadcrumb'],
'mainEntity' => ['@id' => $pageUrl . '#production'],
],
$production->updated_at ? ['dateModified' => $production->updated_at->toIso8601String()] : [],
),
$webPageExtra = [
'mainEntity' => ['@id' => $pageUrl . '#production'],
];
if ($production->updated_at) {
$webPageExtra['dateModified'] = $production->updated_at->toIso8601String();
}
return self::wrap([
self::websiteNode(route('music-productions.index') . '?q={search_term_string}'),
self::organizationNode(),
self::webPageNode($pageUrl, $title, $description, $webPageExtra),
self::breadcrumbNode($pageUrl, [
['name' => __('music_productions.breadcrumb_home'), 'item' => self::siteUrl()],
['name' => __('music_productions.nav-music-productions'), 'item' => route('music-productions.index')],
['name' => $title, 'item' => $pageUrl],
]),
array_merge(
['@id' => $pageUrl . '#production'],
self::productionSchema($production),
),
];
return [
'@context' => 'https://schema.org',
'@graph' => $graph,
];
]);
}
/**
@@ -145,7 +85,7 @@ class MusicProductionStructuredData
];
if ($production->cover_image_url) {
$schema['image'] = $production->cover_image_url;
$schema['image'] = [$production->cover_image_url];
}
$content = strip_tags((string) $production->translate('content'));
@@ -176,50 +116,4 @@ class MusicProductionStructuredData
return $schema;
}
/**
* @return array<string, mixed>
*/
protected static function websiteNode(string $siteUrl, string $siteName): array
{
return [
'@type' => 'WebSite',
'@id' => $siteUrl . '#website',
'url' => $siteUrl,
'name' => $siteName,
'publisher' => ['@id' => $siteUrl . '#organization'],
'potentialAction' => [
'@type' => 'SearchAction',
'target' => [
'@type' => 'EntryPoint',
'urlTemplate' => route('music-productions.index') . '?q={search_term_string}',
],
'query-input' => 'required name=search_term_string',
],
];
}
/**
* @return array<string, mixed>
*/
protected static function organizationNode(string $siteUrl, string $siteName): array
{
$node = [
'@type' => 'Organization',
'@id' => $siteUrl . '#organization',
'name' => $siteName,
'url' => $siteUrl,
];
$logo = setting('site_logo');
if ($logo) {
$logoUrl = str_starts_with($logo, 'http') ? $logo : asset('storage/' . ltrim($logo, '/'));
$node['logo'] = [
'@type' => 'ImageObject',
'url' => $logoUrl,
];
}
return $node;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Support;
use App\Models\Page;
class PageStructuredData extends StructuredData
{
public static function forPage(?Page $page, string $pageUrl, ?string $name = null, ?string $description = null): array
{
$pageName = $name ?: ($page
? (method_exists($page, 'translate')
? ($page->translate('meta_title') ?: $page->translate('title'))
: ($page->meta_title ?? $page->title ?? self::siteName()))
: self::siteName());
$pageDescription = $description ?: ($page
? (method_exists($page, 'translate')
? ($page->translate('meta_description') ?: ($page->excerpt ?? null))
: ($page->meta_description ?? $page->excerpt ?? null))
: setting('seo_meta_description'));
$webPageExtra = [];
if ($page?->updated_at) {
$webPageExtra['dateModified'] = $page->updated_at->toIso8601String();
}
if ($page?->is_homepage) {
$webPageExtra['@type'] = 'WebPage';
}
$graph = [
self::websiteNode(),
self::organizationNode(),
self::webPageNode($pageUrl, $pageName, $pageDescription, $webPageExtra),
self::breadcrumbNode($pageUrl, self::breadcrumbItems($page, $pageUrl, $pageName)),
];
return self::wrap($graph);
}
/**
* @return array<int, array{name: string, item: string}>
*/
protected static function breadcrumbItems(?Page $page, string $pageUrl, string $pageName): array
{
$items = [
['name' => __('pages.breadcrumb_home'), 'item' => self::siteUrl()],
];
if ($page && !$page->is_homepage) {
$items[] = ['name' => $pageName, 'item' => $pageUrl];
}
return $items;
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Support;
abstract class StructuredData
{
/**
* @param array<int, array<string, mixed>> $graph
* @return array<string, mixed>
*/
protected static function wrap(array $graph): array
{
return [
'@context' => 'https://schema.org',
'@graph' => array_values($graph),
];
}
protected static function siteUrl(): string
{
return url('/');
}
protected static function siteName(): string
{
return (string) setting('site_name', config('app.name'));
}
/**
* @return array<string, mixed>
*/
protected static function websiteNode(?string $searchUrlTemplate = null): array
{
$siteUrl = static::siteUrl();
$siteName = static::siteName();
$node = [
'@type' => 'WebSite',
'@id' => $siteUrl . '#website',
'url' => $siteUrl,
'name' => $siteName,
'inLanguage' => app()->getLocale(),
'publisher' => ['@id' => $siteUrl . '#organization'],
];
if ($searchUrlTemplate) {
$node['potentialAction'] = [
'@type' => 'SearchAction',
'target' => [
'@type' => 'EntryPoint',
'urlTemplate' => $searchUrlTemplate,
],
'query-input' => 'required name=search_term_string',
];
}
return $node;
}
/**
* @return array<string, mixed>
*/
protected static function organizationNode(): array
{
$siteUrl = static::siteUrl();
$siteName = static::siteName();
$node = [
'@type' => 'Organization',
'@id' => $siteUrl . '#organization',
'name' => $siteName,
'url' => $siteUrl,
];
$logo = setting('site_logo');
if ($logo) {
$logoUrl = str_starts_with($logo, 'http') ? $logo : asset('storage/' . ltrim($logo, '/'));
$node['logo'] = [
'@type' => 'ImageObject',
'url' => $logoUrl,
];
}
return $node;
}
/**
* @param array<int, array{name: string, item: string}> $items
* @return array<string, mixed>
*/
protected static function breadcrumbNode(string $pageUrl, array $items): array
{
$elements = [];
foreach ($items as $index => $item) {
$elements[] = [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $item['name'],
'item' => $item['item'],
];
}
return [
'@type' => 'BreadcrumbList',
'@id' => $pageUrl . '#breadcrumb',
'itemListElement' => $elements,
];
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
protected static function webPageNode(
string $pageUrl,
string $name,
?string $description = null,
array $extra = [],
): array {
$node = array_merge([
'@type' => 'WebPage',
'@id' => $pageUrl . '#webpage',
'url' => $pageUrl,
'name' => $name,
'inLanguage' => app()->getLocale(),
'isPartOf' => ['@id' => static::siteUrl() . '#website'],
'breadcrumb' => ['@id' => $pageUrl . '#breadcrumb'],
], $extra);
if ($description) {
$node['description'] = $description;
}
return $node;
}
/**
* @return array<string, mixed>
*/
protected static function listItem(int $position, array $item): array
{
return [
'@type' => 'ListItem',
'position' => $position,
'item' => $item,
];
}
}