$ cat work/budgetgen.md
BudgetGen - Smart Finance Manager
A self-hosted personal-finance manager in Laravel 12 and Filament 4: thirteen domain models covering budgets, loans, cards, investments and goals - designed, built, and shipped solo.
PHP 8.4 · Laravel 12 · FilamentPHP 4 · Livewire · SQLite · Pest
$ metrics
The problem
Most budgeting apps are either spreadsheets with extra steps or subscription services that want your bank credentials. I wanted a self-hosted tool where a household can set monthly budgets per category, log expenses in seconds, and see where the money actually goes - without handing financial data to a third party. The second requirement is the one that shaped the build: it had to model real Indian household finance, which is not just income and expenses. It is EMIs, credit-card dues, LIC premiums, SIPs, and tax-saving deductions.
The build
Laravel 12 with FilamentPHP 4 as the entire interface. That is the decision that saved the project. This is a forms-and-tables product for one household - building a bespoke UI for thirteen resources would have been weeks of CRUD screens that Filament generates from the model definitions. The two Blade files left in the repo are the untouched default scaffold page and the layout shell; Filament renders the login screen from its own vendor views.
Thirteen domain models across eighteen migrations, one per money concept rather than one generic Transaction table with a type column: Loan, CreditCard, CreditCardDues, Investment, Insurance, TaxSavingPlan, Goal, GoalContribution, RecurringPayment, RecurringPaymentSchedule, MonthlyBudget, Category, Transaction. A loan has a principal, an interest rate and an EMI schedule; an insurance policy has a premium cadence and a maturity date. Collapsing those into one table means every query starts with a filter and every form starts with a conditional.
SQLite is the default connection, not a placeholder. For a single-household self-hosted app, the operational cost of running MySQL is the whole reason someone gives up and goes back to a spreadsheet. One file, backed up by copying it.
Two decisions worth stealing
Money is never a float. `MoneyCast` stores every amount as an integer number of paise and converts on read, so nothing in the app ever adds two floats together and gets 0.30000000000000004. Models opt in by listing their money columns in a `$moneyFields` array, and `HasMoneyCasts` merges those into Eloquent's casts at boot - so adding a money column is one array entry, not a cast declaration someone forgets.
Tenancy is a trait, not a habit. `HasUserScope` does two things in one place: it stamps `user_id` on create, and it adds a global scope filtering every query to the authenticated user. Both halves live together on purpose - a model cannot end up with the write half and not the read half, which is the version of this bug that leaks one user's data to another.
// app/Traits/HasUserScope.php - both halves, one trait
static::creating(function ($model): void {
if (Auth::check() && empty($model->user_id)) {
$model->user_id = Auth::id();
}
});
static::addGlobalScope('user_scope', function (Builder $builder): void {
if (Auth::check()) {
$builder->where('user_id', Auth::id());
}
});What I would do differently
The `Auth::check()` guard in `HasUserScope` is doing something quieter than it looks. When there is no authenticated user - an artisan command, a queued job, a seeder - the global scope does not apply at all, and a query that looks scoped returns every row in the table. That is correct for seeding and wrong for anything else. In a single-user self-hosted app it has never bitten me, and it is exactly the kind of thing that stops being harmless the moment a second user exists. A `withoutTenancy()` escape hatch plus a scope that fails closed would be the honest version.
`Loan::$moneyFields` includes `interest_rate` alongside `principal_amount` and `emi_amount`. An interest rate is not money. It round-trips correctly today only because `MoneyCast` is a bare multiply-and-divide by 100, so 8.5 percent is stored as 850 and read back as 8.5. The moment that cast learns anything about currency - formatting, a symbol, a rounding rule - the interest rate silently becomes wrong. It should be a decimal cast.
Three test files against 114 application PHP files. Pest, Larastan and Rector are all configured and the static analysis does real work, but the test suite covers a fraction of thirteen models' worth of money arithmetic. For a personal project I traded that away knowingly. I would not make the same trade on a client codebase, and I say so on the call rather than after the invoice.
The outcome
In daily use for real household budgeting since launch. Its more useful second life is as a reference implementation: the money-cast pattern and the tenancy trait both came out of this codebase and now show up in client work, and the honest read of its test coverage is the example I use when a client asks why I quote time for tests.