Hello! Welcome back to our course on mastering Laravel.
In the last lesson, we successfully translated our database blueprint into a physical structure using Laravel migrations. We created the users, products, categories, orders, and our pivot tables. However, as we left it, these tables are like separate islands—they exist, but the database has no formal understanding of how they relate to one another.
Today, we will build the bridges between those islands. This lesson focuses on how to define foreign key constraints and indexes within migrations. By the end of our session, you will be able to:
- Enforce data relationships and integrity at the database level using foreign key constraints.
- Improve the performance of future database queries by strategically adding indexes.
This is a critical step in building a robust and scalable application. Let's get started.
1. Foreign Key Constraints: Enforcing Data Integrity
First, let's understand why we need foreign keys. In our last lesson, we added a user_id column to the orders table. Logically, we know this should point to a valid ID in the users table. But without a foreign key constraint, the database doesn't enforce this. A user could be deleted, leaving "orphaned" orders with an invalid user_id.
Foreign keys prevent this by creating a rule at the database level: an action that would break the link (like deleting a parent record) will be blocked or handled according to a specific policy.
Top 5 Mistakes in Laravel DB Migrations
To see the real-world consequence of not using foreign keys, let's watch this segment from a Laravel Daily video. It clearly demonstrates how easily data can become corrupted without them.
Watch the section from 09:16 to 12:20. Pay close attention to what happens when a category is deleted without a foreign key in place, and how the database throws an error to protect data integrity once the constraint is added.
As the video shows, relying only on application-level checks is risky. A database-level constraint is your ultimate safety net.
Defining Foreign Keys in Laravel
Laravel provides a fluent and expressive syntax for adding foreign keys. The modern, conventional approach is the most common.
Laravel Foreign Key Migrations: Bugs to Avoid and Syntax To Know
This video quickly covers the modern syntax for creating foreign keys, which you'll use most of the time.
Watch from 00:31 to 01:35. The video explains the foreignId() and constrained() methods, which are the standard way to define foreign keys in new Laravel applications.
To summarize, the best practice is to use:
$table->foreignId('user_id')->constrained();
Let's break this down:
foreignId('user_id'): This is a shortcut that creates anunsignedBigIntegercolumn nameduser_id.constrained(): This method inspects the column name (user_id), infers the table it references (users), and the column it references (id). It then creates the foreign key constraint.
If your table or column names don't follow Laravel's conventions, you can specify them manually:$table->foreignId('author_id')->constrained(table: 'users');
A Common Pitfall: Data Type Mismatches
One of the most frequent errors when adding foreign keys is a mismatch between the data types of the primary key and the foreign key columns. By default, $table->id() in modern Laravel creates a BIGINT primary key. Therefore, your foreign key column must also be a BIGINT.
The foreignId() helper handles this for you. However, if you are working with older code or defining columns manually, this is a crucial detail to remember.
Laravel Foreign Key Migrations: Bugs to Avoid and Syntax To Know
This is a critical point that can cause a lot of frustration. This clip explains the importance of matching data types (integer vs. bigInteger) perfectly.
Watch from 02:05 to 03:55. This will save you a lot of debugging time in the future. The key is that the foreign key's type and signedness must identically match the parent's primary key.
Referential Actions: onDelete() and onUpdate()
What should happen if a user with existing orders is deleted? The default behavior is to RESTRICT the deletion, causing a database error. Often, you want a different behavior. You can define this with referential action methods.
// If a user is deleted, delete all their posts too.
$table->foreignId('user_id')->constrained()->onDelete('cascade');
// If a user is deleted, set the user_id on their posts to NULL.
// This requires the user_id column to be nullable!
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
Notice that column modifiers like ->nullable() must be placed before the ->constrained() method.
The official documentation provides a full list of these actions.
Database: Migrations - Foreign Key Constraints
The official Laravel documentation is the definitive source for all constraint options. Let's look at the section on foreign keys.
Read the section titled 'Foreign Key Constraints'. Focus on the different ways to define constraints, how to specify non-conventional table names, and the table listing the expressive syntax for 'on delete' and 'on update' actions (e.g., cascadeOnDelete()).
Your Turn: Add Constraints to Your Schema
Following the golden rule from our last lesson, we will not modify our existing migrations. Instead, we'll create new ones to add the foreign keys.
-
Generate the new migration files:
php artisan make:migration add_foreign_keys_to_orders_table --table=orders php artisan make:migration add_foreign_keys_to_order_product_table --table=order_product php artisan make:migration add_foreign_keys_to_category_product_table --table=category_productUsing the
--tableflag pre-fills the migration withSchema::table()boilerplate, which is what we need to modify existing tables. -
Define the constraints:
Open theadd_foreign_keys_to_orders_tablemigration and modify theup()method. We want theuser_idinordersto reference theidin theuserstable. If a user is deleted, we don't want to lose their order history, so we'll restrict the delete.// In database/migrations/YYYY_MM_DD_HHMMSS_add_foreign_keys_to_orders_table.php public function up(): void { Schema::table('orders', function (Blueprint $table) { $table->foreign('user_id')->references('id')->on('users')->onDelete('restrict'); // An alternative using conventions, assuming user_id was already created as unsignedBigInteger: // $table->foreignId('user_id')->constrained()->onDelete('restrict'); }); } public function down(): void { Schema::table('orders', function (Blueprint $table) { // Drop by constraint name convention: table_column_foreign $table->dropForeign('orders_user_id_foreign'); }); } -
Challenge: Now, apply this to the pivot tables (
order_productandcategory_product). In these cases, if a product or an order is deleted, the corresponding entry in the pivot table is no longer relevant. Acascadedelete is appropriate here.- In
order_product, add foreign keys fororder_idandproduct_id. - In
category_product, add foreign keys forcategory_idandproduct_id. - Remember to implement the
down()method in each migration to drop the constraints.
- In
2. Indexes: Optimizing Query Performance
An index is a database structure that improves the speed of data retrieval operations on a table at the cost of additional writes and storage space. Think of it like the index at the back of a book: instead of scanning every page to find a topic, you look it up in the index and go directly to the right page.
Foreign keys are automatically indexed by most database systems, including MSSQL. But you often need to add indexes to other columns that you plan to search or filter by frequently.
Optimizing Laravel Part 2: Improving Query Performance
This article provides a great introduction to the concept of database indexing and why it's important for performance.
Read the sections 'What is a Database Index?' and 'Adding a Database Index'. Focus on the book index analogy and the Laravel syntax for adding a single-column index ($table->index('user_id');) and a compound index ($table->index(['user_id', 'created_at']);).
The Impact of an Index
Adding an index can have a dramatic effect on performance, especially with large datasets.
Top 5 Mistakes in Laravel DB Migrations
Let's revisit the 'Top 5 Mistakes' video to see a practical demonstration of an index's impact. This segment shows a 'before and after' comparison that makes the performance benefit very clear.
Watch the final segment of the video from 13:06 to 15:28. Notice how adding a compound index cuts the query time in half for a specific dashboard widget.
Defining Indexes in Laravel
The Schema builder makes adding indexes simple. You typically do this when creating the table, but you can also add them later in a separate migration.
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique(); // A unique index
$table->string('password');
$table->timestamps();
});
Schema::table('products', function (Blueprint $table) {
// Add a basic index for faster searching by title
$table->index('title');
// Add a compound index for queries that filter by price AND stock
$table->index(['price', 'stock_quantity']);
});
Since you're focusing on MSSQL, it's worth noting a specific feature Laravel supports. You can create an index "online" without locking the table, which is useful for large production tables.
// In a migration for a SQL Server database
$table->string('email')->unique()->online();
The official documentation contains a full list of index types.
Database: Migrations - Indexes
Let's consult the documentation for a complete reference on creating and managing indexes.
Read the sections 'Creating Indexes', 'Renaming Indexes', and 'Dropping Indexes'. Pay attention to the table of 'Available Index Types' and the syntax for dropping indexes, which requires knowing the index name.
Your Turn: Add Indexes to Your Schema
Let's add a few strategic indexes to our tables.
-
Generate a migration:
php artisan make:migration add_indexes_to_products_and_orders_tables -
Define the indexes: In the new migration's
up()method, let's add indexes to columns we are likely to filter by.// In database/migrations/YYYY_MM_DD_HHMMSS_add_indexes... public function up(): void { Schema::table('products', function (Blueprint $table) { $table->index('price'); }); Schema::table('orders', function (Blueprint $table) { $table->index('status'); }); } public function down(): void { Schema::table('products', function (Blueprint $table) { // Laravel's default index name: table_column_index $table->dropIndex('products_price_index'); }); Schema::table('orders', function (Blueprint $table) { $table->dropIndex('orders_status_index'); }); } -
Run your migrations:
php artisan migrate
Check your database using a tool like TablePlus or DBeaver. You should now see the foreign key constraints and the new indexes on your tables.
Conclusion
Excellent work! You have now created a complete, relational, and optimized database schema. This structure not only protects your data's integrity but also lays the foundation for a high-performance application.
Key Takeaways:
- Foreign Keys Enforce Integrity: Use
$table->foreignId('column')->constrained()as the standard way to create relationships and prevent orphaned records. - Referential Actions Matter: Use
onDelete('cascade')oronDelete('set null')to define what happens when a parent record is removed. - Indexes Boost Performance: Add indexes (
$table->index()) to any non-key column that is frequently used inWHERE,ORDER BY, orJOINclauses. - Migrations are for Evolution: Always create new migrations to add constraints or indexes to existing tables.
Next Up:
Our database schema is now complete, but it's empty. To start building and testing our application, we need data—preferably, lots of it. In the next lesson, we will learn how to seed the database with realistic test data using model factories and seeders. This will allow us to populate our tables with thousands of users, products, and orders in a single command.