Implementing Single Table Inheritance (STI) in Laravel with Global Scopes

How to use STI design pattern in Laravel

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 to users).
  • We have specialized child models: Admin, Customer, and Vendor extending User.
  • Calling Admin::all() automatically queries SELECT * FROM users WHERE type = 'admin'.
  • Calling Admin::create([...]) automatically saves the record with type = '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 SELECT queries to records matching its specific type.
  • Creating Model Event: Automatically assigns the correct type value on INSERT operations.

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 an admins table if $table is not specified).
  • 🔒 Index the Discriminator Column: The type column 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 of User, not the individual child classes. If you need polymorphism on query results (e.g. turning rows into Admin or Customer objects upon retrieval), you can override newFromBuilder() 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 💬


Previous Post Next Post

Contact Form