Skip to main content
Wintertrace
Browse the documentation

Building modules

Public homepage & SEO

How a frontpage module serves a public homepage and opts specific pages into search-engine indexing — while the rest of the installation stays private and unindexed.

A Wintertrace installation is, by default, a private application. The whole thing — login, admin, customer portal, driver app, installer — is kept out of search engines to protect customer data. That is enforced at three layers, all of which default to “deny”.

But some operators — small winter-service businesses with no website of their own — want to use their installation as a public website: rent webspace, install Wintertrace, enable a frontpage module, and get a public site plus clean job documentation in one place. For that, the public pages have to be indexable.

This page explains how a module serves public pages and opts them into search-engine indexing without exposing the rest of the app.

One source of truth. Everything below is driven by a single registry, PublicHomepageRegistry. A module declares its public pages once; the robots.txt route, the X-Robots-Tag response header, and the per-page <meta name="robots"> tag all consult that same registry at request time, so they can never drift apart. (See it in the registries reference.)

PublicHomepageRegistry

Class: App\Services\Extension\PublicHomepageRegistry (singleton)

// Serve the public root URL "/" instead of redirecting to login.
// Registering a homepage automatically marks "/" crawlable.
register(callable $handler): void          // handler returns Response|View|string
has(): bool
render(): mixed

// Declare ADDITIONAL public pages (leading slash optional). "/" is added by register().
allowCrawling(string ...$paths): void      // e.g. allowCrawling('/services', '/imprint')
crawlablePaths(): array                     // list<string>, normalized with leading slash
isCrawlable(string $path): bool             // root matches exactly; sections match sub-paths

// Optional XML sitemap advertised in robots.txt (module must serve it itself).
setSitemapUrl(string $url): void
sitemapUrl(): ?string

isCrawlable() matching rules

The matching is deliberately strict so a single public section cannot accidentally open up a neighbouring private path. The root matches only itself; a registered section matches its sub-paths but not a mere string prefix.

RegisteredRequest pathCrawlable?
//✅ (root matches only itself)
//login
/services/services
/services/services/winter✅ (sub-path)
/services/servicesX❌ (mere prefix, not a sub-path)

Serving the homepage

In your module’s ServiceProvider::boot():

use App\Services\Extension\PublicHomepageRegistry;

app(PublicHomepageRegistry::class)->register(
    fn () => view('frontpage::homepage')
);

The core / route (routes/web.php) consults the registry at request time, so this works even with route:cache enabled. The precedence is sensible: the installer wins before setup is complete; after that, a registered homepage wins; otherwise / redirects to login, exactly as an unconfigured install does today.

Adding more public pages

Register your own routes as usual (see Routes & APIs), then tell the registry which of them are public so they get crawled and indexed:

Route::middleware('web')->group(function () {
    Route::get('/services', [SiteController::class, 'services'])->name('frontpage.services');
    Route::get('/imprint',  [SiteController::class, 'imprint'])->name('frontpage.imprint');
});

app(PublicHomepageRegistry::class)->allowCrawling('/services', '/imprint');

Anything you do not pass to allowCrawling() stays private. The model is default-deny: you opt pages in, you never opt them out.

The three indexing layers

Indexing is not one switch but three, each covering a gap the others cannot.

LayerWhereControlsDefaultPublic opt-in
robots.txtdynamic route in routes/web.phpwhat may be crawledDisallow: /Allow: per crawlablePaths()
X-Robots-TagSecurityHeaders middlewarewhat may be indexed (incl. non-HTML, e.g. PDFs)noindex, nofollow on every responseheader omitted for crawlable paths
<meta name="robots">the Blade layoutswhat may be indexed (HTML only)noindex, nofollow@section('robots','index, follow')

Why three? robots.txt stops crawling before a request even reaches PHP. The X-Robots-Tag header is the authoritative index signal and also covers responses that have no HTML — a generated PDF report has no <meta> tag, but it still must not be indexed. The meta tag is the visible HTML-level signal and acts as defence-in-depth. The middleware and the meta tag must agree, and they do, because both key off the same registered paths.

robots.txt output

When no frontpage module is active (the normal private install):

User-agent: *
Disallow: /

When a module has registered / plus allowCrawling('/services') and a sitemap:

User-agent: *
Disallow: /
Allow: /$
Allow: /services
Allow: /build/
Allow: /favicon.ico
Allow: /favicon.svg

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

Allow: /$ anchors the root so only / itself is crawlable (not /login); section paths are left unanchored so their sub-pages are covered too. /build/ and the favicons are always allowed when public, because a search engine has to fetch your CSS and JS to render the page correctly.

Making a page indexable — checklist

  1. Register the route (for sub-pages) and serve it through the web middleware group so SecurityHeaders runs.

  2. Declare it public: allowCrawling('/your-path') (the homepage / is automatic via register()).

  3. Opt the HTML in. If your view extends a core layout, override the robots section:

    {{-- frontpage::homepage --}}
    @extends('layouts.guest')
    
    @section('robots', 'index, follow')
    
    @section('content')
        <h1>Example Winter Service Co.</h1>
        ...
    @endsection

    If you ship your own standalone HTML (not extending a core layout), just emit <meta name="robots" content="index, follow"> yourself.

  4. (Optional) Advertise a sitemap: setSitemapUrl('https://.../sitemap.xml') and serve that route yourself.

That is all — the robots.txt route and the X-Robots-Tag header pick up the registered path automatically. (The @extensionSlot / @lifecycleFields directives are described under Assets & frontend; the robots section works the same way — a layout hook your view fills in.)

What stays private (no action needed)

Everything you do not register: /login and the rest of auth, /admin/*, /portal/*, /driver/*, /install/*, every module admin page, and all non-HTML responses such as generated PDF reports. They keep the X-Robots-Tag: noindex, nofollow header and the layouts’ default noindex meta tag.

Deployment notes

These matter mostly when updating an existing installation, where stale server-level configuration can quietly override the application:

  • The static public/robots.txt was removed so the dynamic route can take effect. A static file is served by the web server before PHP ever runs, so it would shadow the registry-driven route. Do not re-add it.
  • The blanket X-Robots-Tag header was removed from public/.htaccess. It was unconditional and could not tell public pages from private ones; the SecurityHeaders middleware now sets it per request. When updating an existing installation, make sure the new .htaccess ships in the update package — otherwise an old Apache install keeps the blanket header and the frontpage stays noindex despite everything else being correct. (nginx installs never had this header, so they gain correct behaviour for the first time.)
  • Static assets under /build/, favicons, and the like are served directly by the web server and never pass through the middleware, so they are never given a noindex header — exactly what a search engine needs to render public pages.
  • Registries reference — the PublicHomepageRegistry API in the registry catalogue
  • Routes & APIs — registering routes and the SecurityHeaders middleware
  • Template slots — injecting into existing layouts instead of replacing the homepage