- Published on
Auto Generate Serial Numbers - 4 Different Ways
- Authors
- Name
- Tuantq
When you need to deal with serial numbers that look like XXX-000001. Do you know how to auto-generate them in Laravel?
Table of Content
- DB Structure
- Create Invoice: Form
- Option 1: Generate Serial Number in Controller
- Option 2: Generate Serial Number in Model Observers
- Option 2B: Moving Observer to Separate File
- Option 3: Generate Serial Number Using Jobs
- Option 4: Why Not BOTH? Using Observers and Jobs
- Bonus: Adding Leading Zeros
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 XXX-1
- serial_number - Serial number like 1
- serial_series - Serial series like XXX
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' => [
'XXX',
'ABC',
'XYZ',
'KHZ'
],
];
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.
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.
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 in our app/Providers/EventServiceProvider.php
:
app/Providers/EventServiceProvider.php
use App\Models\Invoice;
use App\Observers\InvoiceObserver;
// ...
protected $observers = [
Invoice::class => [
InvoiceObserver::class,
]
];
// ...
And that's it! Now, we have a dedicated Observer for our Invoice Model. 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.
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.
Bonus: Adding Leading Zeros
It's very common to have leading zeros in your serial numbers. For example, you might want to have XXX-001 instead of XXX-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.