beyondcode/fathom-api
A Laravel-friendly PHP client for the Fathom Analytics API. Query sites, page views, events, and other analytics data directly from your app, with a clean wrapper around Fathom's endpoints for dashboards and reporting.
Install the v3-compatible line and pin it — 1.1.0 is the first release that speaks Fathom's v3 API, and everything below it targets the v2 surface.
composer require beyondcode/fathom-api:^1.1
php artisan vendor:publish --tag=fathom-config
FATHOM_API_TOKEN=your-token-here
Generate the token in Fathom under Settings → API. Auto-discovery wires up the service provider and the Fathom facade alias on Laravel; on Lumen you register BeyondCode\FathomApi\FathomApiServiceProvider and the alias by hand in bootstrap/app.php, or the facade resolves to null and every call becomes an unhelpful null-reference.
Read the published config before anything else. The base URL / API-version key in there decides whether you hit v3 or a legacy endpoint. It is the single most consequential value in the package — confirm it before you trust a single number.
Then read two files, in this order: src/Fathom.php and src/FathomClient.php. That pair is the entire public surface: a small fluent builder over /sites, /aggregations, /events, and /current_visitors. You will not need the README again after that.
Smoke-test access in tinker before writing integration code:
use BeyondCode\FathomApi\Facades\Fathom;
Fathom::sites(); // array of {id, name, ...} or bust
If that returns site objects, auth + base URL + token all line up. If it 401s or throws, the problem is configuration, not your metric arguments — fix it there first.
First real call — last seven days of pageviews:
$siteId = collect(Fathom::sites())->firstWhere('name', 'My Site')['id'];
$pageviews = Fathom::site($siteId)->pageviews(
from: now()->subDays(7),
to: now(),
);
The day-to-day grammar is always the same: pick site → pick metric → give a date range → get an array back. Everything else is a variation on that.
Never call the facade from a controller or a Blade template. Put one seam in front of it — a small AnalyticsService or an AnalyticsReport interface — and let that be the only place the vendor name appears. This is the difference between a 10-minute swap later and a grep-and-pray refactor.
final class AnalyticsService
{
public function __construct(private string $siteId) {}
public function weeklyPageviews(): array
{
return Cache::remember(
"fathom.{$this->siteId}.pv.7d",
300,
fn () => Fathom::site($this->siteId)->pageviews(
from: now()->subDays(7),
to: now(),
),
);
}
}
Bind it in a service provider with the site ID pulled from config, so local, staging, and production can point at different Fathom sites without a code change.
Snapshot to your own table — this is the pattern that carries the package. Fathom's API is comparatively slow and rate-limited, so a dashboard that fetches live on every request will get throttled in production long before it gets popular. Pull yesterday's aggregate per site once a night into an analytics_daily table and let every dashboard read from your database:
$schedule->job(new SnapshotFathomDaily)->dailyAt('02:00')->withoutOverlapping();
The side benefits are the real argument: history that outlives Fathom's retention window, backfills and repairs that never touch the vendor, and dashboards that survive an API outage without anyone noticing.
Cache at the right grain. Key on site + metric + range, not on the dashboard. Five widgets on one screen routinely ask the same question, and you do not want five HTTP round-trips behind a single page render.
Keep widget code dumb. Everything comes back as plain arrays, so a Livewire component or Filament StatsOverview widget is pure formatting: fetch via the service, map into a small DTO (PageviewsToday::fromArray($raw)), render. If the widget owns the fetch, you can't test it without faking HTTP, and every new widget reinvents caching.
All breakdowns share one shape. Per-page, referrer, country, browser — they're all site($id)->{metric}(from:, to:). Put the whole metric-to-method mapping in a single method on your service so a future response-shape change is one edit, not a scavenger hunt.
Multi-site and client portals. Loop site IDs from config, one cached fetch per site, merge for the dashboard. Because the cache is per-site, a portal with ten clients costs ten keys, not ten full re-fetches per page view.
Testing, two levels. Unit tests bind a fake AnalyticsReport into the container and never touch vendor code — fast and vendor-version-independent. Contract tests hit staging with a scoped token against a known-clean site, one per metric you actually render. Http::fake() also works since the package rides Laravel's HTTP client, but that exercises the vendor's parsing rather than your wiring; you generally want both layers.
Degrade, don't blow up. Wrap reporting calls at the service boundary, return an empty struct or the last cached value on failure, and log structurally. A reporting outage should never be able to take down an admin page render.
Treat the version pin as production policy. 0 stars, 0 dependents, 0.3 score — the adoption signal is essentially absent. 1.1.0 is a genuine maintenance signal, but one release is not a cadence. Pin exactly ("1.1.0"), watch the repo and Fathom's v3 changelog, and keep a fork or an in-house Guzzle client as a pre-planned escape hatch rather than a panicked one.
The v2 → v3 story is ambiguous. "Adds v3 compatibility" doesn't tell you whether that replaces the v2 path or sits behind a config switch, or whether the config schema changed. Ship the upgrade as its own change with regression tests — never bundled with a feature.
Endpoint coverage may stop short of what you assume. The builder wraps sites, aggregations, events, and current visitors. Event ingestion, site management, or anything beyond aggregate reporting may simply not be exposed. Check Fathom's v3 docs before you promise it in a spec.
Everything is untyped arrays. No DTOs, no validation, no schema. A silent shape change on Fathom's side lands as null in a widget with no error anywhere in the stack. Validate expected keys at your boundary and log a warning when one is missing — that log line is your early-warning system for API drift.
Site ID resolution is a hot-path trap. firstWhere('name', 'X')['id'] fails silently on a rename and throws on a missing site. Resolve once (a command, a seeder, the config publish step) and store it in config or the DB. Never do a name lookup on a request path.
Set timeouts, retries, and backoff explicitly. The package sits on Laravel's HTTP client, so configure a timeout on the underlying request, add retry-with-jitter, and put a circuit breaker in front of any non-critical path. Verify actual rate-limit behaviour against v3 — the limits and response envelope may have moved from v2.
Be explicit about dates. Fathom aggregates on its own timezone and day boundaries, not yours. Always pass Carbon from/to. Decide early whether "today" means the partial current day or yesterday's full aggregate — most dashboards want the latter and accidentally ship the former. Don't assume your app timezone matches the site timezone.
Config cache is the number-one cause of mystery 401s. After changing FATHOM_API_TOKEN, the base URL, or the version key, run php artisan config:cache — or config:clear while iterating. A stale cache will happily keep hitting the old endpoint with the old token, and nothing in the exception will tell you.
Debug in this order:
Fathom::sites(). Works? Auth and base URL are correct. Doesn't? Fix that and stop.Fathom::site($id)->pageviews(from, to) over a wide window (90 days). Wide works and narrow doesn't? Your date arguments.Design for these failure modes up front: API outage, expired token, throttling, response-schema drift, Laravel upgrade incompatibility, and silent nulls. Structured logging plus an error-rate metric plus a nightly check that Fathom::sites() still answers is usually the whole safety net.
The extension point is the smallness itself. A thin fluent builder is cheap to wrap, cheap to fork, cheap to replace. Keep the facade behind your own interface so swapping is a one-line container binding. And put "review this pin" on the quarterly dependency checklist — if the repo goes twelve months without a release and Fathom ships v4, that's your signal to fork or replace, not to hope.
How can I help you explore Laravel packages today?