Ir para o conteúdo
Blog
Papo Dev

SEO não é meta tag. É arquitetura

A empresa se chama MC — Marketing & Code. O "Marketing" não está no nome por acidente. Quando você entende os dois lados, para de tratar SEO como detalhe de componente e começa a tratar como responsabilidade de sistema.

13 min de leitura 2.449 palavras
SEO não é meta tag. É arquitetura

Existe um momento muito específico no desenvolvimento de SPAs.

Você constrói a página, abre o DevTools, vê tudo renderizando lindo, aí inspeciona o HTML que chegou do servidor:

<head>
  <title>Vite App</title>
</head>
<body>
  <div id="app"></div>
</body>

Esse é o HTML que o Googlebot lê. Esse é o HTML que o WhatsApp usa pra gerar preview quando alguém compartilha o link. Esse é o HTML que decide se o seu artigo ranqueia ou some.

Uma <div id="app"> vazia.

Não importa quantas vezes você chamou useMeta() no componente. Se não tem SSR, não tem SEO de verdade. Os metadados gerados no JavaScript rodam no browser do usuário — não no crawler. Isso é um fato, não opinião.

Esse é o ponto de partida. E é onde a maioria dos projetos anota "SSR — talvez um dia" e segue em frente.

No MKT Code, não teve "talvez". Mas resolver o SSR foi só metade do problema.


#O segundo problema: SEO espalhado

Resolvido o SSR, aparece o próximo erro. E é onde a maioria para de pensar.

O que você renderiza no servidor?

A resposta mais comum: cada página gerencia os próprios metadados. O componente Vue de PostShow.vue monta título, descrição e og:image diretamente. O de ProjectShow.vue faz a mesma coisa do zero. Cada um com sua própria interpretação de como truncar descrição, formatar título, montar canonical.

Três meses depois, você tem a mesma lógica espalhada por dez lugares diferentes. Quando a regra muda — adicionar JSON-LD, ajustar padrão de título — você caça tudo na mão.

Isso não é SEO. É SEO improvisado.

A decisão foi centralizar essa responsabilidade no backend, dentro de um namespace próprio: app/SEO/. Vou mostrar como funciona, camada por camada.


#A fundação: o DTO

O coração de tudo é o SeoData. Não um array, não uma stdClass — um objeto com contratos explícitos:

// app/SEO/DTO/SeoData.php

class SeoData
{
    public function __construct(
        public string  $title,
        public string  $description,
        public string  $image,
        public string  $url,
        public string  $type        = 'website',
        public ?string $canonical   = null,
        public ?string $publishedAt = null,
        public ?string $updatedAt   = null,
        public ?string $author      = null,
        public ?array  $category    = null,
        public ?array  $tags        = null,
        public ?array  $keywords    = null,
        public ?string $imageAlt    = null,
        public ?array  $breadcrumbs = null,
        public string  $robots      = 'index, follow',
        public string  $locale      = 'pt_BR',
        public bool    $noIndex     = false,
    ) {}

    public function withoutIndexing(): static
    {
        $clone = clone $this;
        $clone->noIndex = true;
        $clone->robots  = 'noindex, nofollow';

        return $clone;
    }

    public function toArray(): array
    {
        $data = array_filter([
            'title'       => $this->title,
            'description' => $this->description,
            'image'       => $this->image,
            'url'         => $this->url,
            'canonical'   => $this->canonical ?? $this->url,
            'type'        => $this->type,
            'publishedAt' => $this->publishedAt,
            'updatedAt'   => $this->updatedAt,
            'author'      => $this->author,
            'robots'      => $this->robots,
            'locale'      => $this->locale,
            // ...demais campos
        ], fn($value) => ! is_null($value));

        // noIndex só entra no payload quando verdadeiro —
        // evita ruído desnecessário no frontend em 99% das páginas
        if ($this->noIndex) {
            $data['noIndex'] = true;
        }

        return $data;
    }
}

Dois detalhes que importam aqui.

O withoutIndexing() retorna um clone — imutabilidade intencional. SeoData não muda depois de criado. Se precisar de uma versão sem indexação, você gera uma nova instância a partir da original. Sem efeitos colaterais silenciosos quando o objeto trafega por camadas diferentes.

O array_filter no toArray() garante que campos nulos não poluam o payload JSON que o Inertia manda para o Vue. Economiza banda e mantém o $page.props limpo no frontend.


#Os contratos

Três interfaces definem os limites de responsabilidade:

// Qualquer classe que sabe construir um SeoData para uma model específica
interface BuildsSeo
{
    public function build(): SeoData;
}

// Qualquer Model que expõe SEO para o sistema
interface HasSeo
{
    public function getSeo(): SeoData;
}

// Qualquer classe que sabe gerar um segmento do sitemap
interface SitemapProvider
{
    public function generate(Sitemap $sitemap): void;
    public function filename(): string;
}

O HasSeo é o que amarra tudo ao Eloquent. Um Post que implementa HasSeo declara para o sistema que sabe gerar seus próprios metadados. Sem instanceof espalhados, sem arrays mágicos passando de mão em mão.


#O SeoResolver: convenção como lei

O SeoResolver é a peça central do ecossistema. Ele resolve qual Builder usar via convenção de namespace — e é estrito: se o Builder não existir, lança exceção.

// app/SEO/SeoResolver.php

class SeoResolver
{
    public function resolve(HasSeo $model): SeoData
    {
        $builderClass = str_replace(
            'App\\Models\\',
            'App\\SEO\\Builders\\',
            get_class($model)
        ) . 'SeoBuilder';

        throw_if(
            ! class_exists($builderClass),
            'SeoBuilder not found for model: ' . get_class($model)
        );

        return app($builderClass)->build($model);
    }
}

App\Models\Post vira App\SEO\Builders\PostSeoBuilder. App\Models\Project vira App\SEO\Builders\ProjectSeoBuilder.

A diferença em relação a um mapeamento explícito — um match, um array de configuração, um switch — é que aqui a convenção é a regra. O sistema não precisa ser atualizado quando um novo Builder é criado. Se o arquivo existe no lugar certo, funciona. Se não existe, falha ruidosamente com uma exceção clara.

Isso é OCP — aberto para extensão via convenção, fechado para modificação dos mecanismos centrais.


#Os Builders: onde o SEO de cada conteúdo mora

Cada tipo de conteúdo tem um Builder dedicado. O PostSeoBuilder sabe tudo sobre como gerar SEO de um post. O ProjectSeoBuilder sabe tudo sobre projetos. A lógica fica contida — sem vazar para Model, Controller ou componente Vue.

Novo tipo de conteúdo amanhã? Novo arquivo em app/SEO/Builders/. Nenhum outro arquivo muda.


#O Model: delegando para o Resolver

Com o SeoResolver como ponto central, o Model não precisa conhecer o Builder diretamente. Ele implementa HasSeo e delega:

// app/Models/Post.php

class Post extends Model implements HasMedia, HasSeo, Sitemapable
{
    // ...

    public function getSeo(): SeoData
    {
        return app(SeoResolver::class)->resolve($this);
    }
}

O Model não sabe que existe um PostSeoBuilder. Ele só sabe que implementa HasSeo e que o SeoResolver vai encontrar o responsável certo. Se um dia o Builder não existir, a exceção do throw_if vai avisar imediatamente — em desenvolvimento, não em produção com meta tag vazia.


#Três formas de entregar SEO

Dependendo do contexto, o sistema entrega SEO de três maneiras diferentes. Todas convergem para o mesmo SeoData no final.

Forma 1 — Model pages via PublicResource

Para páginas de conteúdo com um model associado, o PublicResource base cuida automaticamente:

// app/Http/Resources/Public/PublicResource.php

abstract class PublicResource extends JsonResource
{
    public function with(Request $request): array
    {
        return [
            'seo' => $this->seo(),
        ];
    }

    protected function seo(): ?array
    {
        if ($this->resource instanceof HasSeo) {
            return app(SeoResolver::class)->resolve($this->resource)->toArray();
        }

        return null;
    }
}

O with() do Resource entrega o SEO como chave separada no payload do Inertia — fora do data, no mesmo nível. O Controller não precisa saber que SEO existe.

Para páginas de detalhe onde o SEO precisa estar junto dos dados do modelo, o Resource específico pode incluí-lo diretamente no toArray():

// app/Http/Resources/Public/PublicPostShowResource.php

class PublicPostShowResource extends PublicResource
{
    public function toArray(Request $request): array
    {
        return [
            'title'        => $this->title,
            'slug'         => $this->slug,
            'body'         => $this->html,
            'excerpt'      => $this->excerpt ?? $this->plain_text,
            'word_count'   => $this->word_count,
            'reading_time' => $this->reading_time,
            'published_at' => optional($this->published_at)->toIso8601String(),
            'author'       => $this->whenLoaded('author', fn (): array => [
                'name'         => $this->author->name,
                'username'     => $this->author->username,
                'avatar'       => $this->author?->profile_photo_url,
                'location'     => $this->author->location,
                'social_links' => $this->author->social_links,
            ]),
            'category'     => $this->whenLoaded('category', fn (): array => [
                'name' => $this->category->name,
                'slug' => $this->category->slug,
            ]),
            'tags'         => $this->whenLoaded('tags', fn (): array => $this->tags->map(
                fn (Tag $tag): array => ['name' => $tag->name, 'slug' => $tag->slug]
            )->all()),
            'cover'        => $this->cover(),
            'seo'          => $this->seo(),   // SEO embutido nos dados do post
        ];
    }
}

Forma 2 — Coleções via SeoService

Para páginas de listagem sem um model específico, o SeoService é injetado diretamente na Collection:

// app/Http/Resources/Public/PublicProjectCollection.php

class PublicProjectCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => PublicProjectResource::collection($this->collection),
        ];
    }

    public function with(Request $request): array
    {
        return [
            'seo' => app(SeoService::class)->forPage(
                route: 'public.projects',
                title: 'Projetos',
            ),
        ];
    }
}

O Controller instancia a Collection e retorna. Nenhuma linha de SEO no Controller.

Forma 3 — Controller direto via SeoService

Para casos onde o Controller precisa de controle mais fino — breadcrumbs customizados, robots específicos, título dinâmico — o SeoService é injetado diretamente:

// app/Http/Controllers/Public/PostController.php

class PostController extends Controller
{
    public function index(Request $request, SeoService $seo): Response
    {
        $posts = Post::published()
            ->with(['author', 'category', 'media', 'tags'])
            ->latest('published_at')
            ->paginate(12);

        return Inertia::render('public/blog/Index', [
            'posts' => new PublicPostCollection($posts),
            'seo'   => $seo->forPage(
                route: 'public.blog.index',
                title: 'Blog',
            ),
        ]);
    }

    public function show(Post $post, SeoService $seo): Response
    {
        $post->load(['author.media', 'category', 'media', 'tags']);

        return Inertia::render('public/blog/Show', [
            'post' => PublicPostShowResource::make($post)->resolve(),
            'seo'  => $seo->forPost($post),
        ]);
    }
}

O SeoService centraliza os Builders com métodos expressivos: forPage(), forPost(), forProject(), forUser(). O Controller declara o que precisa — não como gera.


#A fonte da verdade global: GeneralSettings

Builders precisam de fallbacks. Qual imagem usar quando um post não tem cover? Qual descrição usar quando a página não define a sua?

A resposta não está em config/. Está no banco de dados, via spatie/laravel-settings:

// app/Settings/GeneralSettings.php

class GeneralSettings extends Settings
{
    public string  $site_name;
    public string  $site_description;
    public ?string $site_keywords;
    public ?string $og_image;
    public ?array  $social_links;
    public string  $site_author;
    public string  $site_locale;

    public function parsedKeywords(): array
    {
        if (blank($this->site_keywords)) {
            return [];
        }

        return array_values(array_filter(
            array_map('trim', explode(',', $this->site_keywords))
        ));
    }

    public function ogImageUrl(): string
    {
        return ! blank($this->og_image)
            ? asset('storage/' . $this->og_image)
            : asset('images/logo.png');
    }
}

O administrador pode alterar nome do site, descrição global, imagem OG e social links diretamente no painel Filament — sem tocar código. Toda página sem metadados explícitos herda esses valores automaticamente.

Esses dados são compartilhados globalmente pelo Inertia via HandleInertiaRequests:

'site' => [
    'name'         => $settings->site_name,
    'url'          => route('home'),
    'description'  => $settings->site_description,
    'og_image'     => $settings->ogImageUrl(),
    'keywords'     => $settings->parsedKeywords(),
    'author'       => $settings->site_author,
    'locale'       => $settings->site_locale,
    'social_links' => $settings->activeSocialLinks(),
],

O frontend recebe $page.props.site em toda requisição. O composable useSeo.ts monta a cascata: props da página$page.props.site → valores padrão. Nunca uma meta tag vazia.


#O sitemap: não um arquivo, um sistema

A maioria dos projetos gera um sitemap.xml monolítico. Aqui funciona diferente.

O SitemapGenerator orquestra providers independentes, cada um responsável por um segmento:

// app/SEO/SitemapServiceProvider.php

$this->app->singleton(SitemapGenerator::class, function () {
    return new SitemapGenerator([
        new PageSitemapProvider,
        new PostSitemapProvider,
        new ProjectSitemapProvider,
        new UserSitemapProvider,
    ]);
});

Cada provider implementa SitemapProvider, gera seu arquivo segmentado. O SitemapGenerator produz um índice sitemap.xml que aponta para cada segmento.

O Post model vai além: implementa Sitemapable do Spatie e declara sua própria entrada no sitemap — incluindo a imagem de cover quando existe:

// app/Models/Post.php

public function toSitemapTag(): Url|string|array
{
    $url = Url::create(route('public.blog.show', $this->slug))
        ->setLastModificationDate($this->updated_at)
        ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
        ->setPriority(0.8);

    $cover = $this->getFirstMedia('cover');

    if ($cover) {
        $url->addImage($cover->getUrl(), $this->seo_title ?? $this->title);
    }

    return $url;
}

O ganho prático: no Google Search Console, você monitora a indexação de "Blog" separada de "Projetos" separada de "Usuários". Quando um segmento específico apresenta problema, você sabe exatamente onde olhar.


#O fluxo completo

Da requisição ao Googlebot, o caminho de um post:

  1. Request chega em /blog/meu-artigo

  2. PostController::show() carrega o post com relacionamentos, retorna PublicPostShowResource::make($post)->resolve()

  3. O toArray() do Resource chama $this->seo(), herdado de PublicResource

  4. O seo() verifica que Post implementa HasSeo e chama SeoResolver::resolve($post)

  5. O SeoResolver mapeia App\Models\PostApp\SEO\Builders\PostSeoBuilder via convenção de namespace. Se o Builder não existe, throw_if lança exceção imediatamente

  6. O PostSeoBuilder constrói o SeoData com título, descrição, og:image, publishedAt, tags como keywords, canonical absoluto

  7. O HandleInertiaRequests injeta site com dados globais do GeneralSettings em paralelo, em toda requisição

  8. O Inertia SSR renderiza o Vue no servidor. O SeoHead.vuepost.seo e $page.props.site, monta a cascata e preenche o <head> completo

  9. O HTML chega no browser com <title>, <meta>, Open Graph, article:published_time, JSON-LD, canonical — tudo resolvido no servidor

  10. O Googlebot indexa. O WhatsApp gera preview. O usuário compartilha com contexto.

Sem <div id="app"> vazia. Sem SEO prometido e não entregue.


#Resumindo

SEO sem SSR é ilusão. Se o crawler não recebe HTML completo, não adianta nenhuma meta tag.

SEO espalhado em componente é dívida. Quando cada página cuida dos próprios metadados, você tem versões do mesmo problema repetido sem controle central.

Convenção de namespace é extensibilidade real. Novo tipo de conteúdo — novo Builder no lugar certo. Nenhum arquivo existente muda. Nenhum mapeamento para registrar.

throw_if em vez de fallback silencioso. Builder ausente deve falhar ruidosamente em desenvolvimento — não gerar meta tag vazia em produção sem ninguém perceber.

Três formas de entregar, um único formato de saída. Resource base, Collection, Controller direto — todos convergem para o mesmo SeoData. O frontend não sabe de onde veio.

Settings no banco, não em config/. O administrador controla os fallbacks globais sem abrir código.

Quando o nome da empresa é Marketing & Code, as duas disciplinas precisam funcionar juntas. Não basta o site existir. Ele precisa ser encontrado.


Esse post faz parte da série de insights do blog da MC — pensamentos diretos sobre desenvolvimento, arquitetura e o que aprendemos construindo sistemas reais.

Está construindo algo que precisa ranquear de verdade? Fala comigo.

#CMS Moderno #PHP #SEO Técnico #Desenvolvimento
Compartilhar:
Flavio Moreira

Escrito por

Flavio Moreira

Desenvolvedor e fundador da mktcode. Apaixonado por transformar ideias em produtos digitais de alta performance.