Skip to main content

Black Friday 2025! Only until December 1st: coupon FRIDAY25 for 40% off Yearly/Lifetime membership!

Read more here
Premium Members Only
Join to unlock this tutorial and all of our courses.
Tutorial Premium Tutorial

Eloquent Performance: Enum VS Int/Tinyint and SoftDeletes

July 01, 2024
13 min read

I recently worked with an Eloquent query with the where status = 'new' filter by ENUM value, which felt quite slow. I experimented with changing it to status_id instead as a big/tiny integer with a foreign key. Was it faster? Let's find out together.

At the end of this tutorial, you'll find the link to the GitHub repository, so you can play around and experiment, too.


The Scenario/Problem

The situation comes from the official Filament demo, where I seeded 1M orders and had a menu badge for counting the new orders:

That executed this SQL query under the hood:

select count(*) as aggregate
from `shop_orders`
where `status` = 'new'
and `shop_orders`.`deleted_at` is null

The column status was created as ENUM on the DB level:

Schema::create('shop_orders', function (Blueprint $table) {
$table->id();
 
// ... other fields
 
$table->enum('status', [
'new', 'processing', 'shipped', 'delivered', 'cancelled'
])->default('new');
 
// ... other fields
 
$table->timestamps();
$table->softDeletes();
});

According to the Laravel Debugbar, the average execution time was around 150 ms.

Not very bad, but my initial reaction was that it could be faster to search where status_id = 1 or something.

So, here's my experiment.


Step 1. New Tables: Migrations

First, a few Artisan commands to create separate models/migrations:

php artisan make:model Status -m
php artisan make:model TinyStatus -m

And then, structure with the initial data seeded right away in migrations:

use App\Models\Status;
 
// ...
 
return new class extends Migration
{
public function up(): void
{
Schema::create('statuses', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
 
Status::create(['name' => 'new']);
Status::create(['name' => 'processing']);
Status::create(['name' => 'shipped']);
Status::create(['name' => 'delivered']);
Status::create(['name' => 'cancelled']);
}
 
};

Separately, the same for Tiny Statuses:

use App\Models\TinyStatus;
 
// ...
 
return new class extends Migration
{
public function up(): void
{
Schema::create('tiny_statuses', function (Blueprint $table) {
$table->tinyIncrements('id');
$table->string('name');
$table->timestamps();
});
 
TinyStatus::create(['name' => 'new']);
TinyStatus::create(['name' => 'processing']);
TinyStatus::create(['name' => 'shipped']);
TinyStatus::create(['name' => 'delivered']);
TinyStatus::create(['name' => 'cancelled']);
}
};

By the way, did you know about the ->tinyIncrements() method? It creates a TINYINT column as an auto-incremented primary key instead of a default BIGINT generated by $table->id().

Notice: I also added the name column in both Models into a $fillable array.


Step 2. Two New Columns with Foreign Keys

Now, let's create two columns in the shop_orders table: one foreign key to the statuses table and another one on the tiny_statuses table:

php artisan make:migration add_columns_to_shop_orders_table

Here's the Migration code:

public function up(): void
{
Schema::table('shop_orders', function (Blueprint $table) {
$table->foreignId('status_id')->default(1)->constrained();
 
$table->unsignedTinyInteger('tiny_status_id')->default(1);
$table->foreign('tiny_status_id')->references('id')->on('tiny_statuses');
});
 
$sqlStatus = "UPDATE shop_orders set status_id =
case when status='new' then 1
when status='processing' then 2
when status='shipped' then 3
when status='delivered' then 4
when status='cancelled' then 5
else 5
end";
\Illuminate\Support\Facades\DB::statement($sqlStatus);
 
$sqlTinyStatus = 'UPDATE shop_orders set tiny_status_id=status_id';
\Illuminate\Support\Facades\DB::statement($sqlTinyStatus);
}

As you can see, we immediately set the values of the new columns with SQL statements.

Side note: it may not be ideal to execute SQL and Eloquent statements directly in migrations, but I'm doing it simply to avoid extra paragraphs of text about seeding and explaining how to launch it.

As a result, we have this in the database:

We're ready to use those columns and measure the improvement results.


Step 3. Change ->where() Condition: Is It Faster?

Now, the place where I had this condition is in the OrderResource of Filament.

app/Filament/Resources/OrderResource.php

class OrderResource extends Resource
{
// ...
 
public static function getNavigationBadge(): ?string
{
/** @var class-string<Model> $modelClass */
$modelClass = static::$model;
 
return (string) $modelClass::where('status', 'new')->count();
}
 
// ...
 
}

Let's change it to use status_id.

return (string) $modelClass::where('status', 'new')->count();
 
return (string) $modelClass::where('status_id', 1)->count();

Refreshing the page to see if it is faster than the previous 150ms...

Wait, WHAT?! 327ms? So, it's 2x slower? Relaunching the page just to double-check...

It's a bit better with 281ms, but still slower. So, it wasn't a coincidence :(

Ok, maybe it's because of the BIGINT? Let's try the tiny_status_id.

return (string) $modelClass::where('status_id', 1)->count();
 
return (string) $modelClass::where('tiny_status_id', 1)->count();

That's even weirder. So, 289ms means no improvement with having a smaller field? What the...?

Just to make sure, I ran a test with Benchmark Laravel class and 10 iterations:

use App\Models\Shop\Order;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Benchmark;
 
Route::get('benchmark', function() {
Benchmark::dd([
"Enum" => fn() => Order::where("status", "new")->count(),
"Bigint" => fn() => Order::where("status_id", 1)->count(),
"Tinyint" => fn() => Order::where("tiny_status_id", 1)->count()
], 10);
});

Results may be unexpected, but pretty clear:

array:3 [▼ // vendor/laravel/framework/src/Illuminate/Support/Benchmark.php:67
"Enum" => "181.001ms"
"Bigint" => "270.180ms"
"Tinyint" => "258.069ms"
]

Mmmkay... but... why enums are faster?


EXPLAIN: Are Indexes Being Used?

The first thought on DB performance is about indexes, right? Do we have/use them?

By default, Laravel adds...

Premium Members Only

This advanced tutorial is available exclusively to Laravel Daily Premium members.

Premium membership includes:

Access to all premium tutorials
Video and Text Courses
Private Discord Channel

Comments & Discussion

FB
Florian Bouffard ✓ Link copied!

This was a question I had been asking myself for quite a while but I never took the time to answer it for myself.

Thank you very much for this research, it’s very interesting.

S
Sjoerd24 ✓ Link copied!

super interesting read, thanks for the thorough research! Just as a side note, my experience is that my cheap 6e droplet is way faster then my windows laptop. I do have all the caching recommendations enabled from the filament docs and I dont have a very fast laptop as you have so that would explain I think the difference. ;)

PK
Povilas Korop ✓ Link copied!

Windows web-servers are also a big reason for slowness, they work in a different way, taking 20 MB RAM vs 5 MB RAM just to load Laravel, last time I saw it with Laragon on some forum.

Maybe the times have changed since then, but it's not only about hardware.

EC
Engel Cituk Caamal ✓ Link copied!

I have a windows 11 with laragon, when I run artisan commands in the terminal it is incredibly slow. My pc is supposed to have 32 gb of ram. I had some improvement when in my environment I enabled caching, discarded apache and used ngix, but still the improvement is not enough. Now I use Herd and it is more decent in speed, but I still see slowness when executing artisan commands.

JC
Jon Cody ✓ Link copied!

ive been looking at HERD. That is a replacement to Laragon? are there complications switching from one to another

MS
Mike Scott ✓ Link copied!

Great research and write-up, Povilas :) You could make the caching solution even better by complying with the Single Responsibility Principle (SRP) as well as the Don't Repeat Yourself (DRY) principle, by putting the caching code into the Order model class, with a static method to get the count of the orders and second static method to clear the cache. You can then clear the cache if necessary from anywhere in your code as well as from the order observer, without any of the duplicated code that's in your order observer ;)

AL
A Lungeanu ✓ Link copied!

A small typo in text, is seconds not minutes and just once mentioned. For me, is a bit weird saying seconds multiply seconds.

"Notice: that 60 * 60 number means caching for 1 hour: 60 seconds x 60 minutes."

LN
Loganathan Natarajan ✓ Link copied!

Thanks for the detailed explanation. It helps.

We'd Love Your Feedback

Tell us what you like or what we can improve

Feel free to share anything you like or dislike about this page or the platform in general.