DEV Community

Naveed Malik
Naveed Malik

Posted on AI-assisted

How I built a 50-state paycheck calculator in Laravel (with no database)

US payroll taxes look simple until you try to code them. Federal brackets are the easy part. Then you meet 50 states, plus DC, that each do something slightly different. Alabama lets you deduct the federal tax you paid. Pennsylvania taxes your 401(k) contributions. Ohio adds a fixed dollar amount once you pass a threshold. Utah gives a credit that shrinks as your income rises.

I wanted a free take-home pay calculator that handles all of that, shows its working, and loads fast. The result is NetPayMeter. This post covers how it's built: one tax engine, state rules as data, server-rendered results, and an embeddable widget.

The stack

  • Laravel 12 on PHP 8.3
  • No database. Every tax rule lives in PHP config files, and page copy lives in Markdown files.
  • Plain JavaScript for the front end, with no framework and no build step.
  • GitHub Actions runs the tests, builds a release tarball, and deploys it to a small VPS over SSH.

Skipping the database was the best decision in the project. Tax data changes once a year, it needs code review, and it's read-only at runtime. Config files give me all three for free: the diff in a pull request is the tax update.

One engine for everything

Every calculator on the site goes through one class, TaxEngine. That includes the paycheck, hourly, salary, overtime, W-4, 1099 and self-employment calculators. So do the worked examples printed in the page copy. If the engine is right, the whole site is right.

At its heart is a progressive-bracket function. Brackets are stored as [upper limit, rate] pairs, with null meaning "no limit":

public function bracketTax(float $income, array $brackets): array
{
    $tax = 0.0;
    $lower = 0.0;
    $rows = [];
    $marginal = $brackets[0][1] ?? 0;
    foreach ($brackets as [$upper, $rate]) {
        if ($income <= $lower) {
            break;
        }
        $top = $upper === null ? $income : min($income, $upper);
        $amount = $top - $lower;
        $part = $amount * $rate;
        $tax += $part;
        $rows[] = ['from' => $lower, 'to' => $top, 'rate' => $rate, 'amount' => $amount, 'tax' => $part];
        $marginal = $rate;
        if ($upper === null) {
            break;
        }
        $lower = $upper;
    }

    return ['tax' => $tax, 'marginal' => $marginal, 'rows' => $rows];
}
Enter fullscreen mode Exit fullscreen mode

It returns the rows as well as the total, so the results page can show a "how your tax was calculated" table. A flat-tax state is just a single bracket: [[null, .025]] is Arizona's 2.5%.

State rules as data, not code

The tricky part is the state-by-state quirks. Writing 51 if branches would have been unmaintainable. Instead, each state is an array in config/states.php, and the engine understands a small set of optional keys:

Key What it handles Example
brackets Progressive or flat rates, per filing status Every state with an income tax
base => 'federal_taxable' State starts from federal taxable income Colorado, Iowa, Idaho
deducts_401k => false 401(k) contributions are taxed Pennsylvania
std_phaseout Standard deduction shrinks as income rises Alabama, South Carolina, Wisconsin
fed_tax_deduct Federal income tax is deductible (with caps and phase-outs) Alabama, Oregon, Missouri
exemption_tiers Exemption depends on income Ohio
credit_phaseout Credit shrinks as income rises Utah
extras Employee payroll contributions with caps State disability or family-leave programs

Here's Arizona, the simplest kind of state:

'arizona' => ['name' => 'Arizona', 'abbr' => 'AZ', 'ready' => true, 'type' => 'flat',
    'brackets' => ['single' => [[null, .025]]],
    'std' => ['single' => 16100, 'married' => 32200, 'hoh' => 24150], ...],
Enter fullscreen mode Exit fullscreen mode

And here's how the engine handles a deduction that phases out. It needs only a few lines, and those lines work for every state that uses the key:

// Standard deduction that shrinks as income rises (SC, WI): std − rate × (income − start).
$std = self::forFiling($st['std'] ?? 0, $f);
if (isset($st['std_phaseout'])) {
    $over = max(0, $base - self::forFiling($st['std_phaseout']['start'], $f));
    $std = max(self::forFiling($st['std_min'] ?? 0, $f), $std - $over * self::forFiling($st['std_phaseout']['rate'], $f));
}
Enter fullscreen mode Exit fullscreen mode

Adding a new quirk means adding one optional key and a few lines to the engine. Adding a new state means adding data only. Each state also has a ready flag: a page goes live, and into the sitemap, only after its numbers have been checked against that state's revenue department. Where the state publishes a withholding formula, the config links the official document (28 states so far).

The current split is 26 progressive, 16 flat-rate, and 9 with no wage income tax that all share one $none template. The no-tax states still matter: Washington has no income tax, but it does have two employee payroll contributions (Paid Family & Medical Leave and the WA Cares Fund), and the calculator includes both.

Tests are the published examples

The page copy includes worked examples ("$60,000 a year, paid biweekly, single filer..."). Those exact numbers are also the test suite:

public function test_paycheck_examples(): void
{
    $this->assertSame(1938.08, $this->net(['amount' => 60000]));
    $this->assertSame(1836.54, $this->net(['amount' => 60000, 'k401' => 5]));
    $this->assertSame(845.42, $this->net(['pay_type' => 'hourly', 'amount' => 25, 'frequency' => 'weekly']));
    // ...
}
Enter fullscreen mode Exit fullscreen mode

When next year's brackets land, the tests fail in exactly the places where the page copy also needs updating. It's a cheap way to keep the text and the maths in sync.

Results are server-rendered HTML fragments

There's no JSON API and no client-side tax logic. The form sends a GET request to /calc/{type}, and Laravel returns a rendered Blade partial:

public function calculate(Request $request, string $type)
{
    abort_unless(in_array($type, ['paycheck', 'federal', 'overtime', /* ... */], true), 404);
    $data = $this->calcData($request, $type, []);

    return response()->view("calculators.results.$type", $data)->header('X-Robots-Tag', 'noindex');
}
Enter fullscreen mode Exit fullscreen mode

A few lines of JavaScript debounce the inputs, call fetch(), and swap the HTML into the page. This has some nice side effects:

  • One source of truth. The tax maths exists only in PHP, so it can't drift from a JavaScript copy.
  • It works without JavaScript. The form is a normal GET form, so the full page renders server-side too.
  • Shareable results. Every calculation is a URL with query parameters.
  • The fragments send X-Robots-Tag: noindex, so search engines don't index thousands of near-duplicate result fragments.
  • The endpoint is rate-limited with Laravel's throttle:120,1 middleware.

All user input passes through one normaliser that clamps every number to a sane range (hours 0 to 100, salary up to $10M, and so on), so odd query strings can't produce odd results.

The embeddable widget uses Shadow DOM

I wanted other sites, such as blogs, relocation guides and HR pages, to be able to embed the calculator with two lines:

<div data-netpaymeter="paycheck"></div>
<script src="https://netpaymeter.com/widget.js" async></script>
Enter fullscreen mode Exit fullscreen mode

widget.js finds each data-netpaymeter element and attaches a shadow root where the browser supports it. It then fetches /widget/{key}, which returns the same calculator form with the site's CSS inlined. Before inlining, the server rewrites :root to :host so the CSS variables apply inside the shadow tree:

preg_replace(['/:root\b/', '/(^|\})\s*body\s*\{/'], [':host', '$1 .npw {'], $css)
Enter fullscreen mode Exit fullscreen mode

The Shadow DOM means the host page's CSS can't break the calculator, and the calculator's CSS can't leak out and break the host page. CORS is enabled only for the calc/* and widget/* paths, and only for GET.

One lesson learned: nginx caches .js files for a year, so widget.js has to stay backward compatible forever. There's a comment at the top of the file saying exactly that.

Deploys

A push to main runs PHPUnit, then builds a release tarball on GitHub's runners rather than on the server. It uploads the tarball over SSH (retrying if the connection drops), switches a symlink, and runs a public health check against /, /sitemap.xml and /robots.txt. A manual workflow input rolls back to the previous release.

The last step pings IndexNow with every URL in the sitemap, so Bing and other IndexNow search engines learn about changes straight away. It's marked continue-on-error, so a failed ping never fails a deploy.

What I'd tell someone building something similar

  1. Model the weird cases as data. Every state quirk became an optional config key, so the engine stayed small.
  2. Make your published examples your tests. The copy and the maths can't silently drift apart.
  3. Server-render the hard logic. One implementation, testable in PHP, and the page works without JavaScript.
  4. Use Shadow DOM for embeds. Style isolation in both directions is worth it.
  5. Link your sources. Payroll tax is easy to get slightly wrong. Linking the official withholding documents keeps you honest and lets users check your work.

You can try it at netpaymeter.com and see the embed options at netpaymeter.com/embed. If you spot a state rule I've modelled wrong, I'd genuinely like to hear about it in the comments.

Top comments (0)