A single-user cost/expense tracker you can self-host. Snap a receipt on the phone, have it logged in two taps; year-end is a CSV export mapped to your accountant's nominal codes. Built for Beyond Develop Ltd, open-sourced under the MIT licence for anyone who wants the same.
Laravel 13 · PHP 8.3 · SQLite · Breeze (Blade) · Tailwind · Alpine.
Everything the old "Accounts Tracker" spreadsheet did, plus receipt capture:
- Three ledgers sharing one shape (single-table inheritance on
transactions): Expenses, Income and Cash flows (share capital / director loans). CRUD, filters, GBP totals and CSV export per ledger. Amounts are net + VAT; for non-GBP entries give either an FX rate or the actual GBP charged from the statement — the other is derived. - Capture (
/capture): tap one takes the photo — a draft expense is created and the image goes through a queued OCR job (Google Vision, or local tesseract as the free fallback); tap two confirms the pre-filled form. Installs to the phone as a PWA (start_urlis the capture screen). - Reports (
/reports): the Summary sheet — per-year Income / Expenses / Profit / Cash flows, expense matrices by report group (accountant view), by category and by payment method, with a personal-cards (to reimburse) subtotal. - Balances (
/balances): snapshot balances per account+currency, pivoted accounts × dates — the Balances sheet. - Ledger (
/ledger): any payment method + currency as a statement with a running balance in the original currency — the PayPal USD/EUR sheets, generalised. - Categories are two-level: entry-level name (e.g. "Website Expenses")
plus a
report_grouprollup ("Advertising and PR") and anaccount_codefor the accountant's chart. - Receipts: many per expense, stored on the private local disk
(
storage/app/private), served only through an authed route. - Money is integer pence everywhere. Floats never touch storage.
- No soft deletes — records are kept forever; deleting a transaction is a deliberate, permanent act (the six-year record trail relies on simply never deleting).
php artisan ledger:import-tracker <dir> loads CSVs exported from the
spreadsheet (income, expenses, cash flows, balances) and prints per-ledger
totals to verify against the workbook. The export script lives in the
session scratchpad (export_csv.py); it reads a copy of the xlsx and
never touches the original. Already run against the tracker as of
July 2026 — totals matched to the penny.
Reports → Export XLSX writes everything back into an .xlsx laid
out like the original spreadsheet. If your own template is at
storage/app/templates/tracker-template.xlsx (it's git-ignored, so it
stays on your server), the export uses it. Otherwise the export uses the
blank template bundled at resources/templates/tracker-template.xlsx,
which has the same sheets, tables and Summary formulas with plain styling.
To rebuild that template, run
python/venv/bin/python python/scripts/build_blank_template.py resources/templates/tracker-template.xlsx.
This app was called Docket until October 2026; the name now belongs to a separate read-later project. An existing install keeps working without changes, but tidy up when convenient:
- Git remote:
git remote set-url origin https://github.com/WebberZone/TheLedger.git. Do this before the new TheDocket repository exists, orgit pullwill fetch the wrong project. .env: renameDOCKET_*variables toLEDGER_*. The old names are still read as fallbacks.- Commands:
docket:install,docket:optimize,docket:ocr,docket:prune-capturesanddocket:import-trackerremain as aliases of theirledger:*replacements. APP_NAME: spatie/laravel-backup names its bucket folder afterAPP_NAME. Changing it fromDocketstarts a fresh folder, so move the existing archives across (or leaveAPP_NAMEalone) to keep retention andbackup:monitorworking on the full history. It also changes the session cookie name, so you will be logged out once.- Run
php artisan config:cacheafter deploying, as usual.
Requirements: PHP 8.3+ (with pdo_sqlite, mbstring, fileinfo),
Composer, Node 20+. Optional: tesseract or Google Vision credentials for
receipt OCR, Python 3 for the Accounts Tracker XLSX export.
Both installers do the same work — create .env, generate the app key,
create the SQLite database, run the migrations and create the single owner
account (there is no registration flow) — and both offer to seed a starter
taxonomy of categories, business units and payment methods you can rename
in Settings, or leave you to start empty.
git clone https://github.com/WebberZone/TheLedger.git ledger
cd ledger
composer install --no-dev
npm install && npm run buildPoint your web server at public/, then open https://your-domain/install
in a browser. The wizard checks the server requirements (PHP version,
extensions, writable directories), then asks for a setup code, your site
URL, name, email and password, and installs.
The setup code proves you control the server: anyone can find a fresh
deployment's /install, so it won't create the account without it. Read
it on the server once the setup page has loaded:
cat storage/app/install/setup-codeThe installer only exists while Ledger has no user account. Once your
login is created it leaves a marker (storage/app/install/installed) and
/install permanently redirects to the login screen. If the database
can't be read, it answers 503 rather than treating the site as fresh.
Ledger has exactly one account: neither installer, nor db:seed, will
create a second one, and db:seed refuses to create the first without
LEDGER_USER_PASSWORD set (there is no default password).
git clone https://github.com/WebberZone/TheLedger.git ledger
cd ledger
composer setup # deps + frontend build + guided php artisan ledger:installThe console installer is safe to re-run (--force) and never deletes data.
For scripted/non-interactive installs pass the answers as flags:
php artisan ledger:install --url=https://ledger.example.com \
--name="Jane Doe" --email=jane@example.com --password=... \
--no-interaction # omit --password to have one generated and shown onceThen, for local use:
php artisan serve # or use Herd/Valet
php artisan queue:work # needed for receipt OCR jobsFor receipt OCR set GOOGLE_VISION_CREDENTIALS in .env, or just install
tesseract for the free local fallback; with neither, uploads simply skip
extraction.
Tests: php artisan test. Dev server (app + queue + logs + Vite):
composer dev.
-
Nginx + PHP 8.3-FPM, standard Laravel vhost with root at
public/. Let's Encrypt viacertbot --nginx -d your-domain. HTTPS is required for the PWA install + camera capture. -
Visit
https://your-domain/install(or runphp artisan ledger:install) and give it the real production URL and your login details, then in.envsetAPP_ENV=production,APP_DEBUG=false,GOOGLE_VISION_CREDENTIALS(or install tesseract),BACKUP_*(see below), and a real mailer (MAIL_*) so backup-failure emails actually arrive. -
php artisan config:cache && php artisan route:cache && php artisan view:cache. -
Queue worker under systemd:
[Service] ExecStart=/usr/bin/php /var/www/ledger/artisan queue:work --tries=3 --max-time=3600 Restart=always User=www-data
-
One cron entry drives the Laravel scheduler (backups, cleanup, monitor, and the OCI keep-alive burn that stops idle reclaim):
* * * * * cd /var/www/ledger && php artisan schedule:run >> /dev/null 2>&1
Daily at 02:00 server time: SQLite is dumped to a file, zipped with the
receipts directory, verified, and shipped to the offsite disk — an
S3-compatible bucket that must live outside OCI (Backblaze B2 or
Cloudflare R2; the OCI account itself is the risk being insured against).
Retention: 14 daily / 8 weekly / 24 monthly / 7 yearly. backup:monitor
emails BACKUP_NOTIFY_EMAIL if the newest backup is older than a day.
Fill in .env:
BACKUP_ACCESS_KEY_ID=...
BACKUP_SECRET_ACCESS_KEY=...
BACKUP_BUCKET=ledger-backups
BACKUP_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
BACKUP_REGION=auto
The database in the archive is an SQL dump (db-dumps/*.sql, written by
sqlite3 .dump), not a database file: it has to be imported into a new
database. Do it on a copy of the server if you can:
php artisan backup:run # take one
# download the newest zip from the bucket, then:
unzip Ledger/….zip -d /tmp/restore
ls /tmp/restore/db-dumps/ # one .sql file
# Build a new database from the dump and check it.
sqlite3 /tmp/restore/restored.sqlite < /tmp/restore/db-dumps/*.sql
sqlite3 /tmp/restore/restored.sqlite "PRAGMA integrity_check;" # expect: ok
sqlite3 /tmp/restore/restored.sqlite "SELECT count(*) FROM transactions;"
# Swap it in with nothing writing: stop the worker and the site first.
php artisan down
sudo systemctl stop ledger-worker # whatever runs queue:work
mkdir -p /tmp/before-restore && cp database/database.sqlite* /tmp/before-restore/
mv /tmp/restore/restored.sqlite database/database.sqlite
rm -f database/database.sqlite-wal database/database.sqlite-shm
rsync -a /tmp/restore/…/storage/app/private/ storage/app/private/
chown -R www-data: database storage/app/private
php artisan migrate --force # if the backup predates a release
php artisan up
sudo systemctl start ledger-workerOpen the app, confirm a known expense and its receipt image are present.
The originals stay in /tmp/before-restore until you're satisfied.
Invoicing, bank feeds, double-entry, VAT/MTD, HMRC APIs. Company is not VAT-registered and outside MTD; this is record-keeping that feeds the CT600, nothing more.