Building a YNAB CLI in Laravel


I've been using You Need a Budget (YNAB) since 2019 and love it. I'm in the budget daily, entering transactions, tinkering, and ensuring my family is on track with our financial goals and current priorities. However, as the transactions and payees pile up over the years, the web UI has become slower and slower. It's not that bad, but when I only want to enter a simple transaction or check on the current amount for a category I don't always want to wait 20-30 seconds for the entire web UI to load.
I used to use a fantastic Chrome extension called Sprout for entering transactions, but unfortunately the developer took the extension off the Chrome store and stopped development. I wanted something that could be equally as fast and easy to use.
Over this blog series, we'll utilize the PHP Laravel framework and its built-in tools to build a robust CLI application and interface with YNAB's API.
The CLI functionality will not replace the excellent web UI but only introduce faster ways to alleviate everyday tasks to go about our day. It's a perfect fit since I'm usually in my terminal during the day.
Before we begin, if you aren't a YNAB user, please sign up. Using my referral link will give you a 34 day trial and if you decide to purchase a subscription, YNAB will give us both a free month!
Set up Laravel
To get started, install Laravel from the docs. I usually use the Laravel installer, but do whatever works best for you. At the time of writing, this will install a new Laravel 11 application.
laravel new ynab
cd ynab
Because we want an easy to use interface within the CLI, we'll also want to install Laravel Prompts.
composer require laravel/prompts
Next, we'll want to add config values for our YNAB API key and the default budget ID that we'll want to utilize. Right now, I'm only setting this up as a single budget, but in the future we could make this more configurable, or you can do this on your own in your own application.
Within .env
and .env.example
, add
YNAB_TOKEN=
YNAB_BUDGET_ID=
Within config/services.php
, add
'ynab' => [
'token' => env('YNAB_TOKEN'),
'budget_id' => env('YNAB_BUDGET_ID'),
],
To get a YNAB API key, go into your Account Settings
, scroll toward the bottom and within the Developer Settings
click the Developer Settings
button. On the next page, under Personal Access Tokens
, click the New Token
button. This will reveal your API token that you can paste into your .env
file.
The budget id is the UUID in the URL when viewing your budget in the YNAB web UI, so you can copy/paste if from there, or get it from the API (/budgets
).
Create Transaction Command
Next, we'll want to set up our initial artisan command, so we'll run php artisan make:command CreateTransaction
, which will create app/Console/Commands/CreateTransaction.php
. Then, we'll stub out the initial command class.
class CreateTransaction extends Command
{
protected $signature = 'ynab:transaction';
protected $description = 'Creates a new transaction in YNAB';
public function handle(): int
{
return self::SUCCESS;
}
Before we get too far, let's set up a shared trait for the HTTP client to be shared across all commands, since we'll be creating more commands in the next blog posts. Create app/Console/Commands/Concerns/Ynab.php
with the following code
<?php
declare(strict_types=1);
namespace App\Console\Commands\Concerns;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
trait Ynab
{
protected function getClient(): PendingRequest
{
return Http::baseUrl('https://api.ynab.com/v1/')
->acceptJson()
->asJson()
->throw()
->withToken(config('services.ynab.token'));
}
}
This helper method will allow us to remove the boilerplate of setting up the YNAB client and set up JSON handling, throw an exception if there's an API error, and use the token from the config file for authentication.
Then, we'll include that in the transaction command class
class CreateTransaction extends Command
{
+ use Concerns\Ynab;
+
protected $signature = 'ynab:transaction';
protected $description = 'Creates a new transaction in YNAB';
Similar to the Sprout UI, we'll want to build a CLI to handle:
Amount
Account
Payee
Category
Optional memo
Optional flag
Optional cleared/uncleared
For now, we'll skip date (we'll assume today's date), approval (default to true), split transactions, and other options found in the Sprout and YNAB UIs. For reference, here is the Sprout UI.
Gathering Budget Data
Before we start building our CLI UI, we'll need to get initial budget data from YNAB. You can find the API docs here. For now, we'll want to gather
Accounts
Payees
Categories
So, we'll stub those out in our command
class CreateTransaction extends Command
{
use Concerns\Ynab;
protected $signature = 'ynab:transaction';
protected $description = 'Creates a new transaction in YNAB';
public function handle(): int
{
+ $accounts = $this->getAccounts();
+ $payees = $this->getPayees();
+ $categories = $this->getCategories();
+
return self::SUCCESS;
}
Getting Budget Accounts
public function getAccounts(): Collection
{
$response = $this->getClient()->get('budgets/' . config('services.ynab.budget_id') . '/accounts');
return collect($response->json('data.accounts'))
->filter(fn($account) => $account['on_budget'] && !$account['closed'])
->mapWithKeys(fn($account) => [$account['id'] => $account['name']]);
}
In this step, we're
Getting the raw response from the
/accounts
endpoint from the APIGrabbing the data from the response under
data.accounts
Filtering for only accounts "on budget" and not closed. On budget means that the money is used for specific budgeting purposes. Alternatively, there are "tracking" accounts that don't impact your budget positively or negatively.
Creating a new collection of account IDs matching their name
Getting Budget Payees
public function getPayees(): Collection
{
$response = $this->getClient()->get('budgets/' . config('services.ynab.budget_id') . '/payees');
return collect($response->json('data.payees'))
->reject(fn($payee) => $payee['deleted'])
->mapWithKeys(fn($payee) => [$payee['id'] => $payee['name']]);
}
In this step, we're
Getting the raw response from the
/payees
endpoint from the APIGrabbing the data from the response under
data.payees
Removing any deleted payees
Creating a new collection of payee IDs matching their name
Getting Budget Categories
This step is a little more involved because the /categories
endpoint has top-level Category Groups, then includes the categories data within each group. You can see the schema for CategoryGroupWithCategories
here.
public function getCategories(): Collection
{
$response = $this->getClient()->get('budgets/' . config('services.ynab.budget_id') . '/categories');
return collect($response->json('data.category_groups'))
->reject(fn($categoryGroup) => $categoryGroup['hidden'] || $categoryGroup['deleted'])
->pluck('categories')
->flatten(1)
->reject(fn($category) => $category['hidden'] || $category['deleted'] || $category['category_group_name'] === 'Credit Card Payments' || $category['category_group_name'] === 'Internal Master Category')
->mapWithKeys(fn($category) => [$category['id'] => $category['category_group_name'].': '.$category['name']]);
}
In this step, we're
Getting the raw response from the
/categories
endpoint from the APIGrabbing the data from the response under
data.category_groups
Removing hidden and deleted category groups
Grabbing the
categories
array from each groupFlattening the collection so all of the categories are in the same root of the collection
Removing any categories that are hidden, deleted, or match specific YNAB-generate categories we don't need
Creating a new collection of category IDs matching the category group name with the category name
Whew, we did it! Now, we'll move on to the Laravel Prompts UI.
Leveraging Laravel Prompts for the CLI UI
Prompts has numerous options for CLI inputs. These can either be one-off or chained together into a form. A form enables us to have multiple questions and inputs while collecting the responses to be used later. Since we'll have a number of questions to ask, we'll be using a form.
$responses = form()
->text('Amount', required: true, name: 'amount')
->select('Account', options: $accounts, required: true, name: 'account')
->search(
label: 'Payee',
options: fn (string $value) => strlen($value) > 0
? $payees->filter(fn ($payee) => Str::contains($payee, $value, true))->all()
: [],
name: 'payee'
)
->search(
label: 'Category',
options: fn (string $value) => strlen($value) > 0
? $categories->filter(fn ($category) => Str::contains($category, $value, true))->all()
: [],
name: 'category'
)
->text('Memo (optional)', name: 'memo')
->select('Flag color (optional)', options: ['none', 'red', 'orange', 'yellow', 'green', 'blue', 'purple'], name: 'flag_color')
->select('Cleared', options: ['cleared', 'uncleared'], default: 'uncleared', name: 'cleared')
->submit();
There's a lot to unpack here, but it should be straight forward
We'll ask for the amount of the transaction - either positive (income) or negative (expense)
Select the account from a
select
prompt loaded with our accounts from the APIA search box for the payee loaded from the API. As new characters are added, the search suggest auto-updates and allows you to arrow up/down to select the payee. The
options
function searches and returns the values the match the input given.Similar to the payees, the categories work the same way with a search suggest
Memo is a simple text field and also optional
Flag color is also optional using a select, like accounts. YNAB allows you to use their default flags, or change them in the UI. I only use these occasionally.
Cleared or uncleared transaction using another select. If I already know the transaction has cleared the bank/card, then I'll change to
cleared
but otherwise most transactions are new and haven't hit the account yet, so we'll default touncleared
Finally, submit the responses
Creating the new transaction via the API
The Laravel Prompts form
will return an array of $responses
that will contain the key/value from what's provided in each input's name. We'll use the /transactions
endpoint to combine our data and use some sensible defaults for other transaction-related data points.
$response = $this->getClient()->post('budgets/' . config('services.ynab.budget_id') . '/transactions', [
'transaction' => [
'account_id' => $responses['account'],
'payee_id' => $responses['payee'],
'category_id' => $responses['category'],
'amount' => $responses['amount'] * 1000,
'memo' => $responses['memo'],
'flag_color' => $responses['flag_color'] === 'none' ? null : $responses['flag_color'],
'cleared' => $responses['cleared'],
'date' => now()->toDateString(),
'approved' => true,
],
]);
The account will be the account's uuid from YNAB
The payee will be the payee's uuid
The category will be the category's uuid
The amount (positive or negative) needs to be converted to what YNAB calls
milliunits
or 3 decimal places, so we'll multiply our amount by 1,000 (docs)Optional memo text
Optional flag color, or converting
none
tonull
in case a color isn't specifiedCleared value
Current date - I usually add transactions as they happen, so we'll default to the current date. We might make this configurable in the future.
Approved defaulted to
true
- YNAB will highlight any new transactions via their auto-import process which you can either approve or delete. However, for the CLI's purposes, I'm entering the transaction manually, I'm approving at the same time.
Handling API responses
At this point, I'm not too worried about responses and everything has been working smoothly so far. We'll expand on this in the future, but again, we'll keep things simple right now.
if ($response->successful()) {
$this->info('Expense created successfully');
} else {
$this->error('Failed to create expense - ' . $response->json('error.detail'));
}
return self::SUCCESS;
Wrapping Up
I hope you enjoyed this quick journey through setting up a fresh, new, Laravel application with Prompts and the YNAB API. As stated earlier, I'll be adding more blog posts to this series as I work through other command and refactorings along the way. I'm sure you can already see some areas that could use improvement. Further, the YNAB API offers some interesting options like Delta Requests, Rate Limiting, and better error handling that we can explore later as well.
Live Demo
Check out the repo on GitHub
Listen to the podcast episode
Subscribe
Please subscribe to my newsletter, RSS feed, podcast, or the GH repo to follow along for more content!
Join the YNAB Family
Signing up using my referral link will give you a 34 day trial and if you decide to purchase a subscription, YNAB will give us both a free month!
Subscribe to my newsletter
Read articles from Chris Gmyr directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Chris Gmyr
Chris Gmyr
Husband, dad, & grilling aficionado. Loves Laravel & coffee. Staff Engineer @ Curology, and TrianglePHP Co-organizer