Техническое SEO в Laravel-проектах

Техническое 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>

Что я проверяю после каждого деплоя

Набор обязательных чек-пунктов, которые экономят нервы:

  1. Валидность sitemap.xml в Яндекс Вебмастере или Google Search Console;
  2. Тест robots.txt на блокировку важных страниц;
  3. Rich Results Test для проверки JSON-LD на реальных страницах товаров;
  4. Логи crawl-бюджета — сколько страниц реально обходит бот за сутки, особенно после релиза с изменениями в структуре URL

Техническое SEO в Laravel — это не разовая настройка, а часть архитектуры проекта. Когда индексация, sitemap и микроразметка встроены в бизнес-логику моделей, а не прикручены сбоку, они не ломаются при рефакторинге и растут вместе с проектом.