jbtcd/fitbit
A Laravel/PHP package for integrating Fitbit OAuth and API data into your app, helping connect users, retrieve activity and health metrics, and manage Fitbit access tokens through a simple interface.
Set expectations first. This package is archived, has no maintainer, and essentially zero adoption. You are not adopting a dependency — you are vendoring source code. Plan to fork it into your own VCS before the first production commit and treat it as first-party from that point on.
Step 1 — Prove it installs on your stack.
composer require jbtcd/fitbit
Pin the exact version ("jbtcd/fitbit": "x.y.z"). Check composer.json for declared PHP/Laravel constraints; they almost certainly predate Laravel 9+/PHP 8.1+. You will likely need to widen constraints (--with-all-dependencies) and patch any deprecated framework calls. Budget half a day for this pass; run whatever tests ship with it against your target versions in CI on day one.
Step 2 — Create the Fitbit app before writing any code. Nothing works until you register at https://dev.fitbit.com/apps. You get a clientId and clientSecret, and you must set the OAuth 2.0 Redirect URI exactly — Fitbit rejects trailing-slash and http vs https mismatches with no useful error. Use the sandbox first.
Step 3 — Wire config yourself. There is no service provider, no publishable config, no facade. Put credentials in config/services.php:
'fitbit' => [
'client_id' => env('FITBIT_CLIENT_ID'),
'client_secret' => env('FITBIT_CLIENT_SECRET'),
'redirect' => env('FITBIT_REDIRECT_URI'),
'scopes' => ['activity', 'heartrate', 'sleep', 'profile'],
],
Step 4 — Instantiate the provider manually. It is a plain provider class, constructed where you need it:
$provider = new \Jbtcd\Fitbit\FitbitProvider([
'clientId' => config('services.fitbit.client_id'),
'clientSecret' => config('services.fitbit.client_secret'),
'redirectUri' => config('services.fitbit.redirect'),
'scopes' => config('services.fitbit.scopes'),
]);
return redirect($provider->getAuthorizationUri());
Look first at src/. Read the provider class and any shipped Api/Client class. That inventory tells you exactly which endpoints are wrapped and which you will call manually with the access token. In practice you will wrap most data calls yourself.
First use case: the OAuth round-trip plus a single GET /1/user/-/profile.json call. Do not start with sleep or heart-rate data. Get auth, encrypted token persistence, and one read endpoint solid, then add scopes.
Dedicated OAuth controller pair. Keep a FitbitAuthController with redirect() and callback(). Generate and store the state value in session in redirect(); verify it in callback() before exchanging the code. Keep the exchange in the controller or a called service — never inline in a route closure, so it stays testable.
Persist tokens per user, encrypted. Access tokens expire in ~8 hours; refresh tokens rotate. You need a table:
Schema::create('fitbit_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('access_token'); // cast: encrypted
$table->text('refresh_token'); // cast: encrypted
$table->timestamp('expires_at');
$table->string('scope')->nullable();
$table->timestamps();
});
Use Eloquent encrypted casts on both tokens and a tokenIsExpired() helper with a small safety margin (refresh at ~expires_at minus 60s).
One service owns refresh. Fitbit returns 401, or 400 with expired_token, on stale access tokens. Centralize refresh so nothing else touches token rotation:
class FitbitClient
{
public function __construct(protected FitbitProvider $provider) {}
public function get(string $path, array $query = []): array
{
$token = $this->tokens->current();
if ($token->isExpired()) {
$token = $this->refresh($token);
}
return $this->request($token->access_token, $path, $query);
}
}
Every Fitbit call goes through this method. No exceptions — direct API calls scattered through controllers are how token bugs become production incidents.
Wrap the vendor behind an interface. Define FitbitClientInterface (authUrl, exchange, refresh, fetch) in your application and implement it with the package. Callers depend on the interface, not on Jbtcd\Fitbit\*. This is the single highest-value deliverable — it turns the eventual rewrite into a swapping exercise instead of a refactor.
Queue data pulls; never call Fitbit on the request path. Rate limits are per-user, and a page render that blocks on the Fitbit API will time out under load. Push syncs to jobs, store responses in local tables, and render from local tables:
SyncFitbitActivity::dispatch($user)->onQueue('fitbit');
Cache aggressively and back off on 429. Respect Retry-After. Add exponential backoff on RateLimitExceeded and RequestLimitExceeded.
Provider-agnostic abstraction is worth the extra day. If you plan Strava, Garmin, or Withings later, keep this integration behind a shared wearable interface. The package's structure makes that shape obvious — mirror it.
Scheduled refresh job. A nightly job that pre-refreshes tokens hours before expiry avoids thundering 401s at user traffic peaks.
Silent token revocation breaks sync quietly. Fitbit users can revoke your app from their Fitbit account. Your refresh then returns invalid_grant, and if nothing handles it, syncs simply stop without an alert. Add explicit handling: mark the token row as revoked, surface a user-facing "Reconnect Fitbit" flow, and alert on unexpected invalid_grant rates.
Refresh-token rotation races corrupt credentials. Fitbit issues a new refresh token on each refresh; the old one becomes invalid. Two concurrent jobs refreshing the same user will permanently break the account grant. Protect token refresh behind a per-user lock:
Cache::lock("fitbit:refresh:{$token->user_id}", 10)->block(5, function () use ($token) {
// re-check expiry inside the lock, then refresh
});
Re-read the token from the DB inside the lock — another process may have refreshed while you waited.
Redirect URI mismatch is the #1 onboarding bug. Fitbit errors give no hint. Compare byte-for-byte: protocol, host, port, path, trailing slash. Check APP_URL in the .env of each environment separately.
Composer constraint widening is not optional. With an archived package expect symfony/* and illuminate/* conflicts. Widening is usually safe — the code surface is small — but commit the change in your fork, not via composer.json on the project root, so CI stays reproducible.
HTTP layer may not be swappable. Check whether the client accepts a Guzzle instance. If not, retry logic, structured logging, and rate-limit handling must live in your wrapping service, not the transport. That is fine — just decide it before writing call sites.
Scope creep changes the consent screen. Every scope you add is a Fitbit review item and a user-visible permission. Request the minimum set for the feature, not the package's default examples. Adding heartrate when you only need profile will cost you conversions and complicate GDPR scope-minimization justifications.
Endpoint drift is your problem now. Fitbit has retired/replaced endpoints over time. When a field moves or an endpoint is deprecated, Fitbit often returns a response that looks fine but is missing fields the package assumes. Add contract tests against recorded fixtures (VCR-style) so drift surfaces loudly in CI, and validate the shape of responses before use.
Nothing in this package does compliance. Health data carries GDPR/HIPAA-adjacent obligations around consent records, retention, scope minimization, and deletion. Token storage encryption is necessary, not sufficient. Log consent explicitly, keep a deletion path, and do not treat the package's behavior as guidance.
Document the fork. Write a short design note — scopes requested, token storage, sync cadence, refresh concurrency, and the replacement plan. With no upstream changelog and no maintainer to consult, this is the only institutional memory you will have. Name an owner; orphans appear within two quarters otherwise.
Open a parallel track to replace it. This is a design reference, not a long-lived dependency. The package's value is the shape it shows you — provider pattern, token exchange, refresh flow — which informs your own client against the official Fitbit API. Keep the wrapper interface and swapping out later is a one-day job.
How can I help you explore Laravel packages today?