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,
.envfor 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.
}
}
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
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);
});
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.
Top comments (0)