---
name: php-utc-timezone-offset-mismatch
description: |
  Fix 6-hour (or N-hour) timestamp display errors when PHP backend stores UTC
  timestamps in the database but format('c') applies the server's local timezone
  offset instead of UTC. Use when: (1) timestamps display exactly N hours off
  where N matches the server's UTC offset, (2) PHP format('c') produces wrong
  timezone suffix like -06:00 on UTC values, (3) Dart DateTime.tryParse trusts
  the offset and double-converts, (4) "new DateTime($string)" in PHP doesn't
  specify DateTimeZone, (5) adding debug prints shows parsed.isUtc=true but
  the local time equals the raw UTC value instead of being offset-adjusted.
  Covers PHP DateTimeZone bug, Dart client-side workaround, and debugging with
  diagnostic prints in path-dependency packages.
author: Claude Code
version: 1.0.0
date: 2026-02-20
---

# PHP UTC Timezone Offset Mismatch

## Problem

Timestamps display exactly N hours off (where N = server's UTC offset). For
example, a message sent at 2:03 PM CST shows as 8:03 PM in the app. The
offset is consistent and matches the server's timezone (e.g., 6 hours for CST).

## Context / Trigger Conditions

- PHP backend stores UTC timestamps in the database (e.g., MySQL `DATETIME` column)
- PHP code creates DateTime from DB string: `new DateTime($dateString)` — WITHOUT specifying UTC
- PHP's `format('c')` outputs ISO 8601 with the **server's default timezone** offset
- The resulting timestamp like `"2026-02-20T20:03:25-06:00"` is semantically WRONG:
  - It claims "8:03 PM CST" but the value `20:03:25` is actually UTC (8:03 PM UTC = 2:03 PM CST)
- Client-side (Dart, JavaScript, etc.) trusts the timezone offset and converts correctly
  according to ISO 8601 — but the source data was wrong, so the result is wrong

### Symptoms

1. Timestamps are exactly N hours off (consistent offset, not random)
2. The offset matches the server's timezone (e.g., -6 hours for CST/America/Chicago)
3. Adding `parsed.isUtc=true` debug output shows the parser correctly identifies timezone
4. The `.toLocal()` conversion works correctly — but produces wrong results because input is wrong

### Debugging Clue

If you add a debug print and see:
```
raw="2026-02-20T20:03:25-06:00" parsed.isUtc=true
utc=2026-02-21 02:22:16.000Z local=2026-02-20 20:22:16.000
```

The `local` value equals the raw time (20:22) — that's the smoking gun. The backend
sent local-timezone-labeled UTC data, and the client faithfully converted it back.

## Solution

### Backend Fix (Proper)

In PHP, when creating DateTime from a UTC database string, ALWAYS specify the timezone:

```php
// WRONG: Uses server's default timezone (e.g., America/Chicago = CST)
$dateTime = new DateTime($row['created_at']);
$dateTime->format('c');
// Output: "2026-02-20T20:03:25-06:00" (WRONG — claims CST)

// CORRECT: Explicitly specify UTC
$dateTime = new DateTime($row['created_at'], new \DateTimeZone('UTC'));
$dateTime->format('c');
// Output: "2026-02-20T20:03:25+00:00" (CORRECT — labeled as UTC)
```

### Client-Side Workaround (Dart)

If the backend can't be fixed immediately, strip the timezone suffix and treat
the raw digits as UTC:

```dart
DateTime? tryParseDateTime(String? value) {
  if (value == null || value.isEmpty) return null;

  // Strip timezone suffix — backend labels UTC values with wrong offset.
  // Handles: -06:00, +05:30, +00:00, Z
  final bare = value
      .replaceFirst(RegExp(r'Z$'), '')
      .replaceFirst(RegExp(r'[+-]\d{2}:\d{2}$'), '');

  final parsed = DateTime.tryParse(bare);
  if (parsed == null) return null;

  // Always re-interpret as UTC (backend stores UTC in the database).
  final utc = DateTime.utc(
    parsed.year, parsed.month, parsed.day,
    parsed.hour, parsed.minute, parsed.second,
    parsed.millisecond, parsed.microsecond,
  );
  return utc.toLocal();
}
```

This workaround is compatible with BOTH the broken and fixed backend output:
- `"2026-02-20T20:03:25-06:00"` → strip → UTC 20:03 → local 14:03 CST ✓
- `"2026-02-20T20:03:25+00:00"` → strip → UTC 20:03 → local 14:03 CST ✓
- `"2026-02-20T20:03:25Z"` → strip → UTC 20:03 → local 14:03 CST ✓
- `"2026-02-20 20:03:25"` → no suffix → UTC 20:03 → local 14:03 CST ✓

### JavaScript Client-Side Workaround

Same pattern for JavaScript/TypeScript clients:

```typescript
function parseUtcTimestamp(value: string): Date {
  // Strip timezone suffix — backend sends UTC with wrong offset
  const bare = value.replace(/Z$/, '').replace(/[+-]\d{2}:\d{2}$/, '');
  // Append Z to force UTC interpretation
  return new Date(bare + 'Z');
}
```

## Debugging Strategy

When investigating timestamp offset bugs:

1. **Add diagnostic print to the date parser** with raw value, isUtc flag, and
   converted result
2. **Check if offset matches server timezone** — if consistent, it's this bug
3. **For path-dependency packages**: Changes require `flutter clean && flutter run`,
   NOT hot restart. Hot restart only picks up changes in the app's own `lib/` code.
4. **Check the backend's `php.ini`** for `date.default_timezone` setting

## Verification

After applying the fix:

1. The diagnostic print should show:
   ```
   raw="2026-02-20T20:03:25+00:00" parsed.isUtc=true
   utc=2026-02-20 20:03:25.000Z local=2026-02-20 14:03:25.000
   ```
2. The `local` time should differ from the `raw` time by the user's timezone offset
3. Displayed times should match the user's wall clock

## Example

Real debugging session that uncovered this bug:

1. User reports: "Still seeing 8:03 when I should see 2:03" (6-hour offset = CST)
2. First hypothesis: Backend sends bare UTC, Dart treats as local → fix: re-interpret as UTC
3. Fix applied but not picked up (path dependency, needed `flutter clean`)
4. After clean rebuild, diagnostic print reveals: `raw="2026-02-20T20:03:25-06:00"`
5. Backend IS sending timezone — but it's WRONG (UTC value labeled as CST)
6. Root cause: PHP `new DateTime($utcString)` without `DateTimeZone('UTC')`
7. Dual fix: backend adds UTC timezone, client strips timezone suffix as safety net

## Notes

- This is NOT specific to PHP — any backend that stores UTC and formats with
  `date_default_timezone` can produce the same bug
- The offset is always consistent (same for every timestamp), which distinguishes
  it from DST-related bugs (where only some timestamps are off)
- MySQL `DATETIME` columns are timezone-naive — the application must track whether
  values are UTC or local. `TIMESTAMP` columns auto-convert but have a 2038 limit.
- Consider setting `date_default_timezone_set('UTC')` globally in PHP to prevent
  this class of bugs entirely

## References

- [PHP DateTime constructor timezone parameter](https://www.php.net/manual/en/datetime.construct.php)
- [PHP date_default_timezone_set](https://www.php.net/manual/en/function.date-default-timezone-set.php)
- [Dart DateTime.parse timezone handling](https://api.dart.dev/stable/dart-core/DateTime/parse.html)
- [ISO 8601 timezone designators](https://en.wikipedia.org/wiki/ISO_8601#Time_zone_designators)
