Laravel Database Structure: 11 Mistakes Developers Make

Laravel Database Structure: 11 Mistakes Developers Make
Admin
Tuesday, November 28, 2023 9 mins to read
Share
Laravel Database Structure: 11 Mistakes Developers Make

In Laravel projects, a lot of issues come from non-Laravel mistakes, database structure is one of those. In this tutorial, we will cover the most typical mistakes devs make when structuring DB in Laravel.

For example, have you ever encountered an issue with fields not being nullable? Or maybe you had problems with relationships being incorrect type?

Don't worry - you are not alone! Let's cover the most typical mistakes and how to solve them.


Mistake 1: Not Using Foreign Keys

When defining the relationships, some developers forget to add foreign key definitions to the database. Let's look at an example:

Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('first_name');
// ...
});
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client');
// ...
});

As you can see, no ->foreign() here.

This works fine for the most part, but once we have some clients and orders in our system - deleting a client will cause an issue:

If we look at our database, we can see that we still have an order with our deleted client:

This issue happened because our database has no idea what to do in that scenario. All it thinks of - is that we have a number in our client column, which is not tied to anything.

You can solve this issue with foreign keys, as this will tell the database to check if the client exists before doing anything with the client_id column. Let's look at an example:

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client_id');
$table->foreign('client_id')->references('id')->on('clients');
// ...
});

Now we will do the same action - attempting to delete our client:

This time, our deletion has failed, but our Orders table is still working:

Why did this happen? Well, in the database, we have a constraint added. This limits what action can happen with our parent resource.

In our case, we can only delete a Client after first deleting all their orders. Of course, you need to handle this error with a try-catch block, but this provides a better development experience.

Even if you forget about this - Database has your back! It might break the user experience, but this will prevent bigger collapses in your system.

Notice: Foreign Key Column Names

There is one small thing to note here - column naming. In our example, we used client, which is okay to use, but standard practice is to use the singular form of the table name suffixed with _id. So, in our case, it would be:

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client_id');
$table->foreign('client_id')->references('id')->on('clients');
// ...
});

Or, shorter:

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained();
// ...
});

While this is a personal preference - it does help you to understand what the column is for. If you have client - you might think it's some identifier for the client (especially in bigger databases), but if you have client_id - you know it's a foreign key to the clients table. A quick look at a database like this - points us in the right direction:


Mistake 2: Incorrect Column Types

Another common issue with Database design is wrong column types, like creating an int field but needing to add floats later on.

One specific case is quite common - varchar fields with comma-separated values instead of using relationships. Let's look at an example:

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('client_id');
// ...
});

This seems fine, right? Well, it probably is okay until you have something like this:

What do we have here? We have a list of related IDs in the format of a string. We have seen this used as a way to have one-to-many relationships.

For example, we would expect our order to have multiple Clients. But this is different from how it works. This is just a string, and if you try to use it as a relationship:

Model

// ...
 
public function client()
{
return $this->belongsToMany(Client::class);
}
 
// ...

You will get an error like this:

Even if you try to use this as a belongsTo relationship like this:

Model

// ...
 
public function client()
{
return $this->belongsTo(Client::class);
}
 
// ...

You will still have an issue where incorrect data will be loaded:

As you can see, it tries to find the client with an ID of 1, 2, 5, 9 or 2, 3, 1, 4 and not the actual IDs. This is because Databases treat this as a string and not as a list of IDs. So what is the better option? Correctly defining this relationship and using a correct column type:

Schema::create('client_order', function (Blueprint $table) {
$table->foreignId('client_id')->constrained('clients');
$table->foreignId('order_id')->constrained('orders');
});

This will produce a Database schema like this:

This helps you avoid common mistakes and also allows you to control your data in a better way.


Mistake 3: Not Using Indexes

Indexing in Databases is often looked at as something that has to be done in massive projects, so they are skipped at the initial step. But what does it really do to our systems? Let's look at an example:

  • We have a Client table with 10 000 records
  • We have an Orders table with 1 000 000 records (100 orders per client)

And we want to load the order count for each of our Clients to display it on our table. This seems fine, as we can use code like this:

Migration

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client_id');
// ...
});

Controller

$clients = Client::withCount('orders')->paginate(50);

In this example, we ignored the index on our client_id in the orders table. Let's load the page and check the debug bar:

Looking closely, it takes 3.87s to load the count! That's A LOT OF TIME to do this kind of operation. Now let's try to add an Index to our client_id column:

Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client_id');
$table->index('client_id');
// ...
});

And re-run the same code again:

As we can see now, this runs in 1.96ms - which is way faster than what we had before. This is because Indexes help us make a map (a lot of generalization here, but treat it like a directory of where to find what value in your database) of our data. This helps the database to run faster.


Mistake 4: "Non-Laravel" Table Names for Pivot Tables

Laravel has a particular way to form many-to-many Pivot tables to auto-resolve them. Let's look at an example:

  • We have a clients table
  • We have an orders table

What should our Pivot table name be?

  • client_order
  • order_client
  • clients_orders
  • orders_clients
  • etc...

In this case, Laravel would expect it to be client_order, and if we follow that, we can define our relationship like this:

Client Model

public function orders(): BelongsToMany
{
return $this->belongsToMany(Order::class);
}

Order Model

public function client(): BelongsToMany
{
return $this->belongsToMany(Client::class);
}

Now, let's do an example where we have a different table name, in this case - client_orders. What will happen then? Well, our relationship will break:

Now, it is not such a big deal at the Database level, but on our Laravel side - we have to add a table name to our relationship:

Client Model

public function orders(): BelongsToMany
{
return $this->belongsToMany(Order::class, 'client_orders');
}

Order Model

public function client(): BelongsToMany
{
return $this->belongsToMany(Client::class, 'client_orders');
}

It doesn't seem like a big deal. Still, it is a lot of extra code to write (especially if you have a lot of Models), and it can be easily avoided by following the naming convention.


Mistake 5: Not Using "ON DELETE" Actions

When defining a foreign key - we tend to ignore cascading actions and leave it as it is. But that causes another typical mistake to happen:

This mistake happens when we try to delete a User from our database, but this user has some Posts. And since we did not inform our database what it should do once this happens - it just throws an error.

To fix this, we can implement "OnDelete" actions, like cascade:

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
// ...
});

Now, if we attempt to delete a User with Posts, it will also delete all the Posts. This is a handy feature, but you can also use other options:

  • cascadeOnDelete() - This will delete all related entries once the parent is deleted
  • restrictOnDelete() - This will throw an error if there are any related entries (this is the default behavior)
  • nullOnDelete() - This will set all related entries to null once the parent is deleted (this requires the column to be nullable!)

These options handle different scenarios, so you must choose the best one that fits your needs. But remember - always choose one of these options, as it will help you to avoid errors in your system.

Note on Soft Deletes

Due to how the database works - the cascading actions will not cover Soft Delete models. Cascading only works with full deletes. So if you have a User with Posts and Soft Delete the User - the Posts will remain there.

Soft Delete is a Laravel feature, not a Database feature. So, if you want to delete all related entries, you must do it manually. There's also a package cascade-soft-deletes that can help you.


Mistake 6: Not Following DB Normalization

When starting a new database design, following Database Normalization is essential. But what does it mean in practice? What is normal in this case? Let's look at a few common mistakes that should be avoided.

Take a look at this database example:

Do you see something wrong with it? If yes - fantastic, you have a good eye! If not, here's the list:

  • We have multiple fields containing comma-separated values - These will be hard to work with. For example, if we want to find a product with a specific SKU number - we will have to do a full-text search, which is slow.
  • We have supplier information in this table - What happens if the product has more than 1 supplier? We will have to duplicate the product information for each supplier. This will cause a lot of data duplication and make it hard to maintain.
  • We have carrier information in this table - If this product has more than one carrier, we must duplicate it. Or add comma-separated values. This could be better.

To normalize this table - we have to perform a few actions, so let's talk about the decisions we had to make:

  • SKU has to become a separate table. This will allow us to have multiple SKUs for a single product with a relationship hasMany. We will gain faster filters and more control over our queries.
  • Category has to become a separate table. This will make our life easier to display products that match the same category. Most likely, this should be a belongsToMany relationship.
  • Tags should also become a separate belongsToMany table. This will allow us to quickly retrieve all products within a specific tag.
  • Prices can either become a separate table or join our SKUs table. This depends on our business logic. Each SKU has a price for simplicity, so we will move them together.
  • Supplier becomes a separate table. This will allow us to have multiple suppliers for a single product. This will also allow us to have a belongsToMany relationship, as one supplier can have multiple products, and one product can have multiple suppliers.
  • Supplier phones should also be moved to the suppliers table. They are a pair and should be together.
  • Carrier becomes a separate table. This will allow us to have multiple carriers for a single product. This will also allow us to have a belongsToMany relationship, as one carrier can have multiple products, and one product can have multiple carriers.

With all that done, we can work with our data more efficiently. We can now easily find products with specific SKUs, Category, Tag, Supplier, or Carrier. We can also easily find all products with a particular Supplier or Carrier.

This is a lot of flexibility that we gain by following normalization rules. And I'm not even talking about the performance gains you will get - as you will have to do fewer full-text searches!

Here's what our database can look like after normalization:

And while this required a lot more tables - everything is now in its own place, and we can efficiently work with our data in any way we want.


Mistake 7: Mixing Laravel and DB Timezones

One of the biggest misconceptions about databases is timezones. People think you need to set the timezone in your application, and everything will work.

But often, we must remember that having the same settings for our database engine and our Laravel application is mandatory.

Let's look at an example:

  • Our database runs on Europe/Vilnius timezone, set in the DB settings
  • Our Laravel application runs on UTC timezone, set in the config/app.php

Running an update like this:

update posts set published_at = now() where id = 1

And then doing the same with Laravel:

Post::where('id', 12)
->update([
'published_at' => now()
]);

Will produce different results:

  • ID 1 has Europe/Vilnius timezone as it was created in the database by running a query there
  • ID 12 has UTC timezone as it was created by the Laravel application

This can lead to many issues and things that happen at unexpected times. For example, if this was a notification to be pushed out - we would have 2 different outcomes.

Now, if we set the timezone on our application and database to be the same - this will never be an issue.

Note: Avoid changing your Database or Laravel timezone settings if you already have a live application, as it will lead to incorrect times.


Mistake 8: No Timestamps or Primary Keys in Pivot Tables

When creating pivot tables, we often skip adding timestamps or primary keys. This is because we think that we don't need them. But there may be cases when it's not true. Let's look at an example:

In our system, we have products and categories. Product has many categories - the typical scenario that looks like this in our database:

It works as we would expect. But today, a new requirement came - we need to sort the categories by the time they were attached. And now - we have an issue. We have no idea when these categories were added. So what can we do here? Well, we can add timestamps to our pivot table:

Now, we can order our categories by the time they are added. This dramatically improves the potential for data access and control in your application.

A similar thing happens with primary keys - they prevent an issue where if you accidentally create a duplicate entry - you won't be able to delete it anymore. Adding a primary key gives your records an identifier and prevents this issue.

Note: While it is debatable if you should use timestamps in pivot tables - they solve more problems than they create. So, we recommend using them for all pivot tables. If not for ordering - you still can see when something was attached to something else.


Mistake 9: Not Using Unique Index on the DB Level

Often, we see people using Laravel Form Validation to validate if a field is unique like this:

$request->validate([
'invoice_number' => ['required', 'unique:invoices'],
]);

And while it is a good thing to have - this comes with a drawback. Our database is not protected!

Why is that? Well - this only protects us from the submission of forms.

But what if someone adds the entry directly to the database like we did. The same applies if you create a unique table entry using Model::create() - there is no protection. To solve this, we need to add a unique index to our database:

Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->string('invoice_number')->unique();
// ...
});

Once this is done - it will not matter if the form is submitted or you are trying to insert the data directly into the database - it will always check if the value is unique. Let's look at this example from our database query:

And if we try to do that with our Model:

Invoice::create([
'invoice_number' => '2023-11-000001'
]);

We will still get a database error:

Note: This also prevents a race condition where two or more people attempt to simultaneously create the same unique value.


Mistake 10: Forgetting Nullable - Not All Fields Are Required

One mistake that can send you into a giant rabbit hole is forgetting to make fields nullable. We often think that all fields are required, but there are a few cases where this might not be true. Let's look at both of them:

Case 1: Adding new Relationship

When you want to add a relationship to an existing site, remember that it is usually constrained by a foreign key. This means that if the relationship is not nullable, you will see issues like this in your migrations:

Why did this happen? Well, we already have data in our system, but now attempted to add another relationship like this:

Migration

Schema::table('clients', function (Blueprint $table) {
$table->foreignIdFor(ClientTag::class)->constrained();
});

This causes a problem with our old records. As we have data already:

They will not have this relationship in place (as you can see, it failed and has 0 there...), so we have to make it nullable:

Migration

Schema::table('clients', function (Blueprint $table) {
$table->foreignIdFor(ClientTag::class)->constrained();
$table->foreignIdFor(ClientTag::class)->nullable()->constrained();
});

Now, it will allow our migration to pass without triggering any errors. This common mistake can be easily avoided by ensuring all new relationships are nullable. If that is not possible, add a default value to your fields.

Case 2: Big Data Imports

Another issue that can be resolved with nullable fields - import failures. This often happens due to somehow malformed data in our CSV files. For example, if we have a CSV file like this:

id,first_name,last_name
1,John,Doe
2,Jane,Doe
3,Tom,
4,,Johnson

You can try to import it into your database, but you will get an error like this:

This happened because of two things:

First - we have first_name and last_name as non-nullable fields in our database:

Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('first_name');
$table->string('last_name');
$table->timestamps();
});

Second - we have our import script that handles missing values as null:

private function importData(array $row): void
{
Client::create([
'first_name' => $row['first_name'] ?: null,
'last_name' => $row['last_name'] ?: null,
]);
}

Both of these issues can be solved by making our fields nullable:

Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('first_name');
$table->string('last_name');
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
$table->timestamps();
});

Re-running our import script will now work as expected:

That's it. Adding nullables is a great way to avoid issues with your database. And if you think that empty values can be set to string - look at our following example!


Mistake 11: Storing Empty Strings Instead of Null

Have you ever looked at a database and seen something like this?

And then you visit migrations in the hope of understanding what is going on, but you see this:

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
$table->string('title');
$table->text('content');
$table->string('summary');
$table->string('excerpt');
$table->string('seo_title');
$table->string('seo_description');
$table->string('seo_keywords');
$table->datetime('published_at')->nullable();
$table->timestamps();
});

And you realize that all fields should have values, but none of them do. And while this works - it is not recommended as this often means - you need better database design. It often hides issues within the system, making it harder to use. Solving this is as simple as adding ->nullable() to your fields:

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
$table->string('title');
$table->text('content')->nullable();
$table->string('summary')->nullable();
$table->string('excerpt')->nullable();
$table->string('seo_title')->nullable();
$table->string('seo_description')->nullable();
$table->string('seo_keywords')->nullable();
$table->datetime('published_at')->nullable();
$table->timestamps();
});

And now, you have a database that expects columns to contain empty values. Even future developers will be happy as there is no more need for ->update(['content' => '']) hacks in your code. It can just be skipped, and it will be handled by the database itself.


Mistake 12: Rushing the Database Design

The last mistake we see constantly in forums - rushed database designs. And while this sometimes is unavoidable - it still has repeating patterns:

  • Not enough time spent on the planning phase
  • Not enough research has been done on the system
  • No plans for future development
  • Focusing on small things too much (like naming conventions)
  • Super complex queries in the system (often with no comments)
  • No documentation on the database structure
  • And so on...

These mistakes do not come from a lack of knowledge. Instead, it all comes from a viewpoint. For example, there is a big difference in choosing a relationship type. You might get away with belongsTo today, but tomorrow you might need belongsToMany. And if you did not plan for that - you will have to rewrite a lot of code. The same goes for any fields and tables in your database. Here's what we recommend:

  1. Spend more time on the planning phase. This will help you understand the system better. For example, if you are building a CRM system - you should spend time understanding what kind of data you will have and how you will use it.
  2. Look at the project UI/description first. This will drive the need for fields and tables. For example, if you are building a custom application - figure out the use cases first and then make the surrounding database.
  3. Think about the use cases. Take time to consider what you will need in the future. For example, your users can buy tickets, but what if they want to purchase multiple tickets? Or what if they're going to buy tickets for multiple people?
  4. Play around with fake queries. This helps you see the flow of data you might have. For example, take the UI page for statistics and try to build a query for it. This will help you understand what feels correct/wrong, and you can adjust from there.
  5. Focus on things that matter. This avoids the overhead you might have at an initial stage. For example, if the table is accessed a few times a day - spend less time on it. But if you have a table accessed a lot - spend more time on it.

These are just a few examples, but they all point to the same thing - spend more time planning and playing with pseudo queries to see how things feel.