awcodes/gtm
A lightweight Laravel and Filament package for adding Google Tag Manager to your app. Set your GTM container ID, inject the required scripts, and push dataLayer events without editing your Blade views—a quick, clean analytics setup.
Install and publish.
composer require awcodes/gtm
php artisan vendor:publish --tag=gtm-config # confirm the exact tag in the package docs/ directory
That gives you config/gtm.php with two keys that matter: gtm.id (the container, e.g. GTM-XXXXXXX) and gtm.enabled (the global kill switch). Wire the ID from env so each environment carries its own container:
// config/gtm.php
'id' => env('GTM_ID'),
'enabled' => env('GTM_ENABLED', false),
Drop the two components into your base layout. This is the whole integration — there is nothing else to register, no middleware, no routes.
{{-- resources/views/layouts/app.blade.php --}}
<head>
...
<x-gtm::script />
</head>
<body>
<x-gtm::no-script />
...
</body>
First use case to get working: a staging deploy that renders the container ID in view-source with GTM_ENABLED=true, and renders nothing at all locally with it unset. Verify with curl -s https://staging.example.com | grep -i googletagmanager rather than eyeballing the browser — this catches the class of bug that v2.0.1 fixed.
Read first: the docs/ directory added in v2.0.1, specifically the page on the two-level enable rule. It is the single thing that will confuse you later.
One wrapper partial, not scattered tags. Put both components in a single resources/views/partials/analytics.blade.php and include that from every layout (app, auth, marketing, error pages). When you eventually need to add consent gating or a second vendor, you edit one file.
Use the two-key gate as your environment policy. Config handles the global state (GTM_ENABLED=false in .env.local, .env.testing, CI); the enabled prop scopes it per surface at the layout level:
{{-- marketing layout: tracked --}}
<x-gtm::script :enabled="true" />
{{-- Filament admin panel layout: never tracked into the customer container --}}
<x-gtm::script :enabled="false" />
Always pass the prop as a bound boolean (:enabled="false"). enabled="false" is the string "false", which is truthy.
Testing pattern — assert each component separately. The v2.0.1 regression existed because the old suite rendered both and asserted the ID appeared somewhere. Mirror the upstream fix in your own feature tests:
it('renders the gtm loader', function () {
config(['gtm.id' => 'GTM-TEST123', 'gtm.enabled' => true]);
$this->get('/')->assertSee('GTM-TEST123', false)->assertSee('googletagmanager.com/gtm.js', false);
});
it('renders the noscript fallback with the container id', function () {
config(['gtm.id' => 'GTM-TEST123', 'gtm.enabled' => true]);
$this->get('/')->assertSee('ns.html?id=GTM-TEST123', false);
});
it('renders nothing when the global switch is off', function () {
config(['gtm.enabled' => false]);
$this->get('/')->assertDontSee('googletagmanager.com', false);
});
Run the matrix — enabled prop × gtm.enabled config, all four combinations — at least once in your own suite, so an upstream change to the gate semantics fails your build rather than silently darking your analytics.
SPA / Livewire route changes. x-gtm::script fires the initial page_view only. If you use Inertia, Livewire wire:navigate, or Turbo, push manually on navigation:
document.addEventListener('livewire:navigated', () => {
window.dataLayer?.push({ event: 'page_view', page_path: location.pathname });
});
Consent. The enabled flags gate rendering, not firing. If you need consent-before-tag, either keep gtm.enabled false until a consent cookie exists (decide server-side, per request) or configure GTM Consent Mode inside the container. Do not assume the package helps here.
Removal plan. Because the output is static HTML, keep the snippet in your wrapper partial commented out. If the package goes unmaintained, swapping to a hand-rolled include is a ten-minute change, and knowing that is what makes the dependency cheap.
Both keys must be true, and failure is silent. A correctly wired page with gtm.enabled false and a :enabled="true" prop renders nothing, logs nothing, and fails no test. When "there's no GTM in the page source," check in this order: config('gtm.enabled'), the prop on the component, then whether the layout includes the partial at all. php artisan tinker --execute="dump(config('gtm.id'), config('gtm.enabled'));" settles it in one command.
Config cache will bite you on deploy. gtm.id and gtm.enabled come from config/gtm.php, which reads env. If you change GTM_ID in .env without php artisan config:cache, you get the stale container — and a stale ID looks identical to the v2.0.1 bug. Make config:cache part of the deploy script, and purge full-page/CDN caches after it, because the rendered HTML is what gets cached.
Pin >= 2.0.1. Pre-2.0.1, x-gtm::no-script read config('site.gtm.id') — a namespace the package never defines — so the fallback iframe emitted an empty id and reported nothing for no-JS visitors. The script tag was fine, which is exactly why it went unnoticed. Check composer.lock; if you're on 2.0.0, upgrade and treat it as an audit trigger for any downstream dashboard that assumed complete traffic coverage.
Delete your workarounds in the same commit. If anyone patched around the noscript bug with a site.gtm.id config entry or an overridden no-script view, remove both when upgrading. Leaving them keeps a dead config key around and leaves your override drifting from corrected upstream. Same for any published view overrides — re-check them against the new version, since they will shadow future fixes.
Per-tenant or per-surface container IDs are not supported out of the box. The ID is config-sourced, read at render time. You can Config::set('gtm.id', $tenant->gtm_id) in a middleware before the response renders, but that's a runtime mutation of shared config and interacts badly with config:cache warm-up and any queued/Octane context. If you genuinely need multiple containers, wrap the component in your own Blade component that sets and restores config around the render, and test it under the same concurrency model you deploy with.
This package renders tags; it does not define them. No event schema, no data-layer validation, no server-side tagging, no consent collection. Budget your effort accordingly — the rendering layer is now solved, the taxonomy is not.
Bus factor. Five stars, zero dependents, single maintainer, no stated Laravel/PHP support matrix. Fine for a snippet renderer behind a one-file wrapper; do not build anything load-bearing on top of it, and keep the wrapper partial as your exit ramp.
How can I help you explore Laravel packages today?