timurturdyev/simple-seo

Framework-agnostic SEO head markup builder: meta, Open Graph, Twitter/X Cards and JSON-LD, with a lazy Laravel integration layer.

Maintainers

Package info

github.com/TimurTurdyev/simple-seo

pkg:composer/timurturdyev/simple-seo

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v2.1.0 2026-08-05 13:56 UTC

This package is auto-updated.

Last update: 2026-08-05 13:59:27 UTC


README

simple-seo

simple-seo

Meta-теги, Open Graph, карточки Twitter/X и JSON-LD для PHP 8.2+. Ядро без зависимостей, слой для Laravel 11/12 в том же пакете.

Я написал этот пакет на замену artesaos/seotools в своих проектах. Из старого пакета взята только идея, реализация и API новые. Две вещи, которые меня в нём раздражали, здесь устроены иначе:

Дефолты из конфига не липнут к странице. Они подставляются при рендере, если страница ничего не задала. Не нужен хак setDescription(false), есть честный withoutDefaults().

JSON-LD собирается коллекцией. Каждый add() это отдельная сущность, две и больше склеиваются в @graph. Нет "текущего блока", который можно случайно перетереть вызовом из другого места шаблона.

Остальное по мелочи: скалярные сеттеры заменяют значение, списочные накапливают, robots задаётся методами noindex() / maxSnippet(120) вместо строки, весь вывод экранирован, JSON-LD кодируется с защитой от закрывающего script-тега. PHPStan на максимальном уровне, поведение закрыто тестами.

Установка

composer require timurturdyev/simple-seo

В Laravel провайдер и фасад подхватятся сами. Конфиг с дефолтами публикуется по желанию:

php artisan vendor:publish --tag=simple-seo-config

Laravel

seo()->title('Кресло руководителя EX-500')
    ->description('Эргономичное кресло с кожаным сиденьем.')
    ->image('https://example.com/chair.jpg')
    ->canonical('https://example.com/catalog/chairs/ex-500');

В layout, в head:

@seo

Верхний уровень раскидывает значения сам: title() заполняет meta, og и twitter разом, canonical() дублируется в og:url, image() уходит в og и twitter. Когда нужен контроль, спускайся в секцию:

seo()->meta()->noindex()->maxSnippet(120)->alternate('ru', 'https://example.com/ru');
seo()->openGraph()->type('article')->property('article:published_time', $date);
seo()->twitterCard()->card(TwitterCardType::SummaryLargeImage)->creator('@author');
seo()->jsonLd()->add(['@type' => 'WebSite', 'name' => 'Example']);

Секция twitter молчит, пока страница не задаст явное значение: X умеет читать og-теги, дублировать их незачем.

Модель может описать свою разметку сама через контракт HasSeo:

final class Product extends Model implements HasSeo
{
    public function toSeo(): SeoData
    {
        return new SeoData(
            title: $this->name,
            description: $this->summary,
            image: $this->cover_url,
            canonical: route('products.show', $this),
            ogType: 'product',
            jsonLd: [Schema::product()->name($this->name)->sku($this->sku)],
        );
    }
}

Тогда в контроллере остаётся одна строка:

seo()->apply($product);

При app.debug директива дописывает html-комментарий: какое поле пришло со страницы, какое из конфига. Сами значения не светятся.

Вне Laravel

Хелпера seo() и директивы тут нет, остальной API тот же. Собери менеджер при бутстрапе:

use TimurTurdyev\SimpleSeo\JsonLd\JsonLdBuilder;
use TimurTurdyev\SimpleSeo\Meta\MetaBuilder;
use TimurTurdyev\SimpleSeo\OpenGraph\OpenGraphBuilder;
use TimurTurdyev\SimpleSeo\SeoManager;
use TimurTurdyev\SimpleSeo\TwitterCard\TwitterCardBuilder;

$seo = new SeoManager(
    new MetaBuilder(defaultTitle: 'Мой сайт', titleSuffix: ' - Мой сайт'),
    new OpenGraphBuilder(defaultSiteName: 'Мой сайт', defaultType: 'website', defaultLocale: 'ru_RU'),
    new TwitterCardBuilder(),
    new JsonLdBuilder(),
);

В шаблоне:

<head>
    <?= $seo->render() ?>
</head>

Вывод экранирован, оборачивать не нужно.

Под PHP-FPM больше ничего не требуется. Под RoadRunner, Swoole и в прочих воркерах вызывай $seo->reset() между запросами или создавай свежий инстанс. В Symfony и Yii регистрируй фабрику в контейнере и бери дефолты из своего конфига. Классы из src/Laravel/ в таких проектах не загружаются, функция seo() объявлена через function_exists и никому не мешает. В Laravel менеджер привязан как scoped, Octane сбрасывает его сам.

Схемы JSON-LD

Готовые билдеры: Product, Offer, AggregateOffer, AggregateRating, Article, BlogPosting, Person, QAPage, Question, Answer, BreadcrumbList, Organization, WebSite, LocalBusiness.

use TimurTurdyev\SimpleSeo\JsonLd\Schema\Schema;

seo()->jsonLd()->add(
    Schema::product()
        ->name('EX-500')
        ->offers(Schema::offer()->price('49900.00')->priceCurrency('RUB')->inStock())
);

seo()->jsonLd()->add(
    Schema::breadcrumbs()
        ->item('Главная', 'https://example.com')
        ->item('Каталог', 'https://example.com/catalog')
        ->item('Кресла')
);

Позиции хлебных крошек нумеруются сами. Под типы, которые Google перестал показывать (FAQ и подобные), билдеров нет: экзотику передавай массивом или через ->property().

Для страниц вопрос-ответ есть связка QAPage:

seo()->jsonLd()->add(
    Schema::qaPage()->question(
        Schema::question()
            ->name('Как выбрать кресло руководителя?')
            ->author('Иван')
            ->acceptedAnswer(
                Schema::answer()->text('Смотрите на механизм качания.')->upvoteCount(12)
            )
            ->suggestedAnswer(Schema::answer()->text('Берите с поясничным упором.'))
    )
);

answerCount подставится сам из числа размеченных ответов, явный ->answerCount() сильнее. Строка в author() превращается в Person, готовые Person и Organization проходят как есть. По правилам Google такая разметка валидна на странице, где главное содержимое - один вопрос с ответами.

Сущность страницы можно собрать из уже заданных значений, не дублируя их:

seo()->title($title)->description($description)->canonical(request()->url());

seo()->jsonLd()->fromPage('Product', [
    'offers' => $product->type === 'product'
        ? Schema::offer()->price($minPrice)->priceCurrency('RUB')->inStock()
        : null,
]);

fromPage() при рендере подставит name из title (без суффикса), description, image из og-картинок и url из canonical. Второй аргумент дополняет и перекрывает, null-поля выпадают из JSON, поэтому условные куски пишутся тернарником. Работает с любым типом от Thing: Article, Recipe, Event и так далее.

Два уточнения. Сущности сайта (Organization, WebSite, LocalBusiness) через fromPage() собирать не надо - он возьмёт заголовок страницы вместо имени организации; их добавляй обычным add() в layout. Типы с нестандартными обязательными полями (JobPosting хочет title, VideoObject хочет thumbnailUrl) добирай через второй аргумент.

Миграция с artesaos/seotools

Таблица соответствий вызовов, маппинг конфига и отличия поведения: docs/migration-from-artesaos.md.

Разработка

composer test
composer analyse
composer lint

Лицензия MIT.

English

Meta, Open Graph, Twitter/X cards and JSON-LD for PHP. Plain PHP 8.2+ core with zero runtime deps, lazy Laravel 11/12 layer in the same package. Config values resolve as render-time fallbacks, every jsonLd()->add() is an independent entity, robots directives are typed methods, output is escaped. jsonLd()->fromPage('Product', $overrides) builds a page entity from the already set title, description, image and canonical at render time. Typed QAPage, Question and Answer builders cover Q&A pages, answerCount is derived from the marked up answers unless set explicitly. Install via composer, set page values with seo(), print with @seo or $seo->render(). Migration notes live in docs/migration-from-artesaos.md.