Laravel 13 网站地图是 SEO 的重要组成部分,本文介绍如何生成和管理网站地图。

网站地图概述

Sitemap 配置

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
<?php

return [
'sitemap' => [
'enabled' => env('SITEMAP_ENABLED', true),
'path' => public_path('sitemap.xml'),
'url' => url('sitemap.xml'),

'cache' => [
'enabled' => true,
'key' => 'sitemap.cache',
'ttl' => 3600,
],

'defaults' => [
'changefreq' => 'weekly',
'priority' => 0.5,
],

'limits' => [
'max_urls' => 50000,
'max_file_size' => 52428800,
],
],
];

Sitemap 生成器

核心 Sitemap 类

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
<?php

namespace App\Services\Sitemap;

use DateTime;
use Illuminate\Support\Collection;

class Sitemap
{
protected Collection $urls;
protected string $defaultChangefreq = 'weekly';
protected float $defaultPriority = 0.5;

public function __construct()
{
$this->urls = collect();
}

public function add(Url $url): self
{
$this->urls->push($url);
return $this;
}

public function addUrl(string $loc, array $options = []): self
{
$url = new Url(
loc: $loc,
lastmod: $options['lastmod'] ?? null,
changefreq: $options['changefreq'] ?? $this->defaultChangefreq,
priority: $options['priority'] ?? $this->defaultPriority,
images: $options['images'] ?? [],
alternates: $options['alternates'] ?? [],
);

return $this->add($url);
}

public function addUrls(iterable $urls): self
{
foreach ($urls as $url) {
$this->add($url);
}
return $this;
}

public function addModel($model, string $routeName, array $options = []): self
{
$url = route($routeName, $model);

$lastmod = $model->updated_at ?? $model->created_at ?? null;

return $this->addUrl($url, array_merge([
'lastmod' => $lastmod,
], $options));
}

public function addModels(iterable $models, string $routeName, array $options = []): self
{
foreach ($models as $model) {
$this->addModel($model, $routeName, $options);
}
return $this;
}

public function getUrls(): Collection
{
return $this->urls;
}

public function count(): int
{
return $this->urls->count();
}

public function render(): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
$xml .= ' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"';
$xml .= ' xmlns:xhtml="http://www.w3.org/1999/xhtml"';
$xml .= '>' . "\n";

foreach ($this->urls as $url) {
$xml .= $url->render();
}

$xml .= '</urlset>';

return $xml;
}

public function save(string $path): bool
{
return file_put_contents($path, $this->render()) !== false;
}
}

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
<?php

namespace App\Services\Sitemap;

use DateTimeInterface;

class Url
{
public function __construct(
public string $loc,
public ?DateTimeInterface $lastmod = null,
public string $changefreq = 'weekly',
public float $priority = 0.5,
public array $images = [],
public array $alternates = []
) {}

public function render(): string
{
$xml = " <url>\n";
$xml .= " <loc>" . $this->escape($this->loc) . "</loc>\n";

if ($this->lastmod) {
$xml .= " <lastmod>" . $this->lastmod->format('c') . "</lastmod>\n";
}

if ($this->changefreq) {
$xml .= " <changefreq>" . $this->changefreq . "</changefreq>\n";
}

if ($this->priority >= 0 && $this->priority <= 1) {
$xml .= " <priority>" . number_format($this->priority, 1) . "</priority>\n";
}

foreach ($this->images as $image) {
$xml .= $this->renderImage($image);
}

foreach ($this->alternates as $alternate) {
$xml .= $this->renderAlternate($alternate);
}

$xml .= " </url>\n";

return $xml;
}

protected function renderImage(array $image): string
{
$xml = " <image:image>\n";
$xml .= " <image:loc>" . $this->escape($image['loc']) . "</image:loc>\n";

if (isset($image['title'])) {
$xml .= " <image:title>" . $this->escape($image['title']) . "</image:title>\n";
}

if (isset($image['caption'])) {
$xml .= " <image:caption>" . $this->escape($image['caption']) . "</image:caption>\n";
}

$xml .= " </image:image>\n";

return $xml;
}

protected function renderAlternate(array $alternate): string
{
return " <xhtml:link rel=\"alternate\" hreflang=\"" . $alternate['hreflang'] .
"\" href=\"" . $this->escape($alternate['href']) . "\" />\n";
}

protected function escape(string $string): string
{
return htmlspecialchars($string, ENT_XML1, 'UTF-8');
}
}

Sitemap 索引

多 Sitemap 管理

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
<?php

namespace App\Services\Sitemap;

class SitemapIndex
{
protected array $sitemaps = [];

public function add(Sitemap $sitemap, string $loc, ?\DateTime $lastmod = null): self
{
$this->sitemaps[] = [
'sitemap' => $sitemap,
'loc' => $loc,
'lastmod' => $lastmod,
];

return $this;
}

public function addPath(string $loc, ?\DateTime $lastmod = null): self
{
$this->sitemaps[] = [
'loc' => $loc,
'lastmod' => $lastmod,
];

return $this;
}

public function render(): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

foreach ($this->sitemaps as $sitemap) {
$xml .= " <sitemap>\n";
$xml .= " <loc>" . htmlspecialchars($sitemap['loc'], ENT_XML1, 'UTF-8') . "</loc>\n";

if (isset($sitemap['lastmod'])) {
$xml .= " <lastmod>" . $sitemap['lastmod']->format('c') . "</lastmod>\n";
}

$xml .= " </sitemap>\n";
}

$xml .= '</sitemapindex>';

return $xml;
}

public function save(string $path): bool
{
return file_put_contents($path, $this->render()) !== false;
}
}

Sitemap 构建器

模型 Sitemap 构建

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
<?php

namespace App\Services\Sitemap;

use App\Models\Article;
use App\Models\Category;
use App\Models\Page;
use App\Models\Product;
use Illuminate\Support\Facades\Storage;

class SitemapBuilder
{
protected Sitemap $sitemap;
protected int $maxUrls = 50000;

public function __construct()
{
$this->sitemap = new Sitemap();
}

public function build(): Sitemap
{
$this->addStaticPages();
$this->addArticles();
$this->addCategories();
$this->addProducts();

return $this->sitemap;
}

protected function addStaticPages(): void
{
$this->sitemap->addUrl(url('/'), [
'changefreq' => 'daily',
'priority' => 1.0,
]);

$this->sitemap->addUrl(route('about'), [
'changefreq' => 'monthly',
'priority' => 0.6,
]);

$this->sitemap->addUrl(route('contact'), [
'changefreq' => 'monthly',
'priority' => 0.6,
]);

Page::where('is_published', true)->chunk(100, function ($pages) {
foreach ($pages as $page) {
$this->sitemap->addModel($page, 'pages.show', [
'changefreq' => 'monthly',
'priority' => 0.6,
]);
}
});
}

protected function addArticles(): void
{
Article::where('status', 'published')
->orderBy('updated_at', 'desc')
->chunk(100, function ($articles) {
foreach ($articles as $article) {
$images = [];

if ($article->featured_image) {
$images[] = [
'loc' => Storage::url($article->featured_image),
'title' => $article->title,
];
}

$this->sitemap->addUrl(
route('articles.show', $article),
[
'lastmod' => $article->updated_at,
'changefreq' => 'weekly',
'priority' => 0.8,
'images' => $images,
]
);
}
});
}

protected function addCategories(): void
{
Category::where('is_active', true)->chunk(100, function ($categories) {
foreach ($categories as $category) {
$this->sitemap->addModel($category, 'categories.show', [
'changefreq' => 'weekly',
'priority' => 0.7,
]);
}
});
}

protected function addProducts(): void
{
Product::where('status', 'active')
->where('stock', '>', 0)
->chunk(100, function ($products) {
foreach ($products as $product) {
$images = [];

foreach ($product->images ?? [] as $image) {
$images[] = [
'loc' => Storage::url($image),
'title' => $product->name,
];
}

$this->sitemap->addUrl(
route('products.show', $product),
[
'lastmod' => $product->updated_at,
'changefreq' => 'daily',
'priority' => 0.9,
'images' => $images,
]
);
}
});
}

public function buildIndex(): SitemapIndex
{
$index = new SitemapIndex();

$index->addPath(url('sitemap-pages.xml'), now());
$index->addPath(url('sitemap-articles.xml'), now());
$index->addPath(url('sitemap-products.xml'), now());

return $index;
}

public function buildPaginated(): array
{
$sitemaps = [];
$page = 1;

Article::where('status', 'published')
->chunk($this->maxUrls, function ($articles) use (&$sitemaps, &$page) {
$sitemap = new Sitemap();

foreach ($articles as $article) {
$sitemap->addModel($article, 'articles.show');
}

$filename = "sitemap-articles-{$page}.xml";
$sitemaps[$filename] = $sitemap;
$page++;
});

return $sitemaps;
}
}

Sitemap 服务

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
<?php

namespace App\Services\Sitemap;

use Illuminate\Support\Facades\Cache;

class SitemapService
{
protected SitemapBuilder $builder;
protected bool $cacheEnabled;
protected int $cacheTtl;

public function __construct(SitemapBuilder $builder)
{
$this->builder = $builder;
$this->cacheEnabled = config('sitemap.cache.enabled', true);
$this->cacheTtl = config('sitemap.cache.ttl', 3600);
}

public function generate(): Sitemap
{
if ($this->cacheEnabled) {
return Cache::remember('sitemap.generated', $this->cacheTtl, function () {
return $this->builder->build();
});
}

return $this->builder->build();
}

public function render(): string
{
return $this->generate()->render();
}

public function save(string $path = null): bool
{
$path = $path ?? config('sitemap.path', public_path('sitemap.xml'));

return $this->generate()->save($path);
}

public function clearCache(): bool
{
return Cache::forget('sitemap.generated');
}

public function pingSearchEngines(): array
{
$sitemapUrl = config('sitemap.url', url('sitemap.xml'));

$engines = [
'google' => 'https://www.google.com/ping?sitemap=',
'bing' => 'https://www.bing.com/ping?sitemap=',
];

$results = [];

foreach ($engines as $engine => $endpoint) {
try {
$response = \Illuminate\Support\Facades\Http::get($endpoint . urlencode($sitemapUrl));
$results[$engine] = $response->successful();
} catch (\Exception $e) {
$results[$engine] = false;
}
}

return $results;
}
}

Sitemap 控制器

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\Http\Controllers;

use App\Services\Sitemap\SitemapService;
use Illuminate\Http\Response;

class SitemapController extends Controller
{
protected SitemapService $sitemapService;

public function __construct(SitemapService $sitemapService)
{
$this->sitemapService = $sitemapService;
}

public function index(): Response
{
$content = $this->sitemapService->render();

return response($content, 200, [
'Content-Type' => 'application/xml',
'Cache-Control' => 'public, max-age=3600',
]);
}

public function articles(int $page = 1): Response
{
$builder = new \App\Services\Sitemap\SitemapBuilder();
$sitemap = new \App\Services\Sitemap\Sitemap();

\App\Models\Article::where('status', 'published')
->offset(($page - 1) * 50000)
->limit(50000)
->chunk(100, function ($articles) use ($sitemap) {
foreach ($articles as $article) {
$sitemap->addModel($article, 'articles.show');
}
});

return response($sitemap->render(), 200, [
'Content-Type' => 'application/xml',
]);
}
}

Sitemap 命令

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
<?php

namespace App\Console\Commands;

use App\Services\Sitemap\SitemapService;
use Illuminate\Console\Command;

class SitemapGenerateCommand extends Command
{
protected $signature = 'sitemap:generate {--ping : Ping search engines after generation}';
protected $description = 'Generate sitemap';

public function handle(SitemapService $sitemapService): int
{
$this->info('Generating sitemap...');

$sitemapService->clearCache();

$sitemap = $sitemapService->generate();

$path = config('sitemap.path', public_path('sitemap.xml'));
$sitemapService->save($path);

$this->info("Sitemap generated with {$sitemap->count()} URLs");
$this->info("Saved to: {$path}");

if ($this->option('ping')) {
$this->info('Pinging search engines...');

$results = $sitemapService->pingSearchEngines();

foreach ($results as $engine => $success) {
$status = $success ? '<info>✓</info>' : '<error>✗</error>';
$this->line("{$status} {$engine}");
}
}

return self::SUCCESS;
}
}

路由配置

1
2
3
4
5
6
7
8
9
<?php

use App\Http\Controllers\SitemapController;

Route::get('sitemap.xml', [SitemapController::class, 'index'])
->name('sitemap');
Route::get('sitemap-articles-{page}.xml', [SitemapController::class, 'articles'])
->where('page', '[0-9]+')
->name('sitemap.articles');

总结

Laravel 13 的网站地图生成系统支持静态页面、动态内容、多语言和图片索引等功能。通过合理的分页和缓存策略,可以高效生成大型网站的 sitemap,并通过 ping 机制通知搜索引擎更新。