Starting a Toggl Timer from the Command Line with Laravel Zero

Start a Toggl timer tied to the correct project and client based on the current directory. Right from the command line.

Joe Tannenbaum

This is the first in a series I'm starting of useful commands I've built for myself as part of my day-to-day workflow. I hope you find them useful!

A long time ago, I started a little repo called util. It was a basic Laravel installation that housed all of the custom commands that I run to grease the wheels of my day-to-day workflow. Like many developers, if I do the same thing more than three times, there's a high probability I'll write some code to never do it again.

The repo was a bit outdated, so I recently decided to move the commands over to a fresh Laravel Zero installation. Along the way, I'll be writing up a short post about each command, so feel free to follow along.

One of those repetitive tasks was timing my work. I'm a freelance full-stack developer, so running a Toggl timer is a near-constant in my life. They have a desktop app, which is amazing, but going up to the menu bar, clicking the icon, selecting a client, choosing a title, hitting start... it gets tedious. I had a different idea:

Every time I dive into a project to continue working on it, it starts from the terminal. I head to the project directory and fire off some custom commands to start development on the project (more on that later). The dream was that I would simply type timer and the command would know which project/client the directory was associated with and simply start timing with a reasonable title for what I was working on.

Designing the Command

So given that we want the command to automatically know which client and project the directory was associated with, we're going to need some sort of config file. I thought about housing all of the config files in the util repo but realized that if I wanted to change something about the config for that project while I was working on it, I didn't want to head to another directory to do so.

So I added toggl-config.json to my global .gitignore, that way I didn't have to deal with ignoring it on a repo-to-repo basis.

Great! We've got a config file... name. So what values do we need in it?

project_id (int)
This is the Toggl project ID, which is in turn attached to a client.

based_on_branch (bool)
This flag tells me whether to base the title of the timer on the name of the current git branch. 97.23% of the time this is what I want, and for most clients this is how I work.

suffix (string/null)
If this is a client that I have multiple sub-projects for, we can automatically add a suffix to the timer title. e.g. "Client Retainer (Mobile App)"

Awesome, let's get into some code.

Writing the Command

First, let's have Laravel Zero scaffold the command for us (my app name is util, so if you're implementing this you should replace util with the app name specified in your Laravel Zero installation):

1php util make:command StartTimer

I've stripped out the schedule method since we won't be using it, here is the bare-bones command:

1<?php
2 
3namespace App\Commands;
4 
5use LaravelZero\Framework\Commands\Command;
6 
7class StartTimer extends Command
8{
9 /**
10 * The signature of the command.
11 *
12 * @var string
13 */
14 protected $signature = 'timer:start';
15 
16 /**
17 * The description of the command.
18 *
19 * @var string
20 */
21 protected $description = 'Start a Toggl timer';
22 
23 /**
24 * Execute the console command.
25 *
26 * @return mixed
27 */
28 public function handle()
29 {
30 }
31}

Interacting with the Toggl API

Let's pause here and create a quick Toggl class. We'll need this to interact with the Toggl API. This is pretty straightforward, so I'll just show you the code:

1<?php
2 
3namespace App\Services;
4 
5use Illuminate\Support\Collection;
6use Illuminate\Support\Facades\Http;
7 
8class Toggl
9{
10 protected $client;
11 
12 public function __construct()
13 {
14 $this->client = Http::withBasicAuth(env('TOGGL_API_KEY'), 'api_token')
15 ->baseUrl('https://api.track.toggl.com/api/v8/');
16 }
17 
18 public function stopCurrentlyRunningTimer()
19 {
20 $currentlyRunning = $this->client->get('time_entries/current')->json();
21 
22 if ($currentlyRunning['data'] !== null) {
23 $this->client->put(
24 sprintf(
25 'time_entries/%d/stop',
26 $currentlyRunning['data']['id']
27 )
28 );
29 }
30 }
31 
32 public function startTimer($description, $projectId)
33 {
34 return $this->client->post('time_entries/start', [
35 'time_entry' => [
36 'description' => $description,
37 'pid' => $projectId,
38 'created_with' => 'Joe Helper',
39 ],
40 ]);
41 }
42 
43 public function workspaceProjects($workspaceId): Collection
44 {
45 return collect($this->client->get(
46 sprintf(
47 'workspaces/%d/projects',
48 $workspaceId
49 )
50 )->json());
51 }
52 
53 public function workspaces(): Collection
54 {
55 return collect($this->client->get('workspaces')->json());
56 }
57}

Note that the TOGGL_API_KEY is being pulled from the .env (if you're following along you can find your API token in your profile settings) . Out of the box, Laravel Zero doesn't include support for environment variables, so we'll have to install that. We'll also need the HTTP client, so let's pull that in too:

1php util app:install dotenv
2php util app:install http

Ok, now that we have our Toggl class, let's inject it into our handle method and get started. We'll also make it a property on the class so that other methods can easily access it as well.

1<?php
2 
3namespace App\Commands;
4 
5use App\Services\Toggl;
6use LaravelZero\Framework\Commands\Command;
7 
8class StartTimer extends Command
9{ ...
10 
11 /**
12 * The signature of the command.
13 *
14 * @var string
15 */
16 protected $signature = 'timer:start';
17 
18 /**
19 * The description of the command.
20 *
21 * @var string
22 */
23 protected $description = 'Start a Toggl timer';
24 
25 protected Toggl $toggl;
26 
27 /**
28 * Execute the console command.
29 *
30 * @return mixed
31 */
32 public function handle(Toggl $toggl)
33 {
34 $this->toggl = $toggl;
35 }
36}

Creating and Loading the Config File

First, let's check if we have a config file in the current working directory. I use config files (and global git ignoring) all the time with my util commands, so I've created a simple helper class for this called, wait for it, ConfigFile.

First, it checks for the existence of a config file, if it finds one, it simply returns the JSON-decoded contents. If it doesn't, it accepts a second argument, a callable function to create the file. If anything is returned from the create function, it is JSON encoded and written to the file path.

It looks like this:

1<?php
2 
3namespace App\Config;
4 
5class ConfigFile
6{
7 public static function get(string $filename, callable $createIfMissing, string $dir = null): array | false
8 {
9 $path = self::path($filename, $dir);
10 
11 if (file_exists($path)) {
12 return json_decode(file_get_contents($path), true);
13 }
14 
15 $data = $createIfMissing($path);
16 
17 if (!$data) {
18 return false;
19 }
20 
21 file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
22 
23 return self::get($filename, $createIfMissing, $dir);
24 }
25 
26 public static function path(string $filename, string $dir = null): string
27 {
28 $dir = $dir ?: exec('pwd');
29 
30 return "{$dir}/{$filename}";
31 }
32}

Great! Let's use it in our StartTimer command:

1<?php
2 
3namespace App\Commands;
4 
5use App\Services\Toggl;
6use LaravelZero\Framework\Commands\Command;
7use App\Config\ConfigFile;
8 
9class StartTimer extends Command
10{ ...
11 
12 /**
13 * The signature of the command.
14 *
15 * @var string
16 */
17 protected $signature = 'timer:start';
18 
19 /**
20 * The description of the command.
21 *
22 * @var string
23 */
24 protected $description = 'Start a Toggl timer';
25 
26 protected Toggl $toggl;
27 
28 /**
29 * Execute the console command.
30 *
31 * @return mixed
32 */
33 public function handle(Toggl $toggl)
34 {
35 $this->toggl = $toggl;
36 
37 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);
38 
39 if (!$config) {
40 // Decided not to create config, abort
41 return;
42 }
43 }
44}

We're referencing a createConfig method here, so let's see what that does. My general rule is that if a config file doesn't exist, walk me through creating it right then and there, don't make me create it from scratch. So I prompt myself through some questions and create the config from the answers:

1<?php
2 
3namespace App\Commands;
4 
5use App\Services\Toggl;
6use LaravelZero\Framework\Commands\Command;
7use App\Config\ConfigFile;
8 
9class StartTimer extends Command
10{ ...
11 
12 /**
13 * The signature of the command.
14 *
15 * @var string
16 */
17 protected $signature = 'timer:start';
18 
19 /**
20 * The description of the command.
21 *
22 * @var string
23 */
24 protected $description = 'Start a Toggl timer';
25 
26 protected Toggl $toggl;
27 
28 /**
29 * Execute the console command.
30 *
31 * @return mixed
32 */
33 public function handle(Toggl $toggl)
34 {
35 $this->toggl = $toggl;
36 
37 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);
38 
39 if (!$config) {
40 // Decided not to create config, abort
41 return;
42 }
43 }
44 
45 public function createConfig($path)
46 {
47 $this->error('Config missing: ' . $path);
48 
49 if (!$this->confirm('Create config file?', true)) {
50 return;
51 }
52 
53 $workspaces = $this->toggl->workspaces();
54 
55 $workspaceName = $this->choice('Workspace', $workspaces->pluck('name')->toArray());
56 $workspace = $workspaces->first(fn ($item) => $item['name'] === $workspaceName);
57 
58 $projects = $this->toggl->workspaceProjects($workspace['id']);
59 
60 $projectName = $this->choice('Project', $projects->pluck('name')->toArray());
61 $project = $projects->first(fn ($item) => $item['name'] === $projectName);
62 
63 $suffix = $this->ask('Is this a client with multiple projects? If so, what is this project? (Add a suffix?)');
64 
65 $basedOnBranch = $this->confirm('Base on Branch', true);
66 
67 return [
68 'project_id' => $project['id'],
69 'suffix' => $suffix ?: null,
70 'based_on_branch' => $basedOnBranch,
71 ];
72 }
73}

Ok, we're cooking now! We have a config file, let's get to the meat and potatoes of the command. The logic itself is simple:

If the config is not based on a git branch: Ask me for the title of what I'm timing.

If the config is based on a git branch:

  • First, title case it.

  • I often name branches based on the issue number in whatever tracking system the client uses (e.g. 1413-user-settings-page-checkbox-broken). If we detect that it starts with a number, format the title as "#1413: User Settings Page Checkbox Broken"

  • If we have a suffix, tack that onto the end of the title

Then:

  • Stop any current timers we have running, if any

  • Start a timer for this project with our title

1<?php
2 
3namespace App\Commands;
4 ...
5use App\Config\ConfigFile;
6use App\Services\Toggl;
7use Illuminate\Support\Arr;
8use LaravelZero\Framework\Commands\Command;
9 
10class StartTimer extends Command
11{ ...
12 
13 /**
14 * The signature of the command.
15 *
16 * @var string
17 */
18 protected $signature = 'timer:start';
19 
20 /**
21 * The description of the command.
22 *
23 * @var string
24 */
25 protected $description = 'Start a Toggl timer';
26 
27 protected Toggl $toggl;
28 
29 /**
30 * Execute the console command.
31 *
32 * @return mixed
33 */
34 
35 public function handle(Toggl $toggl)
36 {
37 $this->toggl = $toggl;
38 
39 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);
40 
41 if (!$config) {
42 // Decided not to create config, abort
43 return;
44 }
45 
46 $timerTitle = null;
47 
48 if (Arr::get($config, 'based_on_branch') === false) {
49 $timerTitle = $this->ask('What are you timing (leave blank to base on branch)');
50 }
51 
52 if (!$timerTitle) {
53 $timerTitle = Str::of(exec('git rev-parse --abbrev-ref HEAD'))
54 ->title()
55 ->replace('-', ' ')
56 // If the string starts with digits, it's probably an issue number, format it
57 ->pipe(fn ($s) => preg_replace('/^(\d+)/', '#$1:', $s))
58 ->toString();
59 }
60 
61 if (Arr::get($config, 'suffix')) {
62 $timerTitle = sprintf('%s (%s)', $timerTitle, $config['suffix']);
63 }
64 
65 $this->toggl->stopCurrentlyRunningTimer();
66 
67 $this->toggl->startTimer($timerTitle, $config['project_id']);
68 } ...
69 
70 public function createConfig($path)
71 {
72 $this->error('Config missing: ' . $path);
73 
74 if (!$this->confirm('Create config file?', true)) {
75 return;
76 }
77 
78 $workspaces = $this->toggl->workspaces();
79 
80 $workspaceName = $this->choice('Workspace', $workspaces->pluck('name')->toArray());
81 $workspace = $workspaces->first(fn ($item) => $item['name'] === $workspaceName);
82 
83 $projects = $this->toggl->workspaceProjects($workspace['id']);
84 
85 $projectName = $this->choice('Project', $projects->pluck('name')->toArray());
86 $project = $projects->first(fn ($item) => $item['name'] === $projectName);
87 
88 $suffix = $this->ask('Is this a client with multiple projects? If so, what is this project? (Add a suffix?)');
89 
90 $basedOnBranch = $this->confirm('Base on Branch', true);
91 
92 return [
93 'project_id' => $project['id'],
94 'suffix' => $suffix ?: null,
95 'based_on_branch' => $basedOnBranch,
96 ];
97 }
98}

And that's it! That's the whole command. Let's see it in its entirety:

1<?php
2 
3namespace App\Commands;
4 
5use App\Config\ConfigFile;
6use App\Services\Toggl;
7use Illuminate\Support\Arr;
8use Illuminate\Support\Str;
9use LaravelZero\Framework\Commands\Command;
10 
11class StartTimer extends Command
12{
13 /**
14 * The signature of the command.
15 *
16 * @var string
17 */
18 protected $signature = 'timer:start';
19 
20 /**
21 * The description of the command.
22 *
23 * @var string
24 */
25 protected $description = 'Start a Toggl timer based on the current branch';
26 
27 protected Toggl $toggl;
28 
29 /**
30 * Execute the console command.
31 *
32 * @return mixed
33 */
34 public function handle(Toggl $toggl)
35 {
36 $this->toggl = $toggl;
37 
38 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);
39 
40 if (!$config) {
41 // Decided not to create config, abort
42 return;
43 }
44 
45 $timerTitle = null;
46 
47 if (Arr::get($config, 'based_on_branch') === false) {
48 $timerTitle = $this->ask('What are you timing (leave blank to base on branch)');
49 }
50 
51 if (!$timerTitle) {
52 $timerTitle = Str::of(exec('git rev-parse --abbrev-ref HEAD'))
53 ->title()
54 ->replace('-', ' ')
55 // If the string starts with digits, it's probably an issue number, format it
56 ->pipe(fn ($s) => preg_replace('/^(\d+)/', '#$1:', $s))
57 ->toString();
58 }
59 
60 if (Arr::get($config, 'suffix')) {
61 $timerTitle = sprintf('%s (%s)', $timerTitle, $config['suffix']);
62 }
63 
64 $this->toggl->stopCurrentlyRunningTimer();
65 
66 $this->toggl->startTimer($timerTitle, $config['project_id']);
67 }
68 
69 public function createConfig($path)
70 {
71 $this->error('Config missing: ' . $path);
72 
73 if (!$this->confirm('Create config file?', true)) {
74 return;
75 }
76 
77 $workspaces = $this->toggl->workspaces();
78 
79 $workspaceName = $this->choice('Workspace', $workspaces->pluck('name')->toArray());
80 $workspace = $workspaces->first(fn ($item) => $item['name'] === $workspaceName);
81 
82 $projects = $this->toggl->workspaceProjects($workspace['id']);
83 
84 $projectName = $this->choice('Project', $projects->pluck('name')->toArray());
85 $project = $projects->first(fn ($item) => $item['name'] === $projectName);
86 
87 $suffix = $this->ask('Is this a client with multiple projects? If so, what is this project? (Add a suffix?)');
88 
89 $basedOnBranch = $this->confirm('Base on Branch', true);
90 
91 return [
92 'project_id' => $project['id'],
93 'suffix' => $suffix ?: null,
94 'based_on_branch' => $basedOnBranch,
95 ];
96 }
97}

Finally, I alias the command as timer in my .zshrc:

1helperPHPPath="/opt/homebrew/Cellar/php/8.1.10_1/bin/php"
2alias timer="$helperPHPPath ~/Dev/util-zero/util timer:start"

Just over 200 lines of code that have saved me countless hours over the years. Let's see it in action:

Thanks for following along! Stay tuned for more helpful commands as I continue the series.

(Excellent) syntax highlighting provided by Torchlight