Техническое SEO в Laravel-проектах
За последние несколько лет я провёл техническую оптимизацию десятка Laravel-проектов — от лендингов до крупных каталогов с сотнями страниц. Расскажу, как выстраиваю техническое SEO на актуальном стеке, без сторонних SEO-пакетов, с полным контролем над кодом.
Почему техническое SEO — это архитектура, а не разовая настройка
Многие думают, что достаточно один раз сгенерировать sitemap.xml и написать robots.txt. На практике всё сложнее: у интернет-магазина ежедневно появляются новые товары, снимаются с продажи старые, меняются категории. Если sitemap не обновляется динамически — поисковик индексирует мёртвые страницы, а новые попадают в индекс с задержкой в недели.
Управление индексацией через трейты и middleware
Создаю трейт для моделей с контролем индексации app/Traits/HasSeoMeta.php:
namespace App\Traits;
trait HasSeoMeta
{
public function getRobotsDirectiveAttribute(): string
{
if (!$this->is_active || $this->is_out_of_stock) {
return 'noindex, follow';
}
return $this->meta_robots ?? 'index, follow';
}
public function getCanonicalUrlAttribute(): string
{
return $this->canonical_url ?? route('product.show', $this->slug);
}
}
Вывожу это единообразно через Blade-компонент resources/views/components/seo-meta.blade.php:
<meta name="robots" content="{{ $robots ?? 'index, follow' }}">
<link rel="canonical" href="{{ $canonical }}">
@if($paginated ?? false)
@if(!$isFirstPage)
<link rel="prev" href="{{ $prevUrl }}">
@endif
@if(!$isLastPage)
<link rel="next" href="{{ $nextUrl }}">
@endif
@endif
Страницы с фильтрами и сортировкой генерируют тысячи дублей в каталогах. Закрываю их через middleware — app/Http/Middleware/NoIndexFilteredPages.php:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class NoIndexFilteredPages
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$allowedParams = ['page'];
$hasExtraParams = collect($request->query())
->except($allowedParams)
->isNotEmpty();
if ($hasExtraParams) {
$response->headers->set('X-Robots-Tag', 'noindex, follow');
}
return $response;
}
}
Регистрация в bootstrap/app.php:
use App\Http\Middleware\NoIndexFilteredPages;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('web', NoIndexFilteredPages::class);
})
->create();
Заголовок X-Robots-Tag надёжнее meta-тега — он отрабатывает даже для не-HTML ресурсов и корректно считывается ботами.
Динамический sitemap.xml без нагрузки на сервер
Главная ошибка в чужих проектах — генерация sitemap «на лету» при каждом запросе бота без кеша. На каталоге в 50 000 товаров это укладывает сервер за минуты.
Мой подход — генерация через artisan-команду с кешированием и разбивкой на несколько файлов (sitemap index). В Laravel 13 команды создаются через новый синтаксис с атрибутами вместо $signature, app/Console/Commands/GenerateSitemap.php:
namespace App\Console\Commands;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
protected $description = 'Генерирует sitemap.xml для всех сущностей проекта';
public function handle(): void
{
$this->generateProductsSitemap();
$this->generateSitemapIndex();
$this->info('Sitemap успешно сгенерирован');
}
protected function generateProductsSitemap(): void
{
$chunkIndex = 0;
Product::query()
->where('is_active', true)
->orderBy('id')
->chunk(1000, function ($products) use (&$chunkIndex) {
$xml = view('sitemap.urlset', [
'urls' => $products->map(fn($p) => [
'loc' => route('product.show', $p->slug),
'lastmod' => $p->updated_at->toAtomString(),
'priority' => '0.8',
]),
])->render();
Storage::disk('public')
->put("sitemaps/products-{$chunkIndex}.xml", $xml);
$chunkIndex++;
});
}
protected function generateSitemapIndex(): void
{
$files = Storage::disk('public')->files('sitemaps');
$xml = view('sitemap.index', [
'sitemaps' => collect($files)->map(fn($f) => [
'loc' => asset("storage/{$f}"),
'lastmod' => now()->toAtomString(),
]),
])->render();
Storage::disk('public')->put('sitemap.xml', $xml);
}
}
Шаблон resources/views/sitemap/urlset.blade.php:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach($urls as $url)
<url>
<loc>{{ $url['loc'] }}</loc>
<lastmod>{{ $url['lastmod'] }}</lastmod>
<priority>{{ $url['priority'] }}</priority>
</url>
@endforeach
</urlset>
Планировщик задач routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('sitemap:generate')
->everyFourHours()
->withoutOverlapping();
Это упрощает конфигурацию — не нужно лезть в отдельный Kernel-класс, вся логика планировщика теперь в одном месте.
robots.txt: динамика вместо статичного файла
Статичный robots.txt хорош только для маленьких проектов. Для мультидоменных или staging-окружений делаю его генерируемым через маршрут с invokable-контроллером.
routes/web.php:
use App\Http\Controllers\RobotsController;
Route::get('/robots.txt', RobotsController::class);
app/Http/Controllers/RobotsController.php:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class RobotsController extends Controller
{
public function __invoke(Request $request): Response
{
$content = view('robots', [
'isProduction' => app()->environment('production'),
'sitemapUrl' => url('sitemap.xml'),
])->render();
return response($content, 200)
->header('Content-Type', 'text/plain');
}
}
resources/views/robots.blade.php:
@if($isProduction)
User-agent: *
Disallow: /admin
Disallow: /cart
Disallow: /checkout
Disallow: /*?*sort=
Disallow: /*?*filter=
Allow: /
Sitemap: {{ $sitemapUrl }}
@else
User-agent: *
Disallow: /
@endif
На staging-окружениях всегда закрываю индексацию полностью — иначе дубли тестового сайта начинают конкурировать с продакшеном в выдаче. Такое встречал не раз в чужих проектах.
Микроразметка через Schema.org
Для микроразметки не использую сторонние пакеты — они негибкие и не покрывают все нужные типы. Собираю JSON-LD через сервис-класс app/Services/SchemaBuilder.php:
namespace App\Services;
class SchemaBuilder
{
public static function product($product): array
{
return array_filter([
'@context' => 'https://schema.org/',
'@type' => 'Product',
'name' => $product->title,
'image' => $product->image_url,
'description' => $product->short_description,
'sku' => $product->sku,
'offers' => [
'@type' => 'Offer',
'url' => route('product.show', $product->slug),
'priceCurrency' => 'RUB',
'price' => $product->price,
'availability' => $product->is_out_of_stock
? 'https://schema.org/OutOfStock'
: 'https://schema.org/InStock',
],
'aggregateRating' => $product->reviews_count > 0 ? [
'@type' => 'AggregateRating',
'ratingValue' => $product->rating_avg,
'reviewCount' => $product->reviews_count,
] : null,
]);
}
public static function breadcrumbs(array $items): array
{
return [
'@context' => 'https://schema.org/',
'@type' => 'BreadcrumbList',
'itemListElement' => collect($items)->map(fn($item, $i) => [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
'item' => $item['url'],
])->values()->all(),
];
}
}
Вывод в шаблоне:
<script type="application/ld+json">
{!! json_encode(
App\Services\SchemaBuilder::product($product),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
) !!}
</script>
Что я проверяю после каждого деплоя
Набор обязательных чек-пунктов, которые экономят нервы:
- Валидность sitemap.xml в Яндекс Вебмастере или Google Search Console;
- Тест robots.txt на блокировку важных страниц;
- Rich Results Test для проверки JSON-LD на реальных страницах товаров;
- Логи crawl-бюджета — сколько страниц реально обходит бот за сутки, особенно после релиза с изменениями в структуре URL
Техническое SEO в Laravel — это не разовая настройка, а часть архитектуры проекта. Когда индексация, sitemap и микроразметка встроены в бизнес-логику моделей, а не прикручены сбоку, они не ломаются при рефакторинге и растут вместе с проектом.