How to use STI design pattern in Laravel
When modeling your domain entities, that share many common database columns but differ significantly in business logic, behavior, and relationships, you often encounter the design problem. For example, there can be multiple user roles in an enterprise application, such as Administrators, Customers, and Vendors, or several payment methods, such as CreditCards, BankTransfers, and CryptoWallets.
You cannot make separate database tables for every role or payment method; it would lead to too many similar but different table schemas, migrations, etc. At the same time, you cannot make one table with all roles and methods; you would have to write too many conditionals like if ($user->is_admin) or switch ($payment->type) in your business logic.
The solution to this kind of problem in object-oriented programming is to utilize inheritance. Even though Laravel doesn't have any built-in Single Table Inheritance (STI) keywords, it is possible to implement it in PHP by using model inheritance, booting model classes, and global scopes. We will discuss how Single Table Inheritance works in Laravel, and how to implement it in a clean way, while avoiding some common pitfalls and questions.
What is Single Table Inheritance (STI)?
Single Table Inheritance is an architectural pattern where multiple subclasses representing distinct business entities map to a single database table. A discriminator column (commonly named type, role, or class) stores the specific entity type for each record.
In our Laravel application, this means:
- We have one database table:
users.
- We have a base model:
User(mapping directly tousers).
- We have specialized child models:
Admin,Customer, andVendorextendingUser.
- Calling
Admin::all()automatically queriesSELECT * FROM users WHERE type = 'admin'.
- Calling
Admin::create([...])automatically saves the record withtype = 'admin'without manual assignment.
1. Database Schema with a Discriminator Column
Let's create a single migration for the users table containing a discriminator column (type) and fields shared across user types:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
// Discriminator column indicating the model subtype
$table->string('type')->index();
// Admin-specific fields (nullable for other types)
$table->string('department')->nullable();
// Customer-specific fields
$table->decimal('loyalty_points', 8, 2)->default(0);
// Vendor-specific fields
$table->string('company_name')->nullable();
$table->string('tax_number')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};
2. Creating the Base Model
The base User model represents the general table. It contains shared attributes, casts, and general logic common to all users:
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
// Explicitly declare table name so child classes inherit it
protected $table = 'users';
protected $fillable = [
'name',
'email',
'password',
'type',
'department',
'loyalty_points',
'company_name',
'tax_number',
];
protected $hidden = [
'password',
'remember_token',
];
}
3. Implementing Child Subclasses with Global Scopes
Now, we create the specialized child models: Admin, Customer, and Vendor. Each model extends User and uses its static booted() method to enforce two critical behaviors:
- Anonymous Global Scope: Automatically restricts all
SELECTqueries to records matching its specifictype.
- Creating Model Event: Automatically assigns the correct
typevalue onINSERToperations.
The Admin Subclass
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Admin extends User
{
/**
* The type value stored in the database.
*/
protected const TYPE = 'admin';
protected static function booted(): void
{
// 1. Auto-assign the type attribute before saving a new Admin
static::creating(function (Admin $admin) {
$admin->forceFill(['type' => self::TYPE]);
});
// 2. Filter all read queries to type = 'admin'
static::addGlobalScope('sti_admin', function (Builder $builder) {
$builder->where('type', self::TYPE);
});
}
/**
* Admin-specific relationship: Audit logs reviewed by this admin.
*/
public function auditLogs(): HasMany
{
return $this->hasMany(AuditLog::class, 'admin_id');
}
/**
* Admin-specific business behavior.
*/
public function canAccessControlPanel(): bool
{
return true;
}
}
The Customer Subclass
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Customer extends User
{
protected const TYPE = 'customer';
protected static function booted(): void
{
static::creating(function (Customer $customer) {
$customer->forceFill(['type' => self::TYPE]);
});
static::addGlobalScope('sti_customer', function (Builder $builder) {
$builder->where('type', self::TYPE);
});
}
/**
* Customer-specific relationship: Orders placed by this customer.
*/
public function orders(): HasMany
{
return $this->hasMany(Order::class, 'user_id');
}
/**
* Customer-specific business behavior.
*/
public function applyLoyaltyReward(float $amount): void
{
$this->increment('loyalty_points', $amount * 0.05);
}
}
4. Reusing STI Logic with a Trait (Best Practice)
Instead of copying and pasting the booted() method across every child model, we can encapsulate the global scope and type assignment inside a reusable PHP trait.
Create app/Models/Concerns/SingleTableInheritance.php:
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait SingleTableInheritance
{
/**
* Boot the STI trait for the model.
*/
protected static function bootSingleTableInheritance(): void
{
$type = static::getStiTypeValue();
// Scope queries to the child type
static::addGlobalScope('sti_type', function (Builder $builder) use ($type) {
$builder->where($builder->getModel()->getTable() . '.type', $type);
});
// Set the type before record creation
static::creating(function (Model $model) use ($type) {
$model->forceFill(['type' => $type]);
});
}
/**
* Get the discriminator value for this subclass.
*/
public static function getStiTypeValue(): string
{
return defined('static::STI_TYPE') ? static::STI_TYPE : strtolower(class_basename(static::class));
}
}
Now, writing the Vendor model takes just a few clean lines:
namespace App\Models;
use App\Models\Concerns\SingleTableInheritance;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Vendor extends User
{
use SingleTableInheritance;
protected const STI_TYPE = 'vendor';
public function products(): HasMany
{
return $this->hasMany(Product::class, 'vendor_id');
}
}
5. How STI Operates in Practice
With Single Table Inheritance in place, your controllers and services interact with domain models naturally without needing manual checks.
Creating Records
// Type is automatically set to 'customer'
$customer = Customer::create([
'name' => 'Rahul Sharma',
'email' => 'rahul@example.com',
'password' => bcrypt('secret123'),
]);
// Type is automatically set to 'admin'
$admin = Admin::create([
'name' => 'Anita Desai',
'email' => 'anita@example.com',
'password' => bcrypt('secret123'),
'department' => 'Finance',
]);
Querying Records
// Executes: SELECT * FROM `users` WHERE `users`.`type` = 'admin';
$admins = Admin::all();
// Executes: SELECT * FROM `users` WHERE `users`.`type` = 'customer';
$customers = Customer::where('loyalty_points', '>', 100)->get();
// Base model accesses all users across all types
// Executes: SELECT * FROM `users`;
$allUsers = User::all();
Single Table Inheritance vs. Separate Database Tables
| Factor | Single Table Inheritance (STI) | Separate Database Tables |
|---|---|---|
| Database Migrations | Single migration table. | Multiple tables with duplicate common columns. |
| Global Operations | Trivial (User::count(), single login lookup). |
Requires SQL UNION queries or multiple queries. |
| Subclass Behavior | Isolated inside dedicated model classes. | Isolated inside dedicated model classes. |
| Schema Sparsity | Subtype-specific columns must be nullable. | Columns can be strictly non-nullable. |
Important Things to Remember
- 📌 Explicit Table Property: Always declare
protected $table = 'users';on your base model. By default, Laravel derives table names using the pluralized class name (meaning Eloquent would mistakenly look for anadminstable if$tableis not specified).
- 🔒 Index the Discriminator Column: The
typecolumn will be present in every query generated by your child models. Ensure you add an index ($table->string('type')->index()) in your database migration to avoid full table scans.
- ⚡ Hydration Consideration: When querying via the parent
User::all(), Eloquent instantiates instances ofUser, not the individual child classes. If you need polymorphism on query results (e.g. turning rows intoAdminorCustomerobjects upon retrieval), you can overridenewFromBuilder()on the base model.
- ⚠️ Avoid Deep Subclass Trees: STI works best when your subclasses share 70–80% of the same columns. If your subtypes have completely disjoint schemas with dozens of unique columns, separate tables or polymorphic relations are more appropriate.
Conclusion
Single Table Inheritance allows you to have the best of both worlds: nicely normalized database and clean object oriented PHP code. With Eloquent's global scopes and model lifecycle you can hide the complexity of role based relationships and domain specific business logic behind simple classes and one DB table.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
-compressed.jpg)