I wouldn't introduce a state management library or over-complicate it. Here's a state diagram for example:

Example (* but see note towards the end):
<?php
// database/migrations/YYYY_MM_DD_create_invoices_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->string('number')->unique();
$table->enum('status', ['draft', 'validated']);
$table->enum('payment_status', ['not_paid', 'partially_paid', 'paid'])->nullable();
$table->decimal('total_amount', 10, 2);
$table->decimal('paid_amount', 10, 2)->default(0);
$table->date('due_date')->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamps();
// Add indexes for common queries
$table->index(['status', 'payment_status']);
$table->index(['status', 'due_date']);
$table->index(['status', 'sent_at']);
});
}
public function down()
{
Schema::dropIfExists('invoices');
}
};
// app/Models/Invoice.php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Invoice extends Model
{
protected $fillable = [
'number',
'status',
'payment_status',
'total_amount',
'paid_amount',
'due_date',
'sent_at',
];
protected $casts = [
'due_date' => 'date',
'sent_at' => 'datetime',
'total_amount' => 'decimal:2',
'paid_amount' => 'decimal:2',
];
// Status constants
const STATUS_DRAFT = 'draft';
const STATUS_VALIDATED = 'validated';
const PAYMENT_STATUS_NOT_PAID = 'not_paid';
const PAYMENT_STATUS_PARTIALLY_PAID = 'partially_paid';
const PAYMENT_STATUS_PAID = 'paid';
// Accessors & Mutators
public function isLate(): bool
{
return $this->status === self::STATUS_VALIDATED &&
$this->due_date &&
$this->due_date < Carbon::today() &&
$this->payment_status !== self::PAYMENT_STATUS_PAID;
}
public function isSent(): bool
{
return $this->sent_at !== null;
}
// State Transition Methods
public function validate(): bool
{
if ($this->status !== self::STATUS_DRAFT) {
throw new \InvalidArgumentException('Only draft invoices can be validated');
}
return $this->update([
'status' => self::STATUS_VALIDATED,
'payment_status' => self::PAYMENT_STATUS_NOT_PAID,
]);
}
public function recordPayment(float $amount): bool
{
if ($this->status !== self::STATUS_VALIDATED) {
throw new \InvalidArgumentException('Only validated invoices can receive payments');
}
$newPaidAmount = $this->paid_amount + $amount;
if ($newPaidAmount > $this->total_amount) {
throw new \InvalidArgumentException('Payment amount exceeds invoice total');
}
$newPaymentStatus = $this->determinePaymentStatus($newPaidAmount);
return $this->update([
'paid_amount' => $newPaidAmount,
'payment_status' => $newPaymentStatus,
]);
}
public function markAsSent(): bool
{
if ($this->status !== self::STATUS_VALIDATED) {
throw new \InvalidArgumentException('Only validated invoices can be marked as sent');
}
return $this->update([
'sent_at' => Carbon::now(),
]);
}
// Helper Methods
private function determinePaymentStatus(float $paidAmount): string
{
if ($paidAmount >= $this->total_amount) {
return self::PAYMENT_STATUS_PAID;
}
return $paidAmount > 0
? self::PAYMENT_STATUS_PARTIALLY_PAID
: self::PAYMENT_STATUS_NOT_PAID;
}
// Scopes for querying
public function scopeDraft(Builder $query): Builder
{
return $query->where('status', self::STATUS_DRAFT);
}
public function scopeValidated(Builder $query): Builder
{
return $query->where('status', self::STATUS_VALIDATED);
}
public function scopeUnpaid(Builder $query): Builder
{
return $query->validated()
->whereIn('payment_status', [
self::PAYMENT_STATUS_NOT_PAID,
self::PAYMENT_STATUS_PARTIALLY_PAID
]);
}
public function scopeLate(Builder $query): Builder
{
return $query->validated()
->whereIn('payment_status', [
self::PAYMENT_STATUS_NOT_PAID,
self::PAYMENT_STATUS_PARTIALLY_PAID
])
->whereNotNull('due_date')
->where('due_date', '<', Carbon::today());
}
public function scopeUnsent(Builder $query): Builder
{
return $query->validated()
->whereNull('sent_at');
}
}
Use it like this:
// Creating a draft invoice
$invoice = Invoice::create([
'number' => 'INV-2024-001',
'status' => Invoice::STATUS_DRAFT,
'total_amount' => 1000.00,
'due_date' => Carbon::now()->addDays(30),
]);
// Validating an invoice
$invoice->validate();
// Recording a payment
$invoice->recordPayment(500.00);
// Marking as sent
$invoice->markAsSent();
// Querying invoices
$lateInvoices = Invoice::late()->get();
$unpaidInvoices = Invoice::unpaid()->get();
$unsentValidatedInvoices = Invoice::unsent()->get();
- I probably wouldn't do it exactly like this, because of cumulative error problems with limited precision types, so I would store everything as cents in invoices, and calculate on that. So everything coming in should be multiplied by 100, and clean integer calculations can be used, then divided by 100 and formatted for display.
An example of this:
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->string('number')->unique();
$table->enum('status', ['draft', 'validated']);
$table->enum('payment_status', ['not_paid', 'partially_paid', 'paid'])->nullable();
$table->integer('total_amount');
$table->integer('paid_amount')->default(0);
$table->date('due_date')->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamps();
// Add indexes for common queries
$table->index(['status', 'payment_status']);
$table->index(['status', 'due_date']);
$table->index(['status', 'sent_at']);
});
Example use:
// Creating a draft invoice
$invoice = Invoice::create([
'number' => 'INV-2024-001',
'status' => Invoice::STATUS_DRAFT,
// A thousand whole units
// 33.50 would be 3350 for example
'total_amount' => 100000,
'due_date' => Carbon::now()->addDays(30),
]);
// Validating an invoice
$invoice->validate();
// Recording a payment of 500 dollars/pounds/euro
$invoice->recordPayment(50000);
// Marking as sent
$invoice->markAsSent();
// Querying invoices
$lateInvoices = Invoice::late()->get();
$unpaidInvoices = Invoice::unpaid()->get();
$unsentValidatedInvoices = Invoice::unsent()->get();
Use unsigned big integer in migrations instead of decimal, and maybe have something like:
// In the migration:
$table->unsignedBigInteger('total_amount_cents'); // Instead of decimal
$table->unsignedBigInteger('paid_amount_cents')->default(0);
// In the Invoice model:
class Invoice extends Model
{
// ... the rest of it
// Just add checks for division by zero if you expect that might happen
public function getFormattedTotalAmount(): string
{
return sprintf('$%s', number_format($this->total_amount_cents / 100, 2));
}
public function getFormattedPaidAmount(): string
{
if ($this->paid_amount_cents === 0) {
return sprintf('$%s', number_format($this->paid_amount_cents);
}
return sprintf('$%s', number_format($this->paid_amount_cents / 100, 2));
}
public function getFormattedRemainingAmount(): string
{
$remainingCents = $this->total_amount_cents - $this->paid_amount_cents;
return sprintf('$%s', number_format($remainingCents / 100, 2));
}
}
Usage example:
$invoice = Invoice::find(1);
// If total_amount_cents is 150000 (representing $1,500.00)
echo $invoice->getFormattedTotalAmount(); // Outputs: "$1,500.00"
// If paid_amount_cents is 50000 (representing $500.00)
echo $invoice->getFormattedPaidAmount(); // Outputs: "$500.00"
echo $invoice->getFormattedRemainingAmount(); // Outputs: "$1,000.00"
Accessor properties:
class Invoice extends Model
{
protected $appends = ['formatted_total_amount', 'formatted_paid_amount'];
public function getFormattedTotalAmountAttribute(): string
{
return sprintf('$%s', number_format($this->total_amount_cents / 100, 2));
}
public function getFormattedPaidAmountAttribute(): string
{
return sprintf('$%s', number_format($this->paid_amount_cents / 100, 2));
}
}
$invoice->formatted_total_amount; // "$1,500.00"
You could of course keep the formatted amounts a certain width, and exclude the dollar sign or replace with whichever currency you're using. numer_format can take the usual dot as dollar and cents separator, and nothing as the thousands separator, so amounts come out like "150.55" for example.
number_format(
float $number, // The number to format
int $decimals = 0, // Number of decimal points
string $decimal_point = ".", // What to use as decimal point
string $thousands_sep = "," // What to use as thousands separator
);