phl/laravel-sti
phl/laravel-sti brings Single Table Inheritance to Laravel Eloquent. Map multiple model subclasses to one database table using a type column, with automatic class resolution, scoped queries, and familiar Eloquent relationships and behavior for cleaner hierarchical models instead of separate tables.
Install the package and publish its config:
composer require phl/laravel-sti
php artisan vendor:publish --tag=laravel-sti
Add the discriminator column to the table that will back the hierarchy. The default column name is type (confirm in config/laravel-sti.php):
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('type')->index(); // discriminator
$table->string('title');
$table->text('body')->nullable();
$table->string('video_url')->nullable(); // only used by Video
$table->timestamps();
});
Create a base model that extends the package's StiModel, then extend it for each concrete type:
use PaulhenriL\LaravelSti\StiModel;
class Post extends StiModel
{
protected $table = 'posts';
}
class Article extends Post {}
class Video extends Post {}
First use case to validate the setup: create one of each child, then confirm that Post::all() returns a mixed collection hydrated as Article/Video, while Article::all() only returns articles.
Article::create(['title' => 'Hello', 'body' => '...']);
Video::create(['title' => 'Demo', 'video_url' => '...']);
Post::count(); // 2
Article::count(); // 1
Where to look first: the README for the exact config keys, then src/StiModel.php — it is small and contains the entire type-resolution and query-scoping behaviour. Read it before building on top of it; that file is the source of truth when the README and reality diverge.
Use the base model as the "all types" query root. Controllers and dashboards that need every row should type-hint Post; anything type-specific should use the child. Keep this convention consistent, otherwise you will scatter where('type', ...) calls through the codebase and defeat the package.
Relationships. Point relation properties at the base class when you want polymorphic behaviour by default, and at the child when you want a narrow set:
class Comment extends Model
{
public function commentable() { return $this->morphTo(); }
}
class Post extends StiModel
{
public function comments() { return $this->morphMany(Comment::class, 'commentable'); }
}
$post->comments works for articles and videos identically, because the morph type stored is the base class as long as you always attach through the base model.
Factories and seeders. Child factories should set the discriminator correctly. If the package sets it on create(), use Article::factory(); if it does not, add 'type' => Article::class to the definition and verify in a test that Article::query()->count() reflects the created rows.
Scopes and query building. Global and local scopes defined on the base model are inherited by children. Define narrow, reusable scopes (scopePublished, scopeFeatured) on the base class so they apply to the whole hierarchy; define type-specific scopes on the child.
Route model binding. Binding to a child implicitly filters by type, so /videos/{video} will 404 for an article id instead of exposing the wrong resource. This is usually desirable; if you need cross-type URLs, bind to the base model and validate the concrete class in a policy or form request.
Migrations. Keep columns for all subtypes on the single table and let them be nullable. Document which columns belong to which subtype in the migration comments — this is the main long-term maintenance cost of STI.
Mass assignment. Keep type out of $fillable. Even if the package protects it, treat it as immutable after creation; nothing good comes from reassigning a row's class at runtime.
newInstance, newFromBuilder, newEloquentBuilder, return types). Expect to vendor the package into app/ or fork it rather than rely on a tagged release, and add tests around hydration immediately.Video would look for videos. Either the package resolves the table from the root parent or you must set protected $table on the base and every child. If you get "table not found" errors, set $table explicitly everywhere.getStiType() returning a short slug) and add a data migration if you change your mind.DB::table('posts')->get() returns plain rows, not subclass instances. Any reporting, exports, or upsert/insert code must set type manually.withoutGlobalScopes() is dangerous here. Dropping scopes exposes the whole hierarchy to a child query and can hydrate rows into the wrong class or produce type errors. Grep for it in code review.protected $hidden = ['type']) unless the frontend actually uses it, and be aware toArray() on a base-model collection will contain a mix of child attribute sets.(type, created_at) for lists. STI queries almost always start with the type filter.php artisan config:clear; stale cached config produces confusing "column not found" errors.Post::count()) as well as the child. A passing child test does not prove hydration of mixed collections works.tighten/parental is the actively maintained STI package in the Laravel ecosystem and covers the same ground with better framework support. Only choose phl/laravel-sti if its specific API fits and you are prepared to maintain a fork.How can I help you explore Laravel packages today?