PHPackages                             pannella/laravel-cti - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. pannella/laravel-cti

ActiveLibrary[Database &amp; ORM](/categories/database)

pannella/laravel-cti
====================

A Laravel package for Class Table Inheritance support with automatic subtype casting.

3.4.0(1mo ago)0506[1 issues](https://github.com/mattpannella/laravel-cti/issues)MITPHPPHP ^8.1CI passing

Since Jun 1Pushed 1mo agoCompare

[ Source](https://github.com/mattpannella/laravel-cti)[ Packagist](https://packagist.org/packages/pannella/laravel-cti)[ Docs](https://github.com/mattpannella/laravel-cti)[ RSS](/packages/pannella-laravel-cti/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (10)Dependencies (13)Versions (26)Used By (0)

Laravel CTI
===========

[](#laravel-cti)

[![Tests](https://github.com/mattpannella/laravel-cti/actions/workflows/tests.yml/badge.svg)](https://github.com/mattpannella/laravel-cti/actions/workflows/tests.yml)[![Latest Stable Version](https://camo.githubusercontent.com/e7b8ca5ab28fde3f8245c1e735d5ad90225dbd529c80385e843b154d65b3ffb0/68747470733a2f2f706f7365722e707567782e6f72672f70616e6e656c6c612f6c61726176656c2d6374692f762f737461626c65)](https://packagist.org/packages/pannella/laravel-cti)[![License](https://camo.githubusercontent.com/f79cb5aefd1250ba98f26ace4f189e26198c8fe60dfd1af3e60e8b6e488f8292/68747470733a2f2f706f7365722e707567782e6f72672f70616e6e656c6c612f6c61726176656c2d6374692f6c6963656e7365)](https://packagist.org/packages/pannella/laravel-cti)

A Laravel package for implementing Class Table Inheritance pattern with Eloquent models. Unlike Laravel's polymorphic relations which denormalize data, CTI maintains proper database normalization by storing shared attributes in a parent table and subtype-specific attributes in separate tables.

Features
--------

[](#features)

- Automatic model type resolution and instantiation
- Seamless saving/updating across parent and subtype tables
- Automatic batch-loading of subtype data (no N+1 queries)
- Support for Eloquent events and relationships
- Full type safety and referential integrity

Requirements
------------

[](#requirements)

- PHP ^8.1
- Laravel 8.x – 13.x (`illuminate/database` &gt;=8.0 &lt;14.0)

Installation
------------

[](#installation)

```
composer require pannella/laravel-cti
```

Configuration
-------------

[](#configuration)

The package works out of the box with sensible defaults — no configuration required. If you want to customize behavior, publish the config file:

```
php artisan vendor:publish --tag=cti-config
```

This creates `config/cti.php` in your application:

```
return [
    // 'exception', 'null', or 'log' (default)
    'on_missing_subtype_data' => 'log',
];
```

### Missing Subtype Data Handling

[](#missing-subtype-data-handling)

When a parent record exists but its corresponding subtype row is missing (a data integrity issue), the `on_missing_subtype_data` option controls the behavior:

ValueBehavior`'log'` *(default)*Subtype attributes remain `null`, a warning is logged, and `$model->isSubtypeDataMissing()` returns `true``'exception'`Throws `SubtypeException` immediately`'null'`Subtype attributes silently remain `null` — no warning, no flagYou can check for missing data programmatically:

```
$quiz = Quiz::find(1);

if ($quiz->isSubtypeDataMissing()) {
    // Handle the data integrity issue
}
```

Quick Start
-----------

[](#quick-start)

### Database Schema

[](#database-schema)

CTI requires three types of tables: a **lookup table** for type definitions, a **parent table** for shared attributes, and one or more **subtype tables** for type-specific attributes.

```
// Lookup table — stores the type definitions
Schema::create('assessment_types', function (Blueprint $table) {
    $table->id();
    $table->string('label')->unique(); // 'quiz', 'survey', etc.
});

// Parent table — shared attributes
Schema::create('assessments', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->foreignId('type_id')->constrained('assessment_types');
    $table->timestamps();
});

// Subtype table — quiz-specific attributes
// The primary key references the parent table's primary key
Schema::create('assessment_quiz', function (Blueprint $table) {
    $table->unsignedBigInteger('id')->primary();
    $table->integer('passing_score')->nullable();
    $table->integer('time_limit')->nullable();
    $table->boolean('show_correct_answers')->default(false);

    $table->foreign('id')->references('id')->on('assessments')->onDelete('cascade');
});

// Subtype table — survey-specific attributes
Schema::create('assessment_survey', function (Blueprint $table) {
    $table->unsignedBigInteger('id')->primary();
    $table->boolean('anonymous')->default(false);

    $table->foreign('id')->references('id')->on('assessments')->onDelete('cascade');
});
```

### Parent Model

[](#parent-model)

```
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Pannella\Cti\Traits\HasSubtypes;

class Assessment extends Model
{
    use HasSubtypes;

    // All properties are protected static
    protected static $subtypeMap = [
        'quiz' => Quiz::class,
        'survey' => Survey::class,
    ];

    protected static $subtypeKey = 'type_id';           // Discriminator column on the parent table
    protected static $subtypeLookupTable = 'assessment_types'; // Lookup table name
    protected static $subtypeLookupKey = 'id';           // Primary key in lookup table
    protected static $subtypeLookupLabel = 'label';      // Label column in lookup table

    protected $fillable = ['title', 'type_id'];
}
```

PropertyDescription`$subtypeMap`Maps type labels (from the lookup table) to subtype class names`$subtypeKey`Column on the parent table that references the lookup table`$subtypeLookupTable`Table containing type definitions`$subtypeLookupKey`Primary key column in the lookup table`$subtypeLookupLabel`Label column in the lookup table (values must match `$subtypeMap` keys)### Subtype Model

[](#subtype-model)

```
namespace App\Models;

use Pannella\Cti\SubtypeModel;

class Quiz extends SubtypeModel
{
    // IMPORTANT: $table must be set to the parent table name
    protected $table = 'assessments';

    protected $subtypeTable = 'assessment_quiz';
    protected $subtypeAttributes = [
        'passing_score',
        'time_limit',
        'show_correct_answers',
    ];

    protected $ctiParentClass = Assessment::class;

    // Only subtype attributes needed — parent's $fillable is auto-inherited
    protected $fillable = [
        'passing_score',
        'time_limit',
        'show_correct_answers',
    ];
}
```

PropertyDescription`$table`**Must** be set to the parent table name (e.g. `assessments`)`$subtypeTable`Table containing this subtype's specific fields`$subtypeAttributes`Array of column names that belong to the subtype table. **Must not overlap with parent table columns.**`$ctiParentClass`Fully-qualified class name of the parent model`$subtypeKeyName`*(Optional)* Foreign key column in the subtype table. Defaults to the parent model's primary key name (`id`)`$fillable`Only subtype attributes needed — parent attributes are auto-inherited (see below)`$inheritParentFillable`*(Optional)* Set to `false` to disable automatic `$fillable` inheritance from parent. Default: `true``$excludeParentFillable`*(Optional)* Array of parent `$fillable` attributes to exclude from inheritance#### Automatic `$fillable` and `$casts` Inheritance

[](#automatic-fillable-and-casts-inheritance)

Subtype models automatically inherit `$fillable` and `$casts` from their CTI parent model. You only need to declare subtype-specific attributes:

```
class Quiz extends SubtypeModel
{
    protected $table = 'assessments';
    protected $subtypeTable = 'assessment_quiz';
    protected $subtypeAttributes = ['passing_score', 'time_limit', 'show_correct_answers'];
    protected $ctiParentClass = Assessment::class;

    // Only subtype attributes needed — parent's $fillable is auto-inherited
    protected $fillable = [
        'passing_score',
        'time_limit',
        'show_correct_answers',
    ];

    // Only subtype casts needed — parent's $casts is auto-inherited
    protected $casts = [
        'passing_score' => 'integer',
        'show_correct_answers' => 'boolean',
    ];
}
```

**Precedence:**

- `$casts`: Parent casts are merged first, then subtype casts overlay — subtype wins on conflicts.
- `$fillable`: Parent and subtype arrays are merged and deduplicated. Existing subtypes that already list parent attributes continue to work (duplicates are removed).

**Opt-out:** Set `$inheritParentFillable = false` to disable fillable inheritance entirely. Use `$excludeParentFillable` to exclude specific parent attributes. Both options work as class properties or via the `#[Subtype]` attribute:

```
// Via class properties
class Quiz extends SubtypeModel
{
    protected bool $inheritParentFillable = false; // don't inherit any parent fillable

    // Or selectively exclude:
    // protected array $excludeParentFillable = ['description'];
}

// Via attribute
#[Subtype(
    table: 'assessment_quiz',
    attributes: ['passing_score', 'time_limit'],
    parentClass: Assessment::class,
    inheritParentFillable: false,
    // excludeParentFillable: ['description'],
)]
class Quiz extends SubtypeModel { /* ... */ }
```

> **Note:** `$guarded` is never merged — it's a security boundary and must be set explicitly on each model.

The discriminator column (`type_id`) is **auto-assigned on create** — you don't need to set it manually. The `BootsSubtypeModel` trait looks up the correct value from the lookup table based on the `$subtypeMap`. If the discriminator is already set, it won't be overridden.

### PHP 8.1 Attribute-Based Configuration

[](#php-81-attribute-based-configuration)

As an alternative to class properties, you can configure CTI models using PHP 8.1 attributes. This keeps all configuration in a single, declarative annotation at the top of your class.

**Parent model with `#[SubtypeConfig]`:**

```
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Pannella\Cti\Attributes\SubtypeConfig;
use Pannella\Cti\Traits\HasSubtypes;

#[SubtypeConfig(
    map: ['quiz' => Quiz::class, 'survey' => Survey::class],
    key: 'type_id',
    lookupTable: 'assessment_types',
    lookupKey: 'id',
    lookupLabel: 'label',
)]
class Assessment extends Model
{
    use HasSubtypes;

    protected $fillable = ['title', 'type_id'];
}
```

**Subtype model with `#[Subtype]`:**

```
namespace App\Models;

use Pannella\Cti\Attributes\Subtype;
use Pannella\Cti\SubtypeModel;

#[Subtype(
    table: 'assessment_quiz',
    attributes: ['passing_score', 'time_limit', 'show_correct_answers'],
    parentClass: Assessment::class,
    keyName: 'assessment_id',
)]
class Quiz extends SubtypeModel
{
    // $table must still be set to the parent table name
    protected $table = 'assessments';

    // Only subtype attributes needed — parent's $fillable is auto-inherited
    protected $fillable = [
        'passing_score',
        'time_limit',
        'show_correct_answers',
    ];
}
```

AttributeTargetParameters`#[SubtypeConfig]`Parent model`map`, `key`, `lookupTable`, `lookupKey`, `lookupLabel``#[Subtype]`Subtype model`table`, `attributes`, `parentClass`, `keyName` (optional), `inheritParentFillable` (optional), `excludeParentFillable` (optional)**Precedence:** When both a class property and an attribute are defined, the **property takes precedence**. This lets you use attributes as defaults and override individual values with properties when needed.

**Performance:** Attribute resolution uses reflection, but results are cached per-class for the lifetime of the request. There is no performance difference after the first access.

### Using the Models

[](#using-the-models)

Subtype data is loaded automatically whenever models are fetched via `get()`, `paginate()`, `find()`, `all()`, etc.

**Important:** Subtype models automatically filter queries by their discriminator value. For example, `Quiz::all()` only returns records where `type_id` matches the quiz type — it will not return surveys. The parent model (`Assessment::all()`) returns all records and morphs them into the correct subtype instances.

```
// Querying subtype models only returns records of that type
$quizzes = Quiz::all();     // Only quizzes (type_id = 1)
$surveys = Survey::all();   // Only surveys (type_id = 2)

// Parent model returns ALL records, morphed into correct subtype instances
$assessments = Assessment::all();  // Returns Quiz and Survey instances

// Each model is an instance of the correct subtype class
$assessments->first() instanceof Quiz; // true or false depending on type_id

// You can remove the discriminator filter if needed
$allRecords = Quiz::withoutGlobalScope(\Pannella\Cti\Support\SubtypeDiscriminatorScope::class)->get();

// Pagination works seamlessly — subtype data is batch-loaded for the page
$quizzes = Quiz::paginate(15);  // Only quizzes

// Create new subtype instance
$quiz = new Quiz();
$quiz->title = 'Final Exam';        // parent attribute
$quiz->passing_score = 80;          // subtype attribute
$quiz->save();                      // saves to both tables in a transaction

// Or use mass assignment
$quiz = Quiz::create([
    'title' => 'Final Exam',
    'passing_score' => 80,
    'time_limit' => 60,
]);

// Load single instance
$quiz = Quiz::find(1);              // hydrates both parent and subtype data

// Update existing
$quiz->time_limit = 60;
$quiz->save();                      // updates only modified tables

// Delete — removes subtype row first, then parent row
$quiz->delete();
```

Relationships
-------------

[](#relationships)

### Subtype Relationships

[](#subtype-relationships)

Define relationships that use the subtype's foreign key with the convenience methods provided by the `HasSubtypeRelations` trait (included in `SubtypeModel`):

```
class Quiz extends SubtypeModel
{
    protected $table = 'assessments';
    protected $subtypeTable = 'assessment_quiz';
    protected $subtypeAttributes = ['passing_score', 'time_limit', 'show_correct_answers'];
    protected $ctiParentClass = Assessment::class;

    public function questions(): HasMany
    {
        // FK defaults to $subtypeKeyName (the subtype table's FK column)
        return $this->subtypeHasMany(Question::class);
    }

    public function gradingRubric(): HasOne
    {
        return $this->subtypeHasOne(GradingRubric::class);
    }

    public function instructor(): BelongsTo
    {
        return $this->subtypeBelongsTo(Instructor::class);
    }

    public function tags(): BelongsToMany
    {
        return $this->subtypeBelongsToMany(Tag::class);
    }
}
```

Available methods:

MethodDescription`subtypeHasOne($related, $foreignKey?, $localKey?)`One-to-one from subtype`subtypeHasMany($related, $foreignKey?, $localKey?)`One-to-many from subtype`subtypeBelongsTo($related, $foreignKey?, $ownerKey?)`Inverse one-to-one/many`subtypeBelongsToMany($related, $table?, $foreignPivotKey?, $relatedPivotKey?, $parentKey?, $relatedKey?)`Many-to-many from subtypeAll of these default the foreign key to `$subtypeKeyName` (which itself defaults to the parent model's primary key name).

### Parent Relationship Inheritance

[](#parent-relationship-inheritance)

Relationships defined on the parent model are automatically accessible from subtype instances via the `__call` proxy:

```
// Defined on Assessment (parent)
class Assessment extends Model
{
    use HasSubtypes;
    // ...

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

// Accessible from Quiz (subtype) without redefining it
$quiz = Quiz::find(1);
$quiz->tags; // works — proxied to Assessment::tags()
```

### Standard Eloquent Alternative

[](#standard-eloquent-alternative)

You can also use standard Eloquent relationship methods with an explicit foreign key instead of the `subtype*` convenience methods:

```
public function questions(): HasMany
{
    return $this->hasMany(Question::class, 'assessment_id');
}
```

Query Builder
-------------

[](#query-builder)

The `SubtypeQueryBuilder` automatically joins the subtype table whenever a subtype column is referenced in a query. No manual joins needed.

```
// Auto-joins assessment_quiz table because passing_score is a subtype attribute
$quizzes = Quiz::where('passing_score', '>', 70)->get();

// Chain multiple conditions across both tables
$quizzes = Quiz::where('passing_score', '>', 70)
    ->where('title', 'like', '%Final%')   // parent column — no join needed
    ->orderBy('time_limit', 'desc')        // subtype column — join added once
    ->get();

// Aggregates work too
$avg = Quiz::avg('passing_score');
```

### Supported Methods

[](#supported-methods)

The following query builder methods support automatic subtype joins:

MethodExample`where` / `orWhere``Quiz::where('passing_score', '>', 70)``whereIn` / `orWhereIn``Quiz::whereIn('passing_score', [70, 80, 90])``whereNotIn` / `orWhereNotIn``Quiz::whereNotIn('time_limit', [30, 60])``whereNull` / `orWhereNull``Quiz::whereNull('time_limit')``whereNotNull` / `orWhereNotNull``Quiz::whereNotNull('passing_score')``whereColumn` / `orWhereColumn``Quiz::whereColumn('passing_score', '>', 'time_limit')``whereBetween` / `orWhereBetween``Quiz::whereBetween('passing_score', [60, 100])``whereNotBetween` / `orWhereNotBetween``Quiz::whereNotBetween('passing_score', [0, 50])``whereDate` / `whereYear` / `whereMonth` / `whereDay``Quiz::whereDate('created_at', '2024-01-01')``orderBy` / `orderByDesc``Quiz::orderBy('time_limit')``latest` / `oldest``Quiz::latest('time_limit')``groupBy``Quiz::groupBy('passing_score')``having``Quiz::groupBy('passing_score')->having('passing_score', '>', 70)``select` / `addSelect``Quiz::select('title', 'passing_score')``pluck` / `value``Quiz::pluck('passing_score')``update` (mass)`Quiz::where('passing_score', '
