A self-hosted invoicing and expense-tracking app for freelancers. Built on Laravel 12 + Filament 3, with a public client-facing invoice preview, PDF generation, and a balance dashboard that tracks money in (paid invoices) against money out (paid bills).
- Create, edit and manage invoices through the Filament admin at
/admin. - Multi-currency support (USD, GBP — extend in
app/Helpers/CurrencyHelper.php). - Auto-numbered with a configurable prefix (
Company Settings → Invoice prefix). - Line items with drag-to-reorder; quantity × unit rate auto-calculates total.
- Tax amount is editable; subtotal and total recompute reactively.
- Capped total: optional override that lets you bill less than the line items add up to (e.g. CEO-imposed cap on a salary invoice). The PDF and public preview show a transparent "Adjustment" line so the client sees the breakdown.
- Public share link: every invoice gets a 64-char-hex token URL (
/invoice/preview/{token}) the client can open without logging in. - View tracking: the invoice flips from
unread→viewedthe first time a guest opens the share link. Authenticated admin previews don't trip the flag, so "viewed" actually means the client looked. - Email: one-click "Send Email" action on the invoice list. Uses the customer's address; the button hides itself once
email_sent_atis set so you don't double-send. Sending also bumpsdraft → sent. - PDF download: rendered with Spatie Browsershot (headless Chromium). Available from both the admin and the public preview link.
- "Mark as Paid" action with confirmation modal.
- Track recurring and one-off vendor bills (Hetzner, AWS, GitHub, etc.).
- Fields: vendor, category, description, amount, currency, due/paid dates, recurring flag, notes.
- "Mark as Paid" action.
- Filter by status, category, recurring.
- Balance widget: Income (paid invoices) − Expenses (paid bills) = Net (color-coded green/red).
- Invoice stats widget: Total earned, Outstanding, Overdue, Paid this month.
- Monthly revenue chart: 12-month line chart of paid invoices.
- Recent invoices table.
- Standard CRUD (
/admin/customers). - Used as the recipient of invoices.
| Layer | Choice |
|---|---|
| Backend | Laravel 12, PHP 8.4 (Docker) / ^8.2 (composer constraint) |
| Runtime | FrankenPHP + Laravel Octane (production), php artisan serve (dev) |
| Admin UI | Filament 3 |
| Frontend assets | Tailwind v4, Vite 7 |
Spatie Browsershot (local Chromium) or Cloudflare Browser Rendering — selectable via LARAVEL_PDF_DRIVER |
|
| DB | SQLite by default; Postgres also wired (Dockerfile installs pdo_pgsql) |
| Tests | Pest 3 |
| Style | Laravel Pint, see pint.json |
- PHP 8.2+ with
pdo_sqlite,intl,gd,mbstring(Herd / Valet on macOS works out of the box). - Composer 2.
- Node 22 + npm.
- For the Browsershot PDF driver: a Chromium binary on the host. On macOS, Herd installs it; on Linux servers, install
chromium-headless-shell(orchromium-browser) and runnpm installin the project root sonode_modules/puppeteerexists. - For the Cloudflare PDF driver: a Cloudflare account with Browser Rendering enabled, an API token with the
Browser Rendering: Editscope, and the account ID.
git clone git@github.com:kevariable/invoicely.git
cd invoicely
composer install
npm install
cp .env.example .env
php artisan key:generate
# Allowlist your email for the admin panel (config/app.php → can_access_panel)
echo 'APP_CAN_ACCESS_PANEL=you@example.com' >> .env
# Migrate + populate with realistic local data
composer freshThat last step gives you:
- Admin user
kevariable@gmail.com/password(change this — see Production safety) - ByteHire Limited as the default
CompanySetting(edit via/admin/company-settings) - 8 customers, ~28 invoices across paid/sent/overdue/draft, 30 sample bills
composer devRuns four processes side by side via concurrently: php artisan serve, queue listener, pail log tail, and vite. Visit http://localhost:8000/admin (or whichever Herd / Valet domain — e.g. http://invoicely.test/admin) and log in.
# Dev
composer dev # all-in-one: server + queue + logs + vite
composer fresh # migrate:fresh --seed (DB reset + seed)
php artisan migrate # apply pending migrations only
php artisan tinker # REPL
# Tests
composer test # config:clear + artisan test
./vendor/bin/pest # direct (matches CI)
./vendor/bin/pest --filter=name # by test name
./vendor/bin/pest tests/Feature # by directory
# Code style
vendor/bin/pint # auto-fix
vendor/bin/pint --test # check only (CI mode)
# Assets
npm run dev # vite dev server
npm run build # production build
# Docker
docker compose up --build # FrankenPHP + Octane on host port 8989phpunit.xml forces DB_CONNECTION=sqlite with :memory: for tests, plus array cache/mail and sync queue — no real DB or external services needed to run the suite. The CI workflow at .github/workflows/tests.yml runs the same pest command on push/PR.
app/
├── Filament/
│ ├── Resources/
│ │ ├── BillResource.php # Vendor bills (expenses)
│ │ ├── CustomerResource.php
│ │ ├── CompanySettingResource.php
│ │ └── InvoiceResource.php # Main invoice CRUD + actions
│ └── Widgets/
│ ├── BalanceOverview.php # Income / Expenses / Net
│ ├── InvoiceStatsOverview.php # Total / Outstanding / Overdue / This month
│ ├── MonthlyRevenueChart.php
│ └── RecentInvoicesTable.php
├── Helpers/CurrencyHelper.php # Single source of truth for currency list
├── Http/Controllers/
│ └── InvoicePreviewController.php # Public /invoice/preview/{token} + PDF
├── Mail/InvoiceNotification.php
├── Models/ # Eloquent — where state lives
│ ├── Bill.php
│ ├── CompanySetting.php # Singleton via firstOrCreate
│ ├── Customer.php
│ ├── Invoice.php
│ ├── InvoiceItem.php
│ └── User.php
└── Providers/Filament/AdminPanelProvider.php
src/ # Domain layer (PSR-4: Invoice\)
├── Base/ # ValueObject, DataReadonly, DataHydration
├── Customer/{Application,Domain}/
└── Invoice/
├── Application/Data/ # DTOs (Valinor-hydrated)
└── Domain/
├── Actions/GenerateInvoiceAction.php # Browsershot PDF
├── Contracts/ # Currently declared but unbound
└── Primitives/ # Value objects (InvoiceId, Amount, ...)
resources/views/
├── filament/brand-logo.blade.php # Inline SVG wordmark
├── invoice-pdf-browsershot.blade.php # Active PDF template
├── invoice-pdf-dompdf.blade.php # Legacy DomPDF template
└── invoice-public-preview.blade.php # Client-facing share link
database/
├── factories/ # User, Customer, Invoice, InvoiceItem, Bill
├── migrations/
└── seeders/DatabaseSeeder.php # Local-only data dump
App\(state, controllers, Filament) andInvoice\(src/, DDD-flavoured DTOs + value objects) are two parallel namespaces. Behaviour and persistence live on the Eloquent models inapp/Models/. The domain layer mostly provides DTOs andGenerateInvoiceAction(PDF). Don't introduce a service-binding layer forDomain/Contracts/*interfaces unless you're also wiring real implementations.CompanySetting::getSettings()isfirstOrCreate([])— a true single-row singleton. Use it everywhere instead of queryingCompanySettingdirectly.- Currency: extend
App\Helpers\CurrencyHelper::CURRENCIES, not migrations. - Filament panel: single panel
admin, top navigation, Neutral palette, brand logo fromresources/views/filament/brand-logo.blade.php(currentColor = light/dark in one asset). Resources/Pages/Widgets are auto-discovered. - Authentication is gated through Filament's
->login(). The User model implementscanAccessPanel()againstconfig('app.can_access_panel'), which readsAPP_CAN_ACCESS_PANELfrom env (comma-separated allowlist of emails).
Invoice\Invoice\Domain\Actions\GenerateInvoiceAction dispatches on config('pdf.driver') (env: LARAVEL_PDF_DRIVER):
browsershot(default) — renders locally via Spatie Browsershot. Needs a Chromium binary andnode_modules/puppeteer. Best for dev and self-hosted servers where you control the runtime.cloudflare— POSTs the rendered HTML to Cloudflare's Browser Rendering REST API and returns the resulting PDF. No local Chromium required, which makes it the right choice on serverless hosts. SetCLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_API_TOKENin env.
A few things to be aware of when deploying:
- Migrations are additive only. The most recent set adds nullable columns and a new table — no column drops or data backfills that could lose data.
DatabaseSeederis gated for local/testing. It early-returns unlessapp()->environment(['local','testing']). Even if someone accidentally runsphp artisan db:seedon prod, it no-ops with a warning. The seeded admin user (kevariable@gmail.com / password) and the 30+ fake customers/invoices/bills cannot leak into production.composer freshon production would drop all tables. Laravel'smigrate:freshalready prompts unless--forceis passed, and the composer script does not pass--force. Don't add it.- Override the seeded admin the first time you boot a fresh prod DB: create a real user via
php artisan tinkerand add their email toAPP_CAN_ACCESS_PANEL. Then change the seeder default if you keep using it locally. - Browsershot needs Chromium at runtime. The bundled
docker/Dockerfileinstallschromium-headless-shell; if you deploy outside Docker, install Chromium yourself.
docker compose up --buildThis builds an image based on dunglas/frankenphp:php8.4, installs Composer + Node + Chromium, and starts the app on host port 8989 (forwarded from container 8000). The entrypoint runs php artisan octane:start --server=frankenphp. Cron is set up via docker/crontab, supervisord via docker/supervisord.conf. Mounts the working directory into the container with delegated for fast local dev.
For production-like deploys, change the entrypoint volume mount and provide a real .env with:
APP_ENV=productionAPP_DEBUG=false- A strong
APP_KEY DB_CONNECTION=pgsql(or your choice) with credentialsAPP_CAN_ACCESS_PANEL=you@example.com,...- Mail credentials
Run migrations on first boot: php artisan migrate --force.
Two GitHub Actions workflows in .github/workflows/:
linter— runsvendor/bin/pint --teston PRs tomain/develop. Style violations fail the job.tests— installs PHP 8.4 + Node 22, builds assets, runs./vendor/bin/pest.
Both require the FLUX_USERNAME / FLUX_LICENSE_KEY repository secrets so composer install can fetch livewire/flux from composer.fluxui.dev. PRs from forks won't have access to those secrets and will fail at composer install — open issues from the main repo, not forks.
- Branch from
mainwith afeat/,fix/ordocs/prefix. - Keep behaviour on Eloquent models in
app/Models/unless reused outside Filament. - Run
vendor/bin/pint && ./vendor/bin/pestbefore pushing. - Use the existing PR template style (Summary + Test plan).
MIT.