TL;DR

  • Shipped a SEO + analytics baseline in Kickoff: meta/OG, canonical, JSON-LD org schema, dynamic robots.txt, sitemap, and admin-editable GA4/GTM.
  • The trick that keeps it clean: DB-stored settings are laid over config('seo.*') at boot, so every view reads config and nothing reads the settings class directly.
  • One source of truth, .env for first boot, admin UI after that. Tests assert on config toggles, not the DB.

Adding Google Analytics to an app is usually a one-liner. The mess starts later: you want the admin to change the GA4 ID without a deploy, you want .env to still seed sane defaults on a fresh install, and you don't want half your Blade views reaching into a settings model while the other half read config(). Today I wired all of that into Kickoff and the pattern is worth stealing.

The problem: two sources of truth

Settings that live in the database (Spatie's laravel-settings) and config that lives in config/*.php will drift the moment you read both from views. You end up with config('seo.google.analytics_id') in one partial and app(SeoSettings::class)->google_analytics_id in another, and now a change in the admin panel updates one but not the other.

The fix is to pick one read path. I picked config().

The bridge: settings laid over config at boot

config/seo.php holds first-boot defaults from .env. Then AppServiceProvider::boot() overlays the DB settings on top, so by the time any view renders, config('seo.*') already reflects the admin's edits:

private function applyDatabaseSettings(): void
{
    try {
        $seo = app(SeoSettings::class);
        config([
            'seo.meta.description'      => $seo->meta_description,
            'seo.canonical'            => $seo->canonical_enabled,
            'seo.google.analytics_id'  => $seo->google_analytics_id,
            'seo.google.tag_manager_id'=> $seo->google_tag_manager_id,
            'seo.organization.name'    => $seo->organization_name,
            // ...
        ]);
    } catch (\Throwable) {
        // Settings table not migrated yet (fresh install) —
        // silently fall back to .env / config defaults.
    }
}

Enter fullscreen mode Exit fullscreen mode

That catch is the important bit. On a fresh clone before migrate, the settings table doesn't exist. Swallowing the throwable means the app still boots on .env defaults instead of white-screening. Fail-safe, not fail-loud.

The rule that keeps it honest: views never read SeoSettings. The config docblock says it out loud — "Read via config('seo.*') everywhere; never read the Settings class in views." One path in, one path out.

Render only when configured

Every snippet is conditional on an ID being present, so local dev stays out of your analytics property automatically:

@if (config('seo.google.analytics_id'))
    <script async src="https://www.googletagmanager.com/gtag/js?id={{ config('seo.google.analytics_id') }}"></script>
    <script>
        window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('js', new Date());
        gtag('config', '{{ config('seo.google.analytics_id') }}');
    </script>
@endif

Enter fullscreen mode Exit fullscreen mode

Leave the field blank → no snippet → no dev traffic polluting production metrics. No APP_ENV branching needed.

Validate at the edge

The admin form validates ID shapes before saving, so a fat-fingered GA ID fails loudly in the UI instead of silently emitting a broken tag:

Field Rule
GA4 Measurement ID regex:/^G-[A-Z0-9]{4,}$/i
GTM container ID regex:/^GTM-[A-Z0-9]{4,}$/i
X/Twitter handle regex:/^@[A-Za-z0-9_]{1,15}$/
OG image / logo url, max:2048

Testing the toggle, not the DB

Because everything reads config(), tests are trivial — set the config, hit the page, assert on the HTML. No settings model, no seeding:

test('the ga4 snippet renders when a measurement id is set', function () {
    config(['seo.google.analytics_id' => 'G-TEST123456']);

    $this->get('/')
        ->assertOk()
        ->assertSee('gtag/js?id=G-TEST123456', false);
});

test('no analytics render when no ids are configured', function () {
    config(['seo.google.analytics_id' => null, 'seo.google.tag_manager_id' => null]);

    $this->get('/')->assertOk()->assertDontSee('googletagmanager.com', false);
});

Enter fullscreen mode Exit fullscreen mode

That's the real payoff of the single read path: the DB bridge is one small method you test once, and every feature on top of it tests against plain config.

Takeaway

If a value needs to be admin-editable and have a sensible default, don't make your views choose where to read it. Seed config from .env, overlay the DB settings at boot inside a try/catch, and let the whole app read config(). It's a boring little method — and boring is exactly what you want holding your source of truth.