Repository: laravel-shift/blueprint (php)

Issue:
Ability to customize table name
The default convention is good. but when we want to use a non-English model name, we need to have an ability to specify the name of our table.



Pull request:
Introduce `meta` key for customizations
This PR adds basic support for a `meta` key to be defined on models. If the `meta` value is a string, it will continue to be used as a column definition. When it is an array, it will be used as additional configuration to customize the model.

Currently, supported keys are `pivot` and `table` to create a [pivot model](https://laravel.com/docs/9.x/eloquent-relationships#defining-custom-intermediate-table-models) and customize the table name, respectively.

For example:

```yaml
models:
  Membership:
    meta:
      pivot: true
      table: team_user
    user_id: id
    team_id: id
```

This lays the foundation to support other customizations, such as class inheritance, controller middleware, authorization policies, etc.

---
Closes #305 and #328

Patch:
diff --git a/src/Generators/MigrationGenerator.php b/src/Generators/MigrationGenerator.php
index bfd65d7..b7a8527 100644
--- a/src/Generators/MigrationGenerator.php
+++ b/src/Generators/MigrationGenerator.php
@@ -161,7 +161,12 @@ class MigrationGenerator extends AbstractClassGenerator implements Generator
             if ($column->name() === 'id' && $dataType === 'id') {
                 $dataType = 'bigIncrements';
             } elseif ($dataType === 'id') {
-                $dataType = 'unsignedBigInteger';
+                if ($model->isPivot()) {
+                    // TODO: what if constraints are enabled?
+                    $dataType = 'foreignId';
+                } else {
+                    $dataType = 'unsignedBigInteger';
+                }
             }
 
             if (in_array($dataType, self::UNSIGNABLE_TYPES) && in_array('unsigned', $column->modifiers())) {
@@ -404,24 +409,18 @@ class MigrationGenerator extends AbstractClassGenerator implements Generator
 
         if ($overwrite) {
             $migrations = collect($this->filesystem->files($dir))
-                ->filter(
-                    fn (SplFileInfo $file) => str_contains($file->getFilename(), $name)
-                )
+                ->filter(fn (SplFileInfo $file) => str_contains($file->getFilename(), $name))
                 ->sort();
 
             if ($migrations->isNotEmpty()) {
                 $migration = $migrations->first()->getPathname();
 
                 $migrations->diff($migration)
-                    ->each(
-                        function (SplFileInfo $file) {
-                            $path = $file->getPathname();
-
-                            $this->filesystem->delete($path);
-
-                            $this->output['deleted'][] = $path;
-                        }
-                    );
+                    ->each(function (SplFileInfo $file) {
+                        $path = $file->getPathname();
+                        $this->filesystem->delete($path);
+                        $this->output['deleted'][] = $path;
+                    });
 
                 return $migration;
             }
@@ -430,22 +429,10 @@ class MigrationGenerator extends AbstractClassGenerator implements Generator
         return $dir . $timestamp->format('Y_m_d_His') . $name;
     }
 
-    protected function getPivotClassName(array $segments)
-    {
-        return 'Create' . Str::studly($this->getPivotTableName($segments)) . 'Table';
-    }
-
-    protected function getPolyClassName(string $parentTable)
-    {
-        return 'Create' . Str::studly($this->getPolyTableName($parentTable)) . 'Table';
-    }
-
     protected function getPivotTableName(array $segments)
     {
         $isCustom = collect($segments)
-            ->filter(
-                fn ($segment) => Str::contains($segment, ':')
-            )->first();
+            ->filter(fn ($segment) => Str::contains($segment, ':'))->first();
 
         if ($isCustom) {
             $table = Str::after($isCustom, ':');
@@ -453,10 +440,7 @@ class MigrationGenerator extends AbstractClassGenerator implements Generator
             return $table;
         }
 
-        $segments = array_map(
-            fn ($name) => Str::snake($name),
-            $segments
-        );
+        $segments = array_map(fn ($name) => Str::snake($name), $segments);
         sort($segments);
 
         return strtolower(implode('_', $segments));
@@ -492,9 +476,7 @@ class MigrationGenerator extends AbstractClassGenerator implements Generator
         }
 
         return collect(self::UNSIGNABLE_TYPES)
-            ->contains(
-                fn ($value) => strtolower($value) === strtolower($type)
-            );
+            ->contains(fn ($value) => strtolower($value) === strtolower($type));
     }
 
     protected function isIdOrUuid(string $dataType)
diff --git a/src/Generators/ModelGenerator.php b/src/Generators/ModelGenerator.php
index 205bdc5..2ae65f5 100644
--- a/src/Generators/ModelGenerator.php
+++ b/src/Generators/ModelGenerator.php
@@ -30,8 +30,22 @@ class ModelGenerator extends AbstractClassGenerator implements Generator
         return $this->output;
     }
 
+    private function pivotColumns(array $columns, array $relationships): array
+    {
+        // TODO: ideally restrict to only "belongsTo" columns used for pivot relationship
+        return collect($columns)
+            ->map(fn ($column) => $column->name())
+            ->reject(fn ($column) => in_array($column, ['created_at', 'updated_at']) || in_array($column, $relationships['belongsTo'] ?? []))
+            ->all();
+    }
+
     protected function populateStub(string $stub, Model $model)
     {
+        if ($model->isPivot()) {
+            $stub = str_replace('class {{ class }} extends Model', 'class {{ class }} extends Pivot', $stub);
+            $stub = str_replace('use Illuminate\\Database\\Eloquent\\Model;', 'use Illuminate\\Database\\Eloquent\\Relations\\Pivot;', $stub);
+        }
+
         $stub = str_replace('{{ namespace }}', $model->fullyQualifiedNamespace(), $stub);
         $stub = str_replace(PHP_EOL . 'class {{ class }}', $this->buildClassPhpDoc($model) . PHP_EOL . 'class {{ class }}', $stub);
         $stub = str_replace('{{ class }}', $model->name(), $stub);
@@ -103,34 +117,42 @@ class ModelGenerator extends AbstractClassGenerator implements Generator
 
     protected function buildProperties(Model $model)
     {
-        $properties = '';
+        $properties = [];
+
+        if ($model->usesCustomTableName() || $model->isPivot()) {
+            $properties[] = str_replace('{{ name }}', $model->tableName(), $this->filesystem->stub('model.table.stub'));
+        }
 
         if (!$model->usesTimestamps()) {
-            $properties .= $this->filesystem->stub('model.timestamps.stub');
+            $properties[] = $this->filesystem->stub('model.timestamps.stub');
+        }
+
+        if ($model->isPivot() && $model->usesPrimaryKey()) {
+            $properties[] = $this->filesystem->stub('model.incrementing.stub');
         }
 
         if (config('blueprint.use_guarded')) {
-            $properties .= $this->filesystem->stub('model.guarded.stub');
+            $properties[] = $this->filesystem->stub('model.guarded.stub');
         } else {
             $columns = $this->fillableColumns($model->columns());
             if (!empty($columns)) {
-                $properties .= PHP_EOL . str_replace('[]', $this->pretty_print_array($columns, false), $this->filesystem->stub('model.fillable.stub'));
+                $properties[] = str_replace('[]', $this->pretty_print_array($columns, false), $this->filesystem->stub('model.fillable.stub'));
             } else {
-                $properties .= $this->filesystem->stub('model.fillable.stub');
+                $properties[] = $this->filesystem->stub('model.fillable.stub');
             }
         }
 
         $columns = $this->hiddenColumns($model->columns());
         if (!empty($columns)) {
-            $properties .= PHP_EOL . str_replace('[]', $this->pretty_print_array($columns, false), $this->filesystem->stub('model.hidden.stub'));
+            $properties[] = str_replace('[]', $this->pretty_print_array($columns, false), $this->filesystem->stub('model.hidden.stub'));
         }
 
         $columns = $this->castableColumns($model->columns());
         if (!empty($columns)) {
-            $properties .= PHP_EOL . str_replace('[]', $this->pretty_print_array($columns), $this->filesystem->stub('model.casts.stub'));
+            $properties[] = str_replace('[]', $this->pretty_print_array($columns), $this->filesystem->stub('model.casts.stub'));
         }
 
-        return trim($properties);
+        return trim(implode(PHP_EOL, array_filter($properties, fn ($property) => !empty(trim($property)))));
     }
 
     protected function buildRelationships(Model $model)
@@ -146,6 +168,7 @@ class ModelGenerator extends AbstractClassGenerator implements Generator
         foreach ($model->relationships() as $type => $references) {
             foreach ($references as $reference) {
                 $is_model_fqn = Str::startsWith($reference, '\\');
+                $is_pivot = false;
 
                 $custom_template = $template;
                 $key = null;
@@ -157,7 +180,13 @@ class ModelGenerator extends AbstractClassGenerator implements Generator
                 if (Str::contains($reference, ':')) {
                     [$foreign_reference, $column_name] = explode(':', $reference);
 
-                    $method_name = Str::beforeLast($column_name, '_id');
+                    if (Str::startsWith($column_name, '&')) {
+                        $is_pivot = true;
+                        $column_name = Str::after($column_name, '&');
+                        $method_name = $column_name;
+                    } else {
+                        $method_name = Str::beforeLast($column_name, '_id');
+                    }
 
                     if (Str::contains($foreign_reference, '.')) {
                         [$class, $key] = explode('.', $foreign_reference);
@@ -192,7 +221,22 @@ class ModelGenerator extends AbstractClassGenerator implements Generator
                 } elseif (!is_null($key)) {
                     $relationship = sprintf('$this->%s(%s::class, \'%s\', \'%s\')', $type, $fqcn, $column_name, $key);
                 } elseif (!is_null($class) && $type === 'belongsToMany') {
-                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, $fqcn, $column_name);
+                    if ($is_pivot) {
+                        $relationship = sprintf('$this->%s(%s::class)', $type, $fqcn);
+                        $relationship .= sprintf('%s->using(%s::class)', PHP_EOL . str_pad(' ', 12), $column_name);
+                        $relationship .= sprintf('%s->as(\'%s\')', PHP_EOL . str_pad(' ', 12), Str::snake($column_name));
+
+                        $foreign = $this->tree->modelForContext($column_name);
+                        $columns = $this->pivotColumns($foreign->columns(), $foreign->relationships());
+                        if ($columns) {
+                            $relationship .= sprintf('%s->withPivot(\'%s\')', PHP_EOL . str_pad(' ', 12), implode("', '", $columns));
+                        }
+                        if ($foreign->usesTimestamps()) {
+                            $relationship .= sprintf('%s->withTimestamps()', PHP_EOL . str_pad(' ', 12));
+                        }
+                    } else {
+                        $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, $fqcn, $column_name);
+                    }
                     $column_name = $class;
                 } else {
                     $relationship = sprintf('$this->%s(%s::class)', $type, $fqcn);
diff --git a/src/Lexers/ModelLexer.php b/src/Lexers/ModelLexer.php
index 3e7d073..8f2fcb7 100644
--- a/src/Lexers/ModelLexer.php
+++ b/src/Lexers/ModelLexer.php
@@ -130,6 +130,18 @@ class ModelLexer implements Lexer
     {
         $model = new Model($name);
 
+        if (isset($columns['meta']) && is_array($columns['meta'])) {
+            if (isset($columns['meta']['table'])) {
+                $model->setTableName($columns['meta']['table']);
+            }
+
+            if (!empty($columns['meta']['pivot'])) {
+                $model->setPivot();
+            }
+
+            unset($columns['meta']);
+        }
+
         if (isset($columns['id'])) {
             if ($columns['id'] === false) {
                 $model->disablePrimaryKey();
diff --git a/src/Models/Model.php b/src/Models/Model.php
index dc41d57..5d5e6fa 100644
--- a/src/Models/Model.php
+++ b/src/Models/Model.php
@@ -11,12 +11,16 @@ class Model implements BlueprintModel
 
     private $namespace;
 
+    private $pivot = false;
+
     private $primaryKey = 'id';
 
     private $timestamps = 'timestamps';
 
     private $softDeletes = false;
 
+    private $table;
+
     private $columns = [];
 
     private $relationships = [];
@@ -100,9 +104,29 @@ class Model implements BlueprintModel
         $this->primaryKey = false;
     }
 
+    public function isPivot()
+    {
+        return $this->pivot;
+    }
+
+    public function setPivot()
+    {
+        $this->pivot = true;
+    }
+
+    public function usesCustomTableName()
+    {
+        return isset($this->table);
+    }
+
     public function tableName()
     {
-        return Str::snake(Str::pluralStudly($this->name));
+        return $this->table ?? Str::snake(Str::pluralStudly($this->name));
+    }
+
+    public function setTableName($name)
+    {
+        $this->table = $name;
     }
 
     public function timestampsDataType(): string
@@ -173,6 +197,10 @@ class Model implements BlueprintModel
 
     public function addPivotTable(string $reference)
     {
+        if (str_contains($reference, ':&')) {
+            return;
+        }
+
         $segments = [$this->name(), class_basename($reference)];
         sort($segments);
         $this->pivotTables[] = $segments;
diff --git a/stubs/model.incrementing.stub b/stubs/model.incrementing.stub
new file mode 100644
index 0000000..8718fdd
--- /dev/null
+++ b/stubs/model.incrementing.stub
@@ -0,0 +1,6 @@
+    /**
+     * Indicates if the IDs are auto-incrementing.
+     *
+     * @var bool
+     */
+    public $incrementing = true;
diff --git a/stubs/model.table.stub b/stubs/model.table.stub
new file mode 100644
index 0000000..c800008
--- /dev/null
+++ b/stubs/model.table.stub
@@ -0,0 +1,6 @@
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = '{{ name }}';


Test patch:
diff --git a/tests/Feature/Generators/MigrationGeneratorTest.php b/tests/Feature/Generators/MigrationGeneratorTest.php
index 86d49f9..3670655 100644
--- a/tests/Feature/Generators/MigrationGeneratorTest.php
+++ b/tests/Feature/Generators/MigrationGeneratorTest.php
@@ -660,6 +660,37 @@ class MigrationGeneratorTest extends TestCase
         $this->assertEquals(['created' => [$timestamp_path]], $this->subject->output($tree));
     }
 
+    /**
+     * @test
+     */
+    public function output_generates_custom_pivot_tables()
+    {
+        $this->filesystem->expects('stub')
+            ->with('migration.stub')
+            ->andReturn($this->stub('migration.stub'));
+
+        $now = Carbon::now();
+        Carbon::setTestNow($now);
+
+        $user_migration = str_replace('timestamp', $now->copy()->subSeconds(2)->format('Y_m_d_His'), 'database/migrations/timestamp_create_users_table.php');
+        $team_migration = str_replace('timestamp', $now->copy()->subSecond()->format('Y_m_d_His'), 'database/migrations/timestamp_create_teams_table.php');
+        $pivot_migration = str_replace('timestamp', $now->format('Y_m_d_His'), 'database/migrations/timestamp_create_team_user_table.php');
+
+        $this->filesystem->expects('exists')->times(3)->andReturn(false);
+
+        $this->filesystem->expects('put')
+            ->with($user_migration, $this->fixture('migrations/custom_pivot_users_table.php'));
+        $this->filesystem->expects('put')
+            ->with($team_migration, $this->fixture('migrations/custom_pivot_teams_table.php'));
+        $this->filesystem->expects('put')
+            ->with($pivot_migration, $this->fixture('migrations/custom_pivot_team_user_table.php'));
+
+        $tokens = $this->blueprint->parse($this->fixture('drafts/custom-pivot.yaml'));
+        $tree = $this->blueprint->analyze($tokens);
+
+        $this->assertEquals(['created' => [$user_migration, $team_migration, $pivot_migration]], $this->subject->output($tree));
+    }
+
     public function modelTreeDataProvider()
     {
         return [
@@ -686,6 +717,7 @@ class MigrationGeneratorTest extends TestCase
             ['drafts/boolean-column-default.yaml', 'database/migrations/timestamp_create_posts_table.php', 'migrations/boolean-column-default.php'],
             ['drafts/foreign-with-class.yaml', 'database/migrations/timestamp_create_events_table.php', 'migrations/foreign-with-class.php'],
             ['drafts/full-text.yaml', 'database/migrations/timestamp_create_posts_table.php', 'migrations/full-text.php'],
+            ['drafts/model-with-meta.yaml', 'database/migrations/timestamp_create_post_table.php', 'migrations/model-with-meta.php'],
         ];
     }
 }
diff --git a/tests/Feature/Generators/ModelGeneratorTest.php b/tests/Feature/Generators/ModelGeneratorTest.php
index 425c0bd..d3c3b73 100644
--- a/tests/Feature/Generators/ModelGeneratorTest.php
+++ b/tests/Feature/Generators/ModelGeneratorTest.php
@@ -20,7 +20,6 @@ class ModelGeneratorTest extends TestCase
     {
         parent::setUp();
 
-        $this->modelStub = 'model.class.stub';
         $this->subject = new ModelGenerator($this->files);
 
         $this->blueprint = new Blueprint();
@@ -34,8 +33,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_nothing_for_empty_tree()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
 
         $this->filesystem->shouldNotHaveReceived('put');
 
@@ -52,8 +51,8 @@ class ModelGeneratorTest extends TestCase
             $this->app['config']->set('blueprint.use_return_types', true);
         }
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
 
         $this->filesystem->expects('stub')
             ->with('model.fillable.stub')
@@ -65,6 +64,12 @@ class ModelGeneratorTest extends TestCase
                 ->andReturn($this->stub('model.hidden.stub'));
         }
 
+        if ($definition === 'drafts/model-with-meta.yaml') {
+            $this->filesystem->expects('stub')
+                ->with('model.table.stub')
+                ->andReturn($this->stub('model.table.stub'));
+        }
+
         $this->filesystem->expects('stub')
             ->with('model.casts.stub')
             ->andReturn($this->stub('model.casts.stub'));
@@ -95,8 +100,8 @@ class ModelGeneratorTest extends TestCase
     public function output_works_for_pascal_case_definition()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->with('model.fillable.stub')
             ->andReturn($this->stub('model.fillable.stub'))
@@ -137,8 +142,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_relationships()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->with('model.fillable.stub')
             ->andReturn($this->stub('model.fillable.stub'));
@@ -167,8 +172,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_relationships_added_with_full_model_namespace()
     {
         $this->files->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->files->expects('stub')
             ->with('model.fillable.stub')
             ->andReturn($this->stub('model.fillable.stub'));
@@ -198,8 +203,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_morphone_morphmany_relation_string_when_using_fqn()
     {
         $this->files->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->files->expects('stub')
             ->with('model.fillable.stub')
             ->andReturn($this->stub('model.fillable.stub'));
@@ -229,8 +234,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_polymorphic_relationships()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->times(3)
             ->with('model.fillable.stub')
@@ -274,8 +279,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_morphtomany_relationship_with_intermediate_models()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->times(3)
             ->with('model.fillable.stub')
@@ -320,8 +325,8 @@ class ModelGeneratorTest extends TestCase
     public function output_generates_disabled_auto_columns()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->with('model.timestamps.stub')
             ->andReturn($this->stub('model.timestamps.stub'));
@@ -357,8 +362,8 @@ class ModelGeneratorTest extends TestCase
         $this->app['config']->set('blueprint.models_namespace', 'Models');
 
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
 
         $this->filesystem->expects('stub')
             ->with('model.fillable.stub')
@@ -395,8 +400,8 @@ class ModelGeneratorTest extends TestCase
         $this->app['config']->set('blueprint.generate_phpdocs', true);
 
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
 
         if ($definition === 'drafts/disable-auto-columns.yaml') {
             $this->filesystem->expects('stub')
@@ -441,8 +446,8 @@ class ModelGeneratorTest extends TestCase
         $this->app['config']->set('blueprint.use_guarded', true);
 
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
 
         $this->filesystem->expects('stub')
             ->with('model.guarded.stub')
@@ -477,8 +482,8 @@ class ModelGeneratorTest extends TestCase
         $this->app['config']->set('blueprint.models_namespace', 'Models');
 
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->times(4)
             ->with('model.fillable.stub')
@@ -529,8 +534,8 @@ class ModelGeneratorTest extends TestCase
         $model = 'models/custom-models-namespace.php';
 
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->with('model.fillable.stub')
             ->andReturn($this->stub('model.fillable.stub'));
@@ -556,11 +561,11 @@ class ModelGeneratorTest extends TestCase
     /**
      * @test
      */
-    public function output_generates_models_with_custom_pivot_columns()
+    public function output_generates_models_with_custom_pivot_table_name()
     {
         $this->filesystem->expects('stub')
-            ->with($this->modelStub)
-            ->andReturn($this->stub($this->modelStub));
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
         $this->filesystem->expects('stub')
             ->with('model.fillable.stub')
             ->andReturn($this->stub('model.fillable.stub'));
@@ -586,6 +591,51 @@ class ModelGeneratorTest extends TestCase
         $this->assertEquals(['created' => ['app/Models/User.php']], $this->subject->output($tree));
     }
 
+    /**
+     * @test
+     */
+    public function output_generates_models_with_custom_pivot()
+    {
+        $this->filesystem->expects('stub')
+            ->with('model.class.stub')
+            ->andReturn($this->stub('model.class.stub'));
+        $this->filesystem->expects('stub')
+            ->times(3)
+            ->with('model.fillable.stub')
+            ->andReturn($this->stub('model.fillable.stub'));
+        $this->filesystem->expects('stub')
+            ->times(3)
+            ->with('model.casts.stub')
+            ->andReturn($this->stub('model.casts.stub'));
+        $this->filesystem->expects('stub')
+            ->times(3)
+            ->with('model.method.stub')
+            ->andReturn($this->stub('model.method.stub'));
+        $this->filesystem->expects('stub')
+            ->with('model.table.stub')
+            ->andReturn($this->stub('model.table.stub'));
+        $this->filesystem->expects('stub')
+            ->with('model.incrementing.stub')
+            ->andReturn($this->stub('model.incrementing.stub'));
+
+        $this->filesystem->expects('exists')
+            ->times(3)
+            ->with('app/Models')
+            ->andReturnTrue();
+
+        $this->filesystem->expects('put')
+            ->with('app/Models/User.php', $this->fixture('models/custom-pivot-user.php'));
+        $this->filesystem->expects('put')
+            ->with('app/Models/Team.php', $this->fixture('models/custom-pivot-team.php'));
+        $this->filesystem->expects('put')
+            ->with('app/Models/Membership.php', $this->fixture('models/custom-pivot-membership.php'));
+
+        $tokens = $this->blueprint->parse($this->fixture('drafts/custom-pivot.yaml'));
+        $tree = $this->blueprint->analyze($tokens);
+
+        $this->assertEquals(['created' => ['app/Models/User.php', 'app/Models/Team.php', 'app/Models/Membership.php']], $this->subject->output($tree));
+    }
+
     public function modelTreeDataProvider()
     {
         return [
@@ -600,6 +650,7 @@ class ModelGeneratorTest extends TestCase
             ['drafts/alias-relationships.yaml', 'app/Models/Salesman.php', 'models/alias-relationships.php'],
             ['drafts/return-type-declarations.yaml', 'app/Models/Term.php', 'models/return-type-declarations.php'],
             ['drafts/uuid-shorthand-invalid-relationship.yaml', 'app/Models/AgeCohort.php', 'models/uuid-shorthand-invalid-relationship.php'],
+            ['drafts/model-with-meta.yaml', 'app/Models/Post.php', 'models/model-with-meta.php'],
         ];
     }
 
diff --git a/tests/Feature/Lexers/ModelLexerTest.php b/tests/Feature/Lexers/ModelLexerTest.php
index 2b208a2..156b3c6 100644
--- a/tests/Feature/Lexers/ModelLexerTest.php
+++ b/tests/Feature/Lexers/ModelLexerTest.php
@@ -651,6 +651,42 @@ class ModelLexerTest extends TestCase
         $this->assertEquals(['Morphable'], $relationships['morphTo']);
     }
 
+    /**
+     * @test
+     */
+    public function it_sets_meta_data()
+    {
+        $tokens = [
+            'models' => [
+                'Post' => [
+                    'meta' => [
+                        'pivot' => true,
+                        'table' => 'post',
+                    ],
+                ],
+            ],
+        ];
+
+        $actual = $this->subject->analyze($tokens);
+
+        $this->assertIsArray($actual['models']);
+        $this->assertCount(1, $actual['models']);
+
+        $model = $actual['models']['Post'];
+        $this->assertEquals('Post', $model->name());
+        $this->assertSame('post', $model->tableName());
+        $this->assertTrue($model->isPivot());
+        $this->assertTrue($model->usesTimestamps());
+
+        $columns = $model->columns();
+        $this->assertCount(1, $columns);
+        $this->assertEquals('id', $columns['id']->name());
+        $this->assertEquals('id', $columns['id']->dataType());
+        $this->assertEquals([], $columns['id']->modifiers());
+
+        $this->assertCount(0, $model->relationships());
+    }
+
     public function dataTypeAttributesDataProvider()
     {
         return [
diff --git a/tests/fixtures/drafts/custom-pivot.yaml b/tests/fixtures/drafts/custom-pivot.yaml
new file mode 100644
index 0000000..78f0845
--- /dev/null
+++ b/tests/fixtures/drafts/custom-pivot.yaml
@@ -0,0 +1,17 @@
+models:
+  User:
+    email: string
+    relationships:
+      belongsToMany: Team:&Membership
+
+  Team:
+    name: string
+    relationships:
+      belongsToMany: User:&Membership
+
+  Membership:
+    meta:
+      pivot: true
+      table: team_user
+    user_id: id
+    team_id: id
diff --git a/tests/fixtures/drafts/model-with-meta.yaml b/tests/fixtures/drafts/model-with-meta.yaml
new file mode 100644
index 0000000..a615cf2
--- /dev/null
+++ b/tests/fixtures/drafts/model-with-meta.yaml
@@ -0,0 +1,7 @@
+models:
+  Post:
+    meta:
+      pivot: true
+      table: post
+    title: string:400
+    content: text
diff --git a/tests/fixtures/migrations/custom_pivot_team_user_table.php b/tests/fixtures/migrations/custom_pivot_team_user_table.php
new file mode 100644
index 0000000..47deb3c
--- /dev/null
+++ b/tests/fixtures/migrations/custom_pivot_team_user_table.php
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('team_user', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('user_id');
+            $table->foreignId('team_id');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('team_user');
+    }
+};
diff --git a/tests/fixtures/migrations/custom_pivot_teams_table.php b/tests/fixtures/migrations/custom_pivot_teams_table.php
new file mode 100644
index 0000000..c91473e
--- /dev/null
+++ b/tests/fixtures/migrations/custom_pivot_teams_table.php
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('teams', function (Blueprint $table) {
+            $table->id();
+            $table->string('name');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('teams');
+    }
+};
diff --git a/tests/fixtures/migrations/custom_pivot_users_table.php b/tests/fixtures/migrations/custom_pivot_users_table.php
new file mode 100644
index 0000000..d41c019
--- /dev/null
+++ b/tests/fixtures/migrations/custom_pivot_users_table.php
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('users', function (Blueprint $table) {
+            $table->id();
+            $table->string('email');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('users');
+    }
+};
diff --git a/tests/fixtures/migrations/model-with-meta.php b/tests/fixtures/migrations/model-with-meta.php
new file mode 100644
index 0000000..380b28d
--- /dev/null
+++ b/tests/fixtures/migrations/model-with-meta.php
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('post', function (Blueprint $table) {
+            $table->id();
+            $table->string('title', 400);
+            $table->text('content');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('post');
+    }
+};
diff --git a/tests/fixtures/models/custom-pivot-membership.php b/tests/fixtures/models/custom-pivot-membership.php
new file mode 100644
index 0000000..fd48cdc
--- /dev/null
+++ b/tests/fixtures/models/custom-pivot-membership.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\Pivot;
+
+class Membership extends Pivot
+{
+    use HasFactory;
+
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'team_user';
+
+    /**
+     * Indicates if the IDs are auto-incrementing.
+     *
+     * @var bool
+     */
+    public $incrementing = true;
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'user_id',
+        'team_id',
+    ];
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'user_id' => 'integer',
+        'team_id' => 'integer',
+    ];
+
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function team()
+    {
+        return $this->belongsTo(Team::class);
+    }
+}
diff --git a/tests/fixtures/models/custom-pivot-team.php b/tests/fixtures/models/custom-pivot-team.php
new file mode 100644
index 0000000..335f916
--- /dev/null
+++ b/tests/fixtures/models/custom-pivot-team.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Team extends Model
+{
+    use HasFactory;
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'name',
+    ];
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'id' => 'integer',
+    ];
+
+    public function users()
+    {
+        return $this->belongsToMany(User::class)
+            ->using(Membership::class)
+            ->as('membership')
+            ->withPivot('id')
+            ->withTimestamps();
+    }
+}
diff --git a/tests/fixtures/models/custom-pivot-user.php b/tests/fixtures/models/custom-pivot-user.php
new file mode 100644
index 0000000..0332956
--- /dev/null
+++ b/tests/fixtures/models/custom-pivot-user.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class User extends Model
+{
+    use HasFactory;
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'email',
+    ];
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'id' => 'integer',
+    ];
+
+    public function teams()
+    {
+        return $this->belongsToMany(Team::class)
+            ->using(Membership::class)
+            ->as('membership')
+            ->withPivot('id')
+            ->withTimestamps();
+    }
+}
diff --git a/tests/fixtures/models/model-with-meta.php b/tests/fixtures/models/model-with-meta.php
new file mode 100644
index 0000000..70276c9
--- /dev/null
+++ b/tests/fixtures/models/model-with-meta.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\Pivot;
+
+class Post extends Pivot
+{
+    use HasFactory;
+
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'post';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'title',
+        'content',
+    ];
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'id' => 'integer',
+    ];
+}