Laravel 13 应用的 SEO 优化是提升网站可见性的关键,本文介绍全面的 SEO 优化技术。

SEO 基础配置

Meta 标签管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<?php

namespace App\Services\SEO;

class MetaTagManager
{
protected array $tags = [];
protected string $title = '';
protected string $description = '';
protected string $keywords = '';
protected string $canonical = '';
protected array $openGraph = [];
protected array $twitter = [];

public function setTitle(string $title, string $separator = ' | '): self
{
$this->title = $title;
$this->tags['title'] = $title . $separator . config('app.name');
return $this;
}

public function setDescription(string $description): self
{
$this->description = $description;
$this->tags['description'] = $description;
return $this;
}

public function setKeywords(string|array $keywords): self
{
$this->keywords = is_array($keywords) ? implode(', ', $keywords) : $keywords;
$this->tags['keywords'] = $this->keywords;
return $this;
}

public function setCanonical(string $url): self
{
$this->canonical = $url;
return $this;
}

public function setRobots(string $directives = 'index, follow'): self
{
$this->tags['robots'] = $directives;
return $this;
}

public function noIndex(): self
{
return $this->setRobots('noindex, nofollow');
}

public function setOpenGraph(array $data): self
{
$this->openGraph = array_merge($this->openGraph, $data);
return $this;
}

public function setTwitter(array $data): self
{
$this->twitter = array_merge($this->twitter, $data);
return $this;
}

public function render(): string
{
$html = [];

if (!empty($this->tags['title'])) {
$html[] = '<title>' . e($this->tags['title']) . '</title>';
}

foreach (['description', 'keywords', 'robots'] as $name) {
if (!empty($this->tags[$name])) {
$html[] = '<meta name="' . $name . '" content="' . e($this->tags[$name]) . '">';
}
}

if (!empty($this->canonical)) {
$html[] = '<link rel="canonical" href="' . e($this->canonical) . '">';
}

$html = array_merge($html, $this->renderOpenGraph());
$html = array_merge($html, $this->renderTwitter());

return implode("\n ", $html);
}

protected function renderOpenGraph(): array
{
$html = [];

$defaults = [
'type' => 'website',
'title' => $this->tags['title'] ?? '',
'description' => $this->description,
'url' => $this->canonical,
'site_name' => config('app.name'),
];

$data = array_merge($defaults, $this->openGraph);

foreach ($data as $property => $content) {
if (!empty($content)) {
$html[] = '<meta property="og:' . $property . '" content="' . e($content) . '">';
}
}

return $html;
}

protected function renderTwitter(): array
{
$html = [];

$defaults = [
'card' => 'summary_large_image',
'title' => $this->tags['title'] ?? '',
'description' => $this->description,
];

$data = array_merge($defaults, $this->twitter);

foreach ($data as $name => $content) {
if (!empty($content)) {
$html[] = '<meta name="twitter:' . $name . '" content="' . e($content) . '">';
}
}

return $html;
}
}

结构化数据

Schema.org 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<?php

namespace App\Services\SEO;

class SchemaBuilder
{
protected array $data = [];

public function article(array $article): self
{
$this->data = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $article['title'],
'description' => $article['description'] ?? '',
'image' => $article['image'] ?? '',
'author' => [
'@type' => 'Person',
'name' => $article['author'] ?? '',
],
'publisher' => [
'@type' => 'Organization',
'name' => config('app.name'),
'logo' => [
'@type' => 'ImageObject',
'url' => asset('images/logo.png'),
],
],
'datePublished' => $article['published_at'] ?? '',
'dateModified' => $article['updated_at'] ?? '',
];

return $this;
}

public function product(array $product): self
{
$this->data = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product['name'],
'description' => $product['description'] ?? '',
'image' => $product['images'] ?? [],
'brand' => [
'@type' => 'Brand',
'name' => $product['brand'] ?? '',
],
'offers' => [
'@type' => 'Offer',
'price' => $product['price'],
'priceCurrency' => $product['currency'] ?? 'USD',
'availability' => $product['in_stock']
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
],
];

if (!empty($product['rating'])) {
$this->data['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => $product['rating']['value'],
'reviewCount' => $product['rating']['count'],
];
}

return $this;
}

public function breadcrumb(array $items): self
{
$this->data = [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => array_map(function ($item, $index) {
return [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $item['name'],
'item' => $item['url'],
];
}, $items, array_keys($items)),
];

return $this;
}

public function organization(array $org = []): self
{
$this->data = [
'@context' => 'https://schema.org',
'@type' => 'Organization',
'name' => $org['name'] ?? config('app.name'),
'url' => $org['url'] ?? config('app.url'),
'logo' => $org['logo'] ?? asset('images/logo.png'),
'contactPoint' => $org['contact'] ?? [],
'sameAs' => $org['social'] ?? [],
];

return $this;
}

public function faq(array $questions): self
{
$this->data = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map(function ($q) {
return [
'@type' => 'Question',
'name' => $q['question'],
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $q['answer'],
],
];
}, $questions),
];

return $this;
}

public function render(): string
{
if (empty($this->data)) {
return '';
}

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

public function toArray(): array
{
return $this->data;
}
}

URL 优化

友好 URL 生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php

namespace App\Services\SEO;

use Illuminate\Support\Str;

class SlugGenerator
{
protected int $maxLength = 100;
protected string $separator = '-';

public function generate(string $title, string $model = null, string $field = 'slug'): string
{
$slug = Str::slug($title, $this->separator);

if (strlen($slug) > $this->maxLength) {
$slug = substr($slug, 0, $this->maxLength);
$slug = preg_replace('/[^a-z0-9]+$/', '', $slug);
}

if ($model) {
$slug = $this->ensureUnique($slug, $model, $field);
}

return $slug;
}

protected function ensureUnique(string $slug, string $model, string $field): string
{
$count = $model::where($field, $slug)->count();

if ($count === 0) {
return $slug;
}

$counter = 1;
$originalSlug = $slug;

while ($model::where($field, $slug)->exists()) {
$slug = $originalSlug . $this->separator . $counter;
$counter++;
}

return $slug;
}
}

trait HasSlug
{
protected static function bootHasSlug(): void
{
static::creating(function ($model) {
if (empty($model->slug) && !empty($model->title)) {
$model->slug = app(SlugGenerator::class)->generate(
$model->title,
static::class
);
}
});
}

public function getRouteKeyName(): string
{
return 'slug';
}
}

规范化 URL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php

namespace App\Services\SEO;

class CanonicalUrl
{
protected bool $forceHttps = true;
protected bool $removeWww = false;
protected bool $removeTrailingSlash = true;

public function generate(string $url = null): string
{
$url = $url ?? request()->fullUrl();

$parsed = parse_url($url);

if ($this->forceHttps && ($parsed['scheme'] ?? '') === 'http') {
$parsed['scheme'] = 'https';
}

if ($this->removeWww && str_starts_with($parsed['host'] ?? '', 'www.')) {
$parsed['host'] = substr($parsed['host'], 4);
}

if ($this->removeTrailingSlash && isset($parsed['path'])) {
$parsed['path'] = rtrim($parsed['path'], '/') ?: '/';
}

return $this->buildUrl($parsed);
}

protected function buildUrl(array $parsed): string
{
$url = ($parsed['scheme'] ?? 'https') . '://';
$url .= $parsed['host'] ?? '';

if (isset($parsed['port']) && $parsed['port'] !== 80 && $parsed['port'] !== 443) {
$url .= ':' . $parsed['port'];
}

$url .= $parsed['path'] ?? '/';

return $url;
}
}

页面速度优化

资源优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<?php

namespace App\Services\SEO;

class AssetOptimizer
{
public function minifyHtml(string $html): string
{
$search = [
'/\>[^\S ]+/s',
'/[^\S ]+\</s',
'/(\s)+/s',
'/<!--(.|\s)*?-->/',
];

$replace = [
'>',
'<',
' ',
'',
];

return preg_replace($search, $replace, $html);
}

public function generateCriticalCss(string $html, array $cssFiles): string
{
$criticalCss = '';

$patterns = [
'/class="([^"]+)"/',
'/id="([^"]+)"/',
];

$classes = [];
foreach ($patterns as $pattern) {
preg_match_all($pattern, $html, $matches);
$classes = array_merge($classes, $matches[1] ?? []);
}

foreach ($cssFiles as $file) {
if (file_exists($file)) {
$css = file_get_contents($file);
$criticalCss .= $this->extractCriticalRules($css, $classes);
}
}

return $criticalCss;
}

protected function extractCriticalRules(string $css, array $classes): string
{
$critical = '';

foreach ($classes as $class) {
$pattern = '/\.' . preg_quote($class, '/') . '\s*\{[^}]+\}/';
preg_match_all($pattern, $css, $matches);
$critical .= implode('', $matches[0] ?? []);
}

return $critical;
}

public function lazyLoadImages(string $html): string
{
return preg_replace(
'/<img([^>]*)src=/',
'<img$1loading="lazy" src=',
$html
);
}

public function preloadResources(array $resources): string
{
$html = [];

foreach ($resources as $resource) {
$html[] = '<link rel="preload" ' .
'href="' . e($resource['href']) . '" ' .
'as="' . e($resource['as']) . '" ' .
(isset($resource['type']) ? 'type="' . e($resource['type']) . '" ' : '') .
(isset($resource['crossorigin']) ? 'crossorigin ' : '') .
'>';
}

return implode("\n", $html);
}
}

SEO 中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

namespace App\Http\Middleware;

use App\Services\SEO\CanonicalUrl;
use App\Services\SEO\MetaTagManager;
use Closure;
use Illuminate\Http\Request;

class SEOMiddleware
{
protected MetaTagManager $meta;
protected CanonicalUrl $canonical;

public function __construct(MetaTagManager $meta, CanonicalUrl $canonical)
{
$this->meta = $meta;
$this->canonical = $canonical;
}

public function handle(Request $request, Closure $next)
{
$response = $next($request);

if ($this->shouldAddSEOHeaders($request)) {
$this->addSEOHeaders($response);
}

return $response;
}

protected function shouldAddSEOHeaders(Request $request): bool
{
return $request->isMethod('GET') &&
!$request->ajax() &&
!$request->wantsJson();
}

protected function addSEOHeaders($response): void
{
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');

$canonical = $this->canonical->generate();
$response->headers->set('Link', '<' . $canonical . '>; rel="canonical"');
}
}

SEO 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
<?php

namespace App\Services\SEO;

class SEOAnalyzer
{
public function analyzePage(string $url): array
{
$content = @file_get_contents($url);

if (!$content) {
return ['error' => 'Unable to fetch page'];
}

return [
'title' => $this->analyzeTitle($content),
'meta_description' => $this->analyzeMetaDescription($content),
'headings' => $this->analyzeHeadings($content),
'images' => $this->analyzeImages($content),
'links' => $this->analyzeLinks($content, $url),
'performance' => $this->analyzePerformance($content),
];
}

protected function analyzeTitle(string $content): array
{
preg_match('/<title>([^<]+)<\/title>/i', $content, $matches);
$title = $matches[1] ?? '';

$issues = [];

if (empty($title)) {
$issues[] = 'Missing title tag';
} elseif (strlen($title) < 30) {
$issues[] = 'Title is too short (recommended: 50-60 characters)';
} elseif (strlen($title) > 60) {
$issues[] = 'Title is too long (recommended: 50-60 characters)';
}

return [
'value' => $title,
'length' => strlen($title),
'issues' => $issues,
];
}

protected function analyzeMetaDescription(string $content): array
{
preg_match('/<meta[^>]*name="description"[^>]*content="([^"]+)"[^>]*>/i', $content, $matches);
$description = $matches[1] ?? '';

$issues = [];

if (empty($description)) {
$issues[] = 'Missing meta description';
} elseif (strlen($description) < 120) {
$issues[] = 'Meta description is too short (recommended: 150-160 characters)';
} elseif (strlen($description) > 160) {
$issues[] = 'Meta description is too long (recommended: 150-160 characters)';
}

return [
'value' => $description,
'length' => strlen($description),
'issues' => $issues,
];
}

protected function analyzeHeadings(string $content): array
{
$headings = [];

for ($i = 1; $i <= 6; $i++) {
preg_match_all('/<h' . $i . '[^>]*>([^<]+)<\/h' . $i . '>/i', $content, $matches);
$headings['h' . $i] = [
'count' => count($matches[0]),
'values' => $matches[1] ?? [],
];
}

$issues = [];

if ($headings['h1']['count'] === 0) {
$issues[] = 'Missing H1 tag';
} elseif ($headings['h1']['count'] > 1) {
$issues[] = 'Multiple H1 tags found';
}

return [
'headings' => $headings,
'issues' => $issues,
];
}

protected function analyzeImages(string $content): array
{
preg_match_all('/<img[^>]+>/i', $content, $matches);

$images = [];
$missingAlt = 0;

foreach ($matches[0] as $img) {
preg_match('/src="([^"]+)"/i', $img, $src);
preg_match('/alt="([^"]*)"/i', $img, $alt);

if (empty($alt[1])) {
$missingAlt++;
}

$images[] = [
'src' => $src[1] ?? '',
'alt' => $alt[1] ?? '',
];
}

$issues = [];
if ($missingAlt > 0) {
$issues[] = "{$missingAlt} images missing alt text";
}

return [
'total' => count($images),
'missing_alt' => $missingAlt,
'issues' => $issues,
];
}

protected function analyzeLinks(string $content, string $baseUrl): array
{
preg_match_all('/<a[^>]+href="([^"]+)"[^>]*>/i', $content, $matches);

$internal = 0;
$external = 0;
$broken = [];

$baseHost = parse_url($baseUrl, PHP_URL_HOST);

foreach ($matches[1] as $href) {
if (str_starts_with($href, 'http')) {
$host = parse_url($href, PHP_URL_HOST);
if ($host === $baseHost) {
$internal++;
} else {
$external++;
}
} else {
$internal++;
}
}

return [
'total' => count($matches[1]),
'internal' => $internal,
'external' => $external,
];
}

protected function analyzePerformance(string $content): array
{
return [
'size' => strlen($content),
'size_formatted' => $this->formatBytes(strlen($content)),
];
}

protected function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB'];
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
return number_format($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
}
}

SEO 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?php

namespace App\Console\Commands;

use App\Services\SEO\SEOAnalyzer;
use Illuminate\Console\Command;

class SEOAnalyzeCommand extends Command
{
protected $signature = 'seo:analyze {url : URL to analyze}';
protected $description = 'Analyze page SEO';

public function handle(SEOAnalyzer $analyzer): int
{
$url = $this->argument('url');

$this->info("Analyzing: {$url}");
$this->newLine();

$result = $analyzer->analyzePage($url);

if (isset($result['error'])) {
$this->error($result['error']);
return self::FAILURE;
}

$this->info('=== Title ===');
$this->line("Value: {$result['title']['value']}");
$this->line("Length: {$result['title']['length']}");
if (!empty($result['title']['issues'])) {
foreach ($result['title']['issues'] as $issue) {
$this->warn("Issue: {$issue}");
}
}

$this->newLine();

$this->info('=== Meta Description ===');
$this->line("Value: {$result['meta_description']['value']}");
$this->line("Length: {$result['meta_description']['length']}");
if (!empty($result['meta_description']['issues'])) {
foreach ($result['meta_description']['issues'] as $issue) {
$this->warn("Issue: {$issue}");
}
}

$this->newLine();

$this->info('=== Headings ===');
foreach ($result['headings']['headings'] as $tag => $data) {
$this->line("{$tag}: {$data['count']}");
}

$this->newLine();

$this->info('=== Images ===');
$this->line("Total: {$result['images']['total']}");
$this->line("Missing Alt: {$result['images']['missing_alt']}");

return self::SUCCESS;
}
}

总结

Laravel 13 的 SEO 优化需要从 Meta 标签、结构化数据、URL 优化、页面速度等多个方面入手。通过系统化的 SEO 策略和技术实现,可以显著提升网站在搜索引擎中的可见性。