Time tracking for lawyers is not a convenience feature — it is the primary input to billing. This article covers the start/stop timer with drift correction, linking work logs to matters, the approval workflow, and the deduplication check that prevents double-billing.
Start/Stop Timer with Drift Correction
class TimeClock
{
public function start(int $userId, int $matterId, string $description, PDO $pdo): int
{
// Check for an already-running timer (no open timers allowed)
$running = $pdo->prepare('SELECT id FROM work_logs WHERE user_id = ? AND ended_at IS NULL AND deleted_at IS NULL');
$running->execute([$userId]);
if ($running->fetch()) {
throw new \RuntimeException('A timer is already running. Stop it before starting a new one.');
}
$stmt = $pdo->prepare(
'INSERT INTO work_logs (team_id, matter_id, user_id, description, started_at, status)
VALUES (?, ?, ?, ?, NOW(), \'draft\')'
);
$stmt->execute([$this->teamId, $matterId, $userId, $description]);
return (int) $pdo->lastInsertId();
}
public function stop(int $workLogId, int $userId, PDO $pdo): array
{
$stmt = $pdo->prepare(
'SELECT * FROM work_logs WHERE id = ? AND user_id = ? AND ended_at IS NULL AND deleted_at IS NULL'
);
$stmt->execute([$workLogId, $userId]);
$log = $stmt->fetch();
if (!$log) {
throw new \RuntimeException('No running timer found for this user.');
}
// Compute duration with server-side timestamps to avoid client clock drift
$pdo->prepare(
'UPDATE work_logs
SET ended_at = NOW(),
duration_min = TIMESTAMPDIFF(MINUTE, started_at, NOW())
WHERE id = ?'
)->execute([$workLogId]);
return $pdo->query("SELECT * FROM work_logs WHERE id = {$workLogId}")->fetch();
}
}
The Approval Workflow
Work logs flow through: draft → pending_review → approved → applied (linked to a billing entry). Rejections send the log back to draft with a rejection reason. Only approved logs can be converted to billing entries.
public function submitForReview(int $workLogId, PDO $pdo): void
{
$pdo->prepare(
'UPDATE work_logs SET status = \'pending_review\' WHERE id = ? AND status = \'draft\''
)->execute([$workLogId]);
}
public function approve(int $workLogId, int $approverId, PDO $pdo): void
{
// Dedup check: has this time block already been billed?
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM billing_entries
WHERE work_log_id = ? AND team_id = ?'
);
$stmt->execute([$workLogId, $this->teamId]);
if ($stmt->fetchColumn() > 0) {
throw new \RuntimeException('This work log is already linked to a billing entry.');
}
$pdo->prepare(
'UPDATE work_logs SET status = \'approved\', approved_by = ?, approved_at = NOW() WHERE id = ?'
)->execute([$approverId, $workLogId]);
}
What to Watch For
- Time zone handling —
NOW()uses the MySQL server's timezone. If your server is UTC but lawyers are in IST, store all timestamps in UTC and display in the user's timezone at the application layer. - Minimum billable unit — Many law firms bill in 6-minute increments. Apply the minimum unit rounding at the billing entry creation step, not the work log step, so the raw time data is preserved.
- Concurrent timers — The check for running timers must be atomic. Use a SELECT FOR UPDATE or a unique constraint on (user_id, ended_at IS NULL) to prevent race conditions.