Skip to main content

Parsing Garmin FIT Files in PHP: What the Binary Hides

11 min read May 20, 2026
PHP FIT Garmin Binary Parsing Trail Running Developer Tools Running Dynamics

Every GPS watch records more than the app ever shows you.

The rest is in the FIT file — lap splits with timer versus elapsed time, cadence per GPS point, running power, leg spring stiffness, form efficiency, air resistance. A complete log that no standard fitness app surfaces unless someone specifically built support for it.

That is why I wrote beljic/php-fit-sdk.

From Watch to File

During an activity, the watch writes sensor data continuously to internal flash storage. It does not buffer everything in memory and dump it at the end — it flushes records to the FIT file in real time, which is why the format is designed for streaming writes rather than random access.

When you stop the activity and save it, the file is complete. Plug the watch into a computer via USB and it mounts as a mass storage device. On Garmin devices the files live in GARMIN/Activity/. On most other watches the path is similar — a dated folder somewhere in the watch's internal storage. The filenames are timestamps: 2026-05-17-08-30-00.fit.

A two-hour trail run produces a file somewhere between 300 KB and 1 MB depending on how many sensors are active. The equivalent GPX file — the same route exported as XML — is three to four times larger. FIT is compact by design because it was built for watches with 16 MB of flash storage, not cloud APIs.

You can also get FIT files without a USB cable. Garmin Connect lets you export any activity as FIT. Strava has the same option under the activity settings. If your watch syncs via Bluetooth to a phone app, the raw FIT files are typically cached in the app's local storage — accessible if you know where to look and the app does not encrypt them.

What FIT Actually Is

Most fitness data formats are XML under the hood. GPX is XML. TCX is XML. You can open them in a text editor and understand what you are looking at.

FIT is not that.

FIT (Flexible and Interoperable data Transfer) is a binary protocol Garmin designed in 2008. It is compact, designed for embedded devices with limited storage, and structured in a way that most PHP developers never encounter: the file defines its own schema inline, and you cannot decode the data without first reading that schema.

A FIT file has:

  • a 12 or 14-byte header with a magic signature (.FIT), protocol version, profile version, and data size
  • a sequence of records, each prefixed with a single header byte
  • a trailing CRC

The records are where it gets interesting.

The Protocol Surprise: Definition Messages and Data Messages

Reading a FIT file is not a single pass over known fields. The format uses a two-phase approach: definition messages declare the structure, and data messages carry the actual values.

[definition record: local type 0, global message 20 (Record), fields: lat, lon, altitude, heart_rate ...]
[data record: local type 0, values: 44.06°N, 19.91°E, 487m, 148bpm ...]
[data record: local type 0, values: 44.07°N, 19.91°E, 489m, 149bpm ...]
[data record: local type 0, values: 44.07°N, 19.92°E, 491m, 147bpm ...]
[definition record: local type 1, global message 19 (Lap), fields: start_time, total_distance ...]
[data record: local type 1, values: ...]

The definition message tells you: "for local message type 0, here are the fields, their sizes, and their base types." The data messages that follow just pack the raw bytes and trust that you remember the definition.

This means you cannot seek to position X and decode a record. You must read from the beginning, accumulating definitions as you go.

In PHP, the ActivityBuilder does exactly that: a definitions map keyed by local type, populated as definition records arrive, consulted when data records appear.

private function readDataMessage(int $localType): void
{
    $definition = $this->definitions[$localType] ?? null;

    if ($definition === null) {
        return;
    }

    $values = $this->readFieldValues($definition);

    match ($definition->messageType) {
        MessageType::Record  => $this->handleRecord($values),
        MessageType::Lap     => $this->handleLap($values),
        MessageType::Session => $this->handleSession($values),
        // ...
        default              => null,
    };
}

Unknown message types are silently skipped. That is intentional. A FIT file from a Garmin watch contains dozens of message types we do not care about: device settings, workout configuration, HRV windows, GPS metadata. Parsing only what fitness analysis needs keeps the code simple and the output clean.

The Coordinate System Nobody Warns You About

GPS coordinates in FIT files are not stored as decimal degrees.

They are stored as semicircles — a 32-bit signed integer on a scale where the full circle is 2³² units. To convert to degrees:

degrees = semicircles × (180 / 2,147,483,648)

In PHP:

private const float SEMICIRCLE_TO_DEG = 180.0 / 2147483648.0;

private function semicirclesToDeg(int $semicircles): float
{
    return $semicircles * self::SEMICIRCLE_TO_DEG;
}

If you forget this and treat the raw int32 as a coordinate, you get values like 535,912,448 for latitude. Nothing will obviously break. The number just looks like a GPS coordinate from a parallel universe.

The same pattern applies to everything else:

Field Raw type Formula Unit
Latitude / Longitude sint32 × (180 / 2³¹) degrees
Altitude uint16 / 5 − 500 meters
Speed uint16 / 1000 m/s
Distance uint32 / 100 meters
Timestamp uint32 + 631,065,600 Unix timestamp

The altitude formula subtracts 500 because FIT uses an unsigned integer to represent elevation. Encoding as uint16 without an offset would make below-sea-level values impossible. With the −500 offset, the valid range covers roughly −500m to +12,668m. Enough for Everest, enough for the Dead Sea.

The timestamp offset is the amount of seconds between Garmin's FIT epoch (1989-12-31 00:00:00 UTC) and the Unix epoch. Every FIT timestamp is a 32-bit count of seconds since that Garmin epoch. Add 631,065,600 and you get a Unix timestamp. Use DateTimeImmutable::createFromFormat('U', ...) and you have a proper PHP datetime.

Compressed Timestamps

FIT has a space optimization for high-frequency sensor data.

A standard data record carries a timestamp in field 253 — that is 4 bytes per record. A GPS track at 1-second resolution with heart rate and cadence might have 10,000 records. Four bytes per record just for the timestamp adds up.

The solution is a compressed timestamp header. When bit 7 of the record header byte is set, the record is not a normal definition/data pair. Instead:

  • bits 6–5 encode the local message type (2 bits, so types 0–3 only)
  • bits 4–0 encode a 5-bit time offset from the last timestamp

The parser reconstructs the full timestamp by keeping the upper 27 bits of the last seen timestamp and replacing the lower 5 bits with the offset:

$base     = $this->lastTimestamp & 0xFFFFFFE0;
$prevLow  = $this->lastTimestamp & 0x1F;
$rollover = $timeOffset < $prevLow ? 32 : 0;
$timestamp = $base + $rollover + $timeOffset;

The rollover check handles the case where the 5-bit counter wraps around. If the new offset is less than the previous one, we have crossed a 32-second boundary.

This is one of those things that works perfectly in correct data and produces subtle, hard-to-catch bugs in anything else. If you do not handle rollover, your timestamps drift every 32 seconds.

Endianness Per Message

FIT is used on devices from many manufacturers. Some are big-endian. Some are little-endian. The spec handles this by letting each definition message declare its own byte order.

The architecture consequence is that every binary read operation must carry the endianness flag:

$bigEndian = $this->reader->readUint8() === 1;
$globalNum = $this->reader->readUint16($bigEndian);

The BinaryReader handles both:

public function readUint32(bool $bigEndian = false): int
{
    return unpack($bigEndian ? 'N' : 'V', $this->read(4))[1];
}

A single file can have some messages in big-endian and others in little-endian. In practice, most modern devices use little-endian consistently. The spec still requires you to read the flag and respect it.

Developer Fields: Where the Real Running Data Lives

FIT 2.0 added developer data fields — a mechanism for watch manufacturers to define custom metrics and embed them in standard FIT files without breaking other decoders.

Some watches use this extensively. Custom fields like Form Power, Leg Spring Stiffness, Air Power, Impact Loading Rate, and Effort Pace are not standard FIT fields. They are registered via two special message types:

  • FieldDescription (206): declares a developer field — its index, name, base type, and units
  • DeveloperDataId (207): links a developer field set to a specific app or vendor

The parser handles this in two passes:

First, when a FieldDescription record arrives, the field metadata is stored:

$this->developerFields[$devIdx][$fieldNum] = [
    'name'      => $name,
    'base_type' => $baseType,
];

Then, when a Record data message carries developer field values, they are resolved by name and attached to the PHP value object:

$devFields = [];
foreach ($v as $key => $value) {
    if (!is_string($key) || !str_starts_with($key, 'dev_')) {
        continue;
    }
    [, $devIdx, $fieldNum] = explode('_', $key, 3);
    $meta = $this->developerFields[(int) $devIdx][(int) $fieldNum] ?? null;
    if ($meta !== null) {
        $devFields[$meta['name']] = $value;
    }
}

The result is a Record with a developerFields array keyed by field name:

$record->developerFields['Form Power'];          // 180.0 watts
$record->developerFields['Leg Spring Stiffness']; // 11.3 kN/m
$record->developerFields['Air Power'];            // 4.2 watts

These numbers are invisible in every standard fitness app unless it specifically knows about the device's field schema. The FIT file has always had them. You just need something that reads past field 253.

Architecture: Parser, Reader, Builder

The code separates three concerns that often get tangled in binary parsers:

FitParser       — public API, validates header, owns nothing else
BinaryReader    — cursor over raw bytes, knows nothing about FIT semantics
ActivityBuilder — FIT protocol logic, assembles the value object graph

FitParser::parseFile(), parseString(), and parse(SourceInterface) are the only public entry points. They delegate immediately:

public function parse(SourceInterface $source): Activity
{
    $data   = $source->read();
    $reader = new BinaryReader($data);

    $dataEndPosition = $this->validateHeader($reader);

    return (new ActivityBuilder($reader, $dataEndPosition))->build();
}

BinaryReader is a cursor with typed read methods. It knows about bytes, endianness, and position. It knows nothing about semicircles or FIT epochs. Tests that cover specific field conversions do not need a reader at all.

ActivityBuilder accumulates pendingRecords and pendingLaps until a Session message arrives, then flushes them into an immutable Session value object. This mirrors how FIT structures multi-session files: records belong to the session that follows them.

The SourceInterface abstraction is the piece that makes the library framework-agnostic. FileSource reads from disk. StringSource wraps in-memory bytes. A hypothetical StreamSource could read from an HTTP response without buffering. The parser never cares which.

Testing Without Real Files

The test suite never reads a real FIT file.

Real FIT files from the watch are in tmp/ (gitignored). They are useful for manual verification but not for automated tests. A real file is opaque, large, and changes if the watch firmware changes.

Instead, tests use StringSource with carefully constructed binary fixtures:

$header = pack('CCCS A4 S',
    14,     // header size
    0x10,   // protocol version
    0x07EF, // profile version
    0,      // data size (filled later)
    '.FIT', // magic
    0x0000  // header CRC
);

The fixture builds the minimal binary needed to exercise one specific path: compressed timestamps, developer field resolution, endianness, the altitude formula. Small, deterministic, readable in the test itself.

That is also why the architecture separates BinaryReader from ActivityBuilder. The reader can be tested with raw bytes. The builder can be tested with a reader seeded from a fixture. Neither test needs a file on disk.

What the Parser Produces

After parsing, the result is a tree of immutable PHP value objects:

$activity = (new FitParser())->parseFile('activity.fit');

$activity->createdAt;                           // DateTimeImmutable
$activity->device->manufacturer->value;         // 'Garmin'
$activity->device->productName;                 // 'Forerunner 965'

foreach ($activity->sessions as $session) {
    $session->sport->value;                     // 'trail_running'
    $session->totalDistance;                    // 21430.5 (meters)
    $session->avgHeartRate;                     // 157

    foreach ($session->records as $record) {
        $record->lat;                           // 44.060638 (degrees)
        $record->altitude;                      // 487.2 (meters)
        $record->developerFields['Form Power']; // 183.0
    }
}

All classes are readonly. No setters. No mutation after construction. If you need a modified activity — for example, with filtered records — you build a new one. The original stays intact.

What Comes Next

The parser is Phase 1. It solves the access problem: getting the data out of a proprietary binary format into clean PHP objects.

Phase 2 is analysis. A separate package, beljic/fit-analyzer, will take the parsed Activity and produce a TrainerReport — textual interpretation of training load, form trends, lap-over-lap patterns, heat and altitude effects. That package will have a provider-agnostic AI interface so it can talk to Claude, ChatGPT, or a local model. The FIT SDK stays clean, dependency-free, and focused on parsing.

The bigger engineering point is that access to your own training data should not require subscribing to a platform that controls what you see. FIT files live on your watch's filesystem. They are yours. What you do with them should be your choice.

A PHP package that reads them correctly is step one.

The source code is available on GitHub: beljic/php-fit-sdk.