Laravel Invoices: Auto-Generate Serial Numbers - 4 Different Ways

Tutorial last revisioned on March 17, 2024 with Laravel 11

When working with invoices, you need to deal with serial numbers that look like ABC-000001. Do you know how to auto-generate them in Laravel? This tutorial will cover a few ways to do this.


DB Structure

For our example, we will use a simple invoice DB table with the following columns:

  • id
  • user_id
  • due_date
  • amount
  • serial - Full serial number like ABC-1
  • serial_number - Serial number like 1
  • serial_series - Serial series like ABC

Here's how that looks in our migration:

Migration

Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->constrained();
$table->date('due_date');
$table->integer('amount');
$table->string('serial')->nullable();
$table->string('serial_series');
$table->integer('serial_number')->nullable();
$table->timestamps();
});

This makes our Model look like this:

app/Models/Invoice.php

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
class Invoice extends Model
{
use HasFactory;
 
protected $fillable = [
'user_id',
'due_date',
'amount',
'serial',
'serial_number',
'serial_series',
];
 
protected function amount(): Attribute
{
return Attribute::make(
get: fn($value) => $value / 100,
set: fn($value) => $value * 100,
);
}
 
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

Create Invoice: Form

Let's create the Controller methods to save a new invoice and auto-generate the serial number.

This is the form, with series options coming from the config.

app/Http/Controllers/InvoiceController.php

use App\Http\Requests\StoreInvoiceRequest;
use App\Models\Invoice;
use App\Models\User;
 
class InvoiceController extends Controller
{
public function create()
{
$users = User::pluck('name', 'id');
$invoiceSeries = config('invoiceSettings.availableInvoiceSeries');
 
return view('invoices.create', [
'users' => $users,
'invoiceSeries' => $invoiceSeries,
]);
}
}

These are the config values:

config/invoiceSettings.php

return [
'availableInvoiceSeries' => [
'ABC',
'DAF',
'GHI',
'UKS'
],
];

And here's the form in Blade file:

resources/views/invoices/create.blade.php

// ... layout with Tailwind/Breeze
 
<form action="{{ route('invoice.store') }}" method="POST">
@csrf
 
<div class="mb-4">
<label for="user_id" class="block text-gray-700 text-sm font-bold mb-2">User</label>
<select id="user_id" name="user_id"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
<option value="">Select User</option>
@foreach($users as $id => $name)
<option value="{{ $id }}" @selected(old('user_id') == $id)>{{ $name }}</option>
@endforeach
</select>
@error('user_id')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
<div class="mb-4">
<label for="serial_series" class="block text-gray-700 text-sm font-bold mb-2">Invoice Series</label>
<select id="serial_series" name="serial_series"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
@foreach($invoiceSeries as $seriesCode)
<option value="{{ $seriesCode }}" @selected(old('serial_series') == $seriesCode)>{{ $seriesCode }}</option>
@endforeach
</select>
@error('serial_series')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
<div class="mb-4">
<label for="due_date" class="block text-gray-700 text-sm font-bold mb-2">Due Date</label>
<input type="date"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="due_date" name="due_date" value="{{ old('due_date') }}">
@error('due_date')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
<div class="mb-4">
<label for="amount" class="block text-gray-700 text-sm font-bold mb-2">Amount</label>
<input type="text"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="amount" name="amount" value="{{ old('amount') }}">
@error('amount')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
<div class="mb-4">
<button type="submit"
class="bg-blue-500 text-white px-4 py-3 rounded font-medium w-full">Create Invoice
</button>
</div>
 
</form>

Next, we'll look at how to save the new invoice and generate the serial numbers differently.


Option 1: Generate Serial Number in Controller

The first option is to generate the serial number when the invoice is created. This is the simplest option, and it looks like this:

app/Http/Controllers/InvoiceController.php

// ...
 
public function store(StoreInvoiceRequest $request): RedirectResponse
{
$data = $request->validated();
 
$data['serial_number'] = (Invoice::where('serial_series', $data['serial_series'])->max('serial_number') ?? 0) + 1;
$data['serial'] = $data['serial_series'] . '-' . $data['serial_number'];
 
Invoice::create($data);
 
return redirect()->route('invoice.index');
}
 
// ...

And while this works, there are better options in my eyes.

You must remember to add this code to every place you create an invoice if there are multiple places, like an API Controller.

Code in Repository


Option 2: Generate Serial Number in Model Observers

Another option for generating an invoice number is to use Observers:

app/Models/Invoice.php

// ...
 
protected static function booted(): void
{
parent::booted();
 
self::creating(static function (Invoice $invoice) {
$invoice->serial_number = (Invoice::where('serial_series', $invoice->serial_series)->max('serial_number') ?? 0) + 1;
$invoice->serial = $invoice->serial_series . '-' . $invoice->serial_number;
});
}
 
// ...

This allows us to drop a big piece of code from our Controller:

app/Http/Controllers/InvoiceController.php

// ...
 
public function store(StoreInvoiceRequest $request): RedirectResponse
{
$data = $request->validated();
 
$data['serial_number'] = (Invoice::where('serial_series', $data['serial_series'])->max('serial_number') ?? 0) + 1;
$data['serial'] = $data['serial_series'] . '-' . $data['serial_number'];
 
Invoice::create($data);
Invoice::create($request->validated());
 
return redirect()->route('invoice.index');
}
 
// ...

We are making it much cleaner and providing an option to create an invoice in any way we want without worrying about the serial number.

Code in Repository

Option 2B: Moving Observer to Separate File

It's important to mention that Observers can be a separate file, too. They don't have to be defined in our Models and can be used as a dedicated Observer for a Model.

php artisan make:observer InvoiceObserver --model=Invoice

Here's how that looks:

app/Observers/InvoiceObserver.php

use App\Models\Invoice;
 
class InvoiceObserver
{
public function creating(Invoice $invoice): void
{
$invoice->serial_number = (Invoice::where('serial_series', $invoice->serial_series)->max('serial_number') ?? 0) + 1;
$invoice->serial = $invoice->serial_series . '-' . $invoice->serial_number;
}
}

Of course, this has to be registered:

app/Models/Invoice.php

use App\Observers\InvoiceObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
 
#[ObservedBy([InvoiceObserver::class])]
class Invoice extends Model {
// ...
}

And that's it! Now, we have a dedicated Observer for our Invoice Model.

Code in Repository

Sadly, this observer way still has a flaw: we can accidentally make duplicate serial numbers. This is not good, as any accountant will give you a hard time.

Let's take a look at another option.


Option 3: Generate Serial Number Using Jobs

Our third option includes a solution to the duplicate serial number problem - jobs and a unique index! They run in the background and can be re-run if something goes wrong. Here's how it looks:

We need to ensure we have a unique index in our migration.

Migration

Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->constrained();
$table->date('due_date');
$table->integer('amount');
$table->string('serial')->nullable();
$table->string('serial_series');
$table->integer('serial_number')->nullable();
$table->timestamps();
 
$table->unique(['serial_series', 'serial_number']);
});

Then, we can create our Job that will generate the serial number and re-run if the serial number is already taken:

app/Jobs/GenerateInvoiceNumber.php

// ...
private Invoice $invoice;
 
public function __construct(int $invoiceID)
{
$this->onQueue('invoiceNumbersQueue');
 
$this->invoice = Invoice::findOrFail($invoiceID);
}
 
public function handle(): void
{
$this->invoice->serial_number = (Invoice::where('serial_series', $this->invoice->serial_series)->max('serial_number') ?? 0) + 1;
$this->invoice->serial = $this->invoice->serial_series . '-' . $this->invoice->serial_number;
$this->invoice->save();
}
// ...

And then we can dispatch this Job in our controller:

app/Http/Controllers/InvoiceController.php

use App\Jobs\GenerateInvoiceNumberJob;
 
// ...
public function store(StoreInvoiceRequest $request): RedirectResponse
{
$invoice = Invoice::create($request->validated());
 
dispatch(new GenerateInvoiceNumberJob($invoice->id));
 
return redirect()->route('invoice.index');
}
// ...

As a last step, we should make sure that we are running the queue worker:

php artisan queue:work --queue=invoiceNumbersQueue

Running a specific queue is important as we want invoices to have their worker processing only invoices and not other jobs. This way, you would expect your jobs to run one after another rather than in parallel, which could cause duplicates.

Code in Repository


Option 4: Why Not BOTH? Using Observers and Jobs

As our last option, we can combine two examples here - Observers and Jobs. This will solve our problem of invoice creation from anywhere and still use a dedicated queue for invoice numbers that we can re-run if something goes wrong. Here's how it looks:

The first thing we need to do is to create a Job:

app/Jobs/GenerateInvoiceNumber.php

// ...
private Invoice $invoice;
 
public function __construct(int $invoiceID)
{
$this->onQueue('invoiceNumbersQueue');
 
$this->invoice = Invoice::findOrFail($invoiceID);
}
 
public function handle(): void
{
$this->invoice->serial_number = (Invoice::where('serial_series', $this->invoice->serial_series)->max('serial_number') ?? 0) + 1;
$this->invoice->serial = $this->invoice->serial_series . '-' . $this->invoice->serial_number;
$this->invoice->save();
}
// ...

Then it's all about the Observer (for this example, we will use Model Observer, but you can use a dedicated Observer if you want):

app/Models/Invoice.php

use App\Jobs\GenerateInvoiceNumberJob;
 
// ...
 
protected static function booted()
{
parent::booted();
 
self::created(static function (Invoice $invoice) {
dispatch(new GenerateInvoiceNumberJob($invoice->id));
});
}
// ...

Once you create an invoice, it will automatically schedule a job to generate the serial number. It does not matter where you will create it.

Code in Repository


Bonus: Adding Leading Zeros

It's very common to have leading zeros in your serial numbers. For example, you might want to have ABC-001 instead of ABC-1. This is very easy to do like this:

app/Jobs/GenerateInvoiceNumberJob.php

public function handle(): void
{
// ...
$this->invoice->serial = $this->invoice->serial_series . '-' . $this->invoice->serial_number;
$this->invoice->serial = $this->invoice->serial_series . '-' . str_pad($this->invoice->serial_number, 5, '0', STR_PAD_LEFT);
// ...
}

Or in short, here's how the str_pad() works:

str_pad('1', 5, '0', STR_PAD_LEFT); // 00001
  • 1 - The string we want to pad
  • 5 - The total length of the string we want to have
  • 0 - The character we want to use to pad the string
  • STR_PAD_LEFT - The side we want to pad the string on (left, right, both)

That's it! Now you have a serial number with leading zeros.

avatar

Is there a way to make it restart every year? 0001-2022 0002-2022 .... 0160-2022 0001-2023

avatar

yes!

Set your serial series to the year and that's it!

For example in config file set it to now("Y")

avatar

like this?

return [ 'availableInvoiceSeries' => [ now("Y"); ], ];

avatar

Yes, just without typos:

return [
    'availableInvoiceSeries' => [
				now('Y'),
    ],
];

Of course, this will be the only selection in the dropdown, but you can make it a hidden field instead of select (if you don't need to select the series)

avatar

thank you very much for your help

avatar

Hello

  1. Git repositories mentioned does not exists , 404 Not found
  2. as these applications are used by Team(multi user), when two users generating Invoice at same time there can be duplication of generated invoice number.
avatar

Hi,

  1. We might have missed that in an update! Sorry about this.
  2. Sorry, where did we mention multi-tenancy here? I don't think that this is designed with multi-tenancy in mind. To do so, you do have to have a different workflow from ours :)
avatar
You can use Markdown
avatar
Enrique De Jesus Robledo Camacho

Verry usefull, i found this for primary, would this avoid using the job? I mean if some of the columns used is autoincrement. https://laravel.com/api/10.x/Illuminate/Database/Schema/Blueprint.html#method_primary

avatar

The main purpose of the job is not only to avoid the issue with repeating numbers, but to also solve the problem of re-trying the job.

Imagine, you have an index that's unique (mentioned in 3rd option) and when trying to create the invoice - it fails. Then you have to manually re-try the saving with a new number. Job does by itself.

Also, auto-increment is okay, but it would not be PER each of the series, so that causes other issues where gaps will appear. And gaps are pretty bad!

avatar
You can use Markdown
avatar

What about soft deleted records? how to deal with them? here:

$this->invoice->serial_number = (Invoice::where('serial_series', $this->invoice->serial_series)->max('serial_number') ?? 0) + 1;

avatar

You can add withTrashed() and that will cover it

avatar
You can use Markdown
avatar
You can use Markdown

Recent New Courses