Skip to content
Abdulkader Safi edited this page Aug 18, 2026 · 3 revisions

SEO

Server-rendered Blade is the whole reason Atelier is shaped the way it is, so the SEO layer is not an add-on. This page covers what ships, what you configure, and how to put URLs in the sitemap that Atelier knows nothing about.

What every page emits

Rendered into the head, per locale, with no configuration:

  • <title>, falling back to the page title when no meta title is set
  • <meta name="description">
  • <link rel="canonical">, the page's own URL unless you override it
  • <link rel="alternate" hreflang> between every locale
  • Open Graph: og:type, og:title, og:description, og:url, og:image
  • Twitter: summary_large_image when there is a share image, summary when there is not

Previews carry noindex, nofollow as both a meta tag and an X-Robots-Tag header, and are never cached.

⚠️ If you use your own layout, include the partials. All of the above lives in atelier::partials.meta. Point atelier.layout at your own Blade view without including it and every page renders perfectly with no head at all. See Using your own layout.

Per-page fields

Page settings, a tab per locale:

Field Notes
Meta title Around 60 characters. Falls back to the page title.
Meta description Around 155 characters.
Social share image 1200 by 630 is the safe size.
Hide from search engines Adds noindex and drops the URL from the sitemap.
Tell search engines not to follow its links Adds nofollow. Independent of the above.
Canonical URL Empty means the page's own URL.

The robots tag is only emitted when it has something to say. index, follow is what every crawler assumes, so a tag repeating it is noise.

Indexing is decided per locale, not per page. An English page can be listed while its Arabic translation is hidden, and the hreflang alternate pointing at the hidden one drops with it, because an alternate pointing at a noindexed URL is the same mistake as listing it.

Sitemap

/sitemap.xml, generated per request from the pages table. Every published, indexable page in every locale, with xhtml:link alternates and lastmod from the publish time. Drafts, unpublished pages and previews never appear.

Open it in a browser and a stylesheet renders it as a table of URLs, dates and locales. Crawlers ignore the stylesheet and read the XML underneath.

There is no cache to clear and nothing to regenerate on publish.

Adding URLs Atelier does not own

A real client site is rarely only Atelier pages. There is usually a blog, a services catalogue or a careers section with its own model, its own panel resource and its own routes. Those URLs belong in the same sitemap, and Atelier cannot discover them, so you hand them over.

A worked example: a blog

Say the site has a Post model with its own Filament resource, on its own nav tab, with routes that have nothing to do with Atelier:

// routes/web.php
Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');
// app/Models/Post.php
public function scopePublished(Builder $query): void
{
    $query->whereNotNull('published_at')->where('published_at', '<=', now());
}

Register a source when you register the plugin:

// app/Providers/Filament/AdminPanelProvider.php
use App\Models\Post;
use Safi\Atelier\AtelierPlugin;
use Safi\Atelier\Blocks\DefaultBlocks;

->plugins([
    AtelierPlugin::make()
        ->blocks(DefaultBlocks::all())
        ->sitemap([
            fn () => Post::published()->get()->map(fn (Post $post) => [
                'loc' => route('blog.show', $post),
                'lastmod' => $post->updated_at,
            ]),
        ]),
])

That is the whole feature. Every published post now appears in /sitemap.xml alongside the Atelier pages, with its own last-modified date.

The shapes a source can return

The short form is a plain URL string, which is enough for a handful of fixed routes:

->sitemap([
    fn () => [
        route('pricing'),
        route('contact'),
    ],
])

The long form adds lastmod and per-locale alternates:

->sitemap([
    fn () => Post::published()->get()->map(fn (Post $post) => [
        'loc' => route('blog.show', $post),
        'lastmod' => $post->updated_at,
        'alternates' => [
            'en' => route('blog.show', $post),
            'ar' => route('blog.show', ['post' => $post, 'locale' => 'ar']),
        ],
    ]),
])

lastmod takes a string or any DateTimeInterface, and is normalised to the ATOM format sitemaps expect. Anything falsy is left out rather than emitted empty.

Once it needs a real query

A closure in a panel provider stops being the right home quickly. Pass the name of an invokable class instead, resolved from the container, so it can take dependencies and be tested on its own:

->sitemap([
    App\Sitemap\PostUrls::class,
    App\Sitemap\ServiceUrls::class,
])
namespace App\Sitemap;

use App\Models\Post;

class PostUrls
{
    public function __invoke(): iterable
    {
        return Post::published()
            ->latest('published_at')
            ->cursor()
            ->map(fn (Post $post) => [
                'loc' => route('blog.show', $post),
                'lastmod' => $post->updated_at,
            ]);
    }
}

Four things worth knowing

  1. Sources run when the sitemap is requested, never at boot. Registering one costs nothing until somebody asks for /sitemap.xml, so a source is free to query.
  2. Atelier does not filter your entries. It will not check whether a post is published, indexable or reachable, because only your model knows that. Filter in the source. The published() scope above is doing the real work.
  3. URLs deduplicate on loc, first one wins. Listing something Atelier already knows about is harmless.
  4. A source that throws takes the sitemap down with it. That is deliberate. A sitemap quietly missing half a site looks fine and stops your blog being indexed; one that fails is a bug somebody notices.

Where these show up in the panel

Nowhere, today. Sources are code, and their URLs appear only in the XML. Open /sitemap.xml in a browser to check a source is returning what you expect.

Structured data

Every page emits a JSON-LD graph: the organisation, the site, the page, breadcrumbs, and whatever the page is about. There is a page type select, an FAQ editor that does not depend on blocks, and a way for your own routes to share the same graph.

It has its own page: Structured data.

robots.txt

Served at /robots.txt, pointing at the sitemap and disallowing the preview route and the panel:

User-agent: *
Disallow: /atelier/preview/
Disallow: /admin/

Sitemap: https://example.com/sitemap.xml

Set the panel path with atelier.robots.disallow_panel, or null to leave it crawlable.

⚠️ Laravel ships a real public/robots.txt, and a file on disk is served by the web server before any route runs. So this route does nothing until you delete that file, or copy the Sitemap: line into it. A package cannot tell which one you meant.

Renaming a slug

Changing a published page's slug writes a 301 from the old URL, and the public route consults those before it 404s. Nothing to configure.

The redirect stores the page rather than a target slug, so a page renamed twice sends both old URLs to wherever it lives now, with no chain to follow. A new page claiming a freed slug drops the redirect from it, because whoever claims a slug owns it. An unpublished target 404s instead: sending someone to a 404 is worse than the 404 itself.

Bilingual

Every page exists at /{slug} and /{locale}/{slug}, with hreflang pointing between them and dir="rtl" plus the Arabic font stack on the RTL side. One tree, one section order, translated text.

hreflang="x-default" is not emitted yet.

Not built yet

  • Review and AggregateRating. The testimonials block could produce them, and deliberately does not: Google ignores reviews a business publishes about itself, and the block has no rating field to aggregate.
  • openingHoursSpecification on a LocalBusiness. Needs a day-and-time repeater.
  • Nodes from your own models. A blog resource cannot yet contribute Article nodes for its own routes, the way it can already contribute sitemap URLs.
  • x-default on the hreflang set.
  • A sitemap index. One sitemap, and the format's limit is 50,000 URLs.
  • changefreq and priority. Deliberate: Google has said publicly it ignores both.

Clone this wiki locally