diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php
index 829166fc7..14fbf7781 100644
--- a/src/Command/BakeMigrationCommand.php
+++ b/src/Command/BakeMigrationCommand.php
@@ -83,6 +83,7 @@ public function templateData(Arguments $arguments): array
$fields = $columnParser->parseFields($args);
$indexes = $columnParser->parseIndexes($args);
$primaryKey = $columnParser->parsePrimaryKey($args);
+ $foreignKeys = $columnParser->parseForeignKeys($args);
$action = $this->detectAction($className);
@@ -119,6 +120,7 @@ public function templateData(Arguments $arguments): array
'indexes' => $indexes,
'primaryKey' => $primaryKey,
],
+ 'constraints' => $foreignKeys,
'name' => $className,
'backend' => Configure::read('Migrations.backend', 'builtin'),
];
@@ -169,16 +171,19 @@ public function getOptionParser(): ConsoleOptionParser
When describing columns you can use the following syntax:
-{name}:{primary}{type}{nullable}[{length}]:{index}
+{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName}
All sections other than name are optional.
* The types are the abstract database column types in CakePHP.
* The ? value indicates if a column is nullable.
- e.x. role:string?.
+ e.g. role:string?.
* Length option must be enclosed in [], for example: name:string?[100].
* The index attribute can define the column as having a unique
key with unique or a primary key with primary.
+* Use references type to create a foreign key constraint.
+ e.g. category_id:references (auto-infers table as 'categories')
+ or category_id:references:custom_table to specify the referenced table.
Examples
@@ -195,9 +200,17 @@ public function getOptionParser(): ConsoleOptionParser
table.
bin/cake bake migration AddSlugToProjects name:string[128]:unique
-Create a migration that adds (name VARCHAR(128) and a UNIQUE<.warning index)
+Create a migration that adds (name VARCHAR(128) and a UNIQUE index)
to the projects table.
+bin/cake bake migration CreatePosts title:string user_id:references
+Create a migration that creates the posts table with a foreign key
+constraint on user_id referencing the users table.
+
+bin/cake bake migration AddCategoryIdToArticles category_id:references:categories
+Create a migration that adds a foreign key column (category_id) to the articles
+table referencing the categories table.
+
TEXT;
$parser->setDescription($text);
diff --git a/src/Util/ColumnParser.php b/src/Util/ColumnParser.php
index 21fe4bc9f..02a1b5ee8 100644
--- a/src/Util/ColumnParser.php
+++ b/src/Util/ColumnParser.php
@@ -5,6 +5,7 @@
use Cake\Collection\Collection;
use Cake\Utility\Hash;
+use Cake\Utility\Inflector;
use Migrations\Db\Adapter\AdapterInterface;
use ReflectionClass;
@@ -64,6 +65,13 @@ public function parseFields(array $arguments): array
$type = 'primary';
}
}
+
+ // Handle references - convert to integer type
+ $isReference = in_array($type, ['references', 'references?'], true);
+ if ($isReference) {
+ $type = str_contains($type, '?') ? 'integer?' : 'integer';
+ }
+
$nullable = (bool)strpos($type, '?');
$type = $nullable ? str_replace('?', '', $type) : $type;
@@ -109,6 +117,11 @@ public function parseIndexes(array $arguments): array
$indexType = Hash::get($matches, 3);
$indexName = Hash::get($matches, 4);
+ // Skip references - they create foreign keys, not indexes
+ if ($type && str_starts_with($type, 'references')) {
+ continue;
+ }
+
if (
in_array($type, ['primary', 'primary_key'], true) ||
in_array($indexType, ['primary', 'primary_key'], true) ||
@@ -168,6 +181,55 @@ public function parsePrimaryKey(array $arguments): array
return $primaryKey;
}
+ /**
+ * Parses a list of arguments into an array of foreign key constraints
+ *
+ * @param array $arguments A list of arguments being parsed
+ * @return array
+ */
+ public function parseForeignKeys(array $arguments): array
+ {
+ $foreignKeys = [];
+ $arguments = $this->validArguments($arguments);
+
+ foreach ($arguments as $field) {
+ preg_match($this->regexpParseColumn, $field, $matches);
+ $fieldName = $matches[1];
+ $type = Hash::get($matches, 2, '');
+ $indexType = Hash::get($matches, 3);
+ $indexName = Hash::get($matches, 4);
+
+ // Check if type is 'references' or 'references?'
+ $isReference = str_starts_with($type, 'references');
+ if (!$isReference) {
+ continue;
+ }
+
+ // Determine referenced table
+ // If indexType is provided, use it as the referenced table name
+ // Otherwise, infer from field name (e.g., category_id -> categories)
+ $referencedTable = $indexType;
+ if (!$referencedTable) {
+ // Remove common suffixes like _id and pluralize
+ $referencedTable = preg_replace('/_id$/', '', $fieldName);
+ $referencedTable = Inflector::pluralize($referencedTable);
+ }
+
+ // Generate constraint name
+ $constraintName = $indexName ?: 'fk_' . $fieldName;
+
+ $foreignKeys[$constraintName] = [
+ 'type' => 'foreign',
+ 'columns' => [$fieldName],
+ 'references' => [$referencedTable, 'id'],
+ 'update' => 'CASCADE',
+ 'delete' => 'CASCADE',
+ ];
+ }
+
+ return $foreignKeys;
+ }
+
/**
* Returns a list of only valid arguments
*
diff --git a/templates/bake/config/skeleton.twig b/templates/bake/config/skeleton.twig
index 5246eee89..d428deae0 100644
--- a/templates/bake/config/skeleton.twig
+++ b/templates/bake/config/skeleton.twig
@@ -78,6 +78,42 @@ class {{ name }} extends AbstractMigration
Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw
}}]);
{%~ endif %}
+ {%~ if constraints is defined and constraints is not empty %}
+ {%~ for constraintName, constraint in constraints %}
+ {%~ if constraint['type'] == 'foreign' %}
+ {%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %}
+ {%~ if constraint['columns']|length > 1 %}
+ {%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 3}) ~ ']' %}
+ {%~ endif %}
+ {%~ if constraint['references'][1] is iterable %}
+ {%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 3}) ~ ']' %}
+ {%~ else %}
+ {%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %}
+ {%~ endif %}
+ {%~ if backend == 'builtin' %}
+ $table->addForeignKey(
+ $this->foreignKey({{ columnsList | raw }})
+ ->setReferencedTable('{{ constraint['references'][0] }}')
+ ->setReferencedColumns({{ columnsReference | raw }})
+ ->setOnDelete('{{ Migration.formatConstraintAction(constraint['delete']) | raw }}')
+ ->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}')
+ ->setName('{{ constraintName }}')
+ );
+ {%~ else %}
+ $table->addForeignKey(
+ {{ columnsList | raw }},
+ '{{ constraint['references'][0] }}',
+ {{ columnsReference | raw }},
+ [
+ 'update' => '{{ Migration.formatConstraintAction(constraint['update']) | raw }}',
+ 'delete' => '{{ Migration.formatConstraintAction(constraint['delete']) | raw }}',
+ 'constraint' => '{{ constraintName }}'
+ ]
+ );
+ {%~ endif %}
+ {%~ endif %}
+ {%~ endfor %}
+ {%~ endif %}
{%~ endif %}
{% endif %}
$table->{{ tableMethod }}(){% if tableMethod == 'drop' %}->save(){% endif %};
diff --git a/tests/TestCase/Command/BakeMigrationCommandTest.php b/tests/TestCase/Command/BakeMigrationCommandTest.php
index 505ec595b..f0d8279da 100644
--- a/tests/TestCase/Command/BakeMigrationCommandTest.php
+++ b/tests/TestCase/Command/BakeMigrationCommandTest.php
@@ -56,6 +56,27 @@ public function tearDown(): void
unlink($file);
}
}
+
+ $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Posts.php');
+ if ($files) {
+ foreach ($files as $file) {
+ unlink($file);
+ }
+ }
+
+ $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Articles.php');
+ if ($files) {
+ foreach ($files as $file) {
+ unlink($file);
+ }
+ }
+
+ $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Products.php');
+ if ($files) {
+ foreach ($files as $file) {
+ unlink($file);
+ }
+ }
}
/**
@@ -387,6 +408,57 @@ public function testActionWithoutValidPrefix()
$this->assertErrorContains('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`.');
}
+ /**
+ * Test creating migration with references (foreign keys)
+ *
+ * @return void
+ */
+ public function testCreateWithReferences()
+ {
+ $this->exec('bake migration CreatePosts title:string user_id:references --connection test');
+
+ $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreatePosts.php');
+ $filePath = current($file);
+
+ $this->assertExitCode(BaseCommand::CODE_SUCCESS);
+ $result = file_get_contents($filePath);
+ $this->assertSameAsFile(__FUNCTION__ . '.php', $result);
+ }
+
+ /**
+ * Test creating migration with references to specific table
+ *
+ * @return void
+ */
+ public function testCreateWithReferencesCustomTable()
+ {
+ $this->exec('bake migration CreateArticles title:string author_id:references:authors --connection test');
+
+ $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreateArticles.php');
+ $filePath = current($file);
+
+ $this->assertExitCode(BaseCommand::CODE_SUCCESS);
+ $result = file_get_contents($filePath);
+ $this->assertSameAsFile(__FUNCTION__ . '.php', $result);
+ }
+
+ /**
+ * Test adding a field with reference
+ *
+ * @return void
+ */
+ public function testAddFieldWithReference()
+ {
+ $this->exec('bake migration AddCategoryIdToProducts category_id:references --connection test');
+
+ $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_AddCategoryIdToProducts.php');
+ $filePath = current($file);
+
+ $this->assertExitCode(BaseCommand::CODE_SUCCESS);
+ $result = file_get_contents($filePath);
+ $this->assertSameAsFile(__FUNCTION__ . '.php', $result);
+ }
+
public function testBakeMigrationWithoutBake()
{
// Make sure to unload the Bake plugin
diff --git a/tests/TestCase/Util/ColumnParserTest.php b/tests/TestCase/Util/ColumnParserTest.php
index efe383fc8..1a31d6717 100644
--- a/tests/TestCase/Util/ColumnParserTest.php
+++ b/tests/TestCase/Util/ColumnParserTest.php
@@ -367,4 +367,116 @@ public function testGetIndexName()
$this->assertSame('PRIMARY', $this->columnParser->getIndexName('id', 'primary', null, false));
$this->assertSame('PRIMARY', $this->columnParser->getIndexName('id', 'primary', null, true));
}
+
+ public function testParseFieldsWithReferences()
+ {
+ // Test basic references - should convert to integer
+ $expected = [
+ 'user_id' => [
+ 'columnType' => 'integer',
+ 'options' => [
+ 'null' => false,
+ 'default' => null,
+ 'limit' => 11,
+ ],
+ ],
+ ];
+ $actual = $this->columnParser->parseFields(['user_id:references']);
+ $this->assertEquals($expected, $actual);
+
+ // Test nullable references
+ $expected = [
+ 'category_id' => [
+ 'columnType' => 'integer',
+ 'options' => [
+ 'null' => true,
+ 'default' => null,
+ 'limit' => 11,
+ ],
+ ],
+ ];
+ $actual = $this->columnParser->parseFields(['category_id:references?']);
+ $this->assertEquals($expected, $actual);
+
+ // Test references with explicit table name
+ $expected = [
+ 'category_id' => [
+ 'columnType' => 'integer',
+ 'options' => [
+ 'null' => false,
+ 'default' => null,
+ 'limit' => 11,
+ ],
+ ],
+ ];
+ $actual = $this->columnParser->parseFields(['category_id:references:categories']);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testParseForeignKeys()
+ {
+ // Test basic reference - infer table name from field
+ $expected = [
+ 'fk_user_id' => [
+ 'type' => 'foreign',
+ 'columns' => ['user_id'],
+ 'references' => ['users', 'id'],
+ 'update' => 'CASCADE',
+ 'delete' => 'CASCADE',
+ ],
+ ];
+ $actual = $this->columnParser->parseForeignKeys(['user_id:references']);
+ $this->assertEquals($expected, $actual);
+
+ // Test reference with explicit table name
+ $expected = [
+ 'fk_category_id' => [
+ 'type' => 'foreign',
+ 'columns' => ['category_id'],
+ 'references' => ['custom_categories', 'id'],
+ 'update' => 'CASCADE',
+ 'delete' => 'CASCADE',
+ ],
+ ];
+ $actual = $this->columnParser->parseForeignKeys(['category_id:references:custom_categories']);
+ $this->assertEquals($expected, $actual);
+
+ // Test reference with custom constraint name
+ $expected = [
+ 'custom_fk' => [
+ 'type' => 'foreign',
+ 'columns' => ['author_id'],
+ 'references' => ['authors', 'id'],
+ 'update' => 'CASCADE',
+ 'delete' => 'CASCADE',
+ ],
+ ];
+ $actual = $this->columnParser->parseForeignKeys(['author_id:references:authors:custom_fk']);
+ $this->assertEquals($expected, $actual);
+
+ // Test multiple foreign keys
+ $expected = [
+ 'fk_user_id' => [
+ 'type' => 'foreign',
+ 'columns' => ['user_id'],
+ 'references' => ['users', 'id'],
+ 'update' => 'CASCADE',
+ 'delete' => 'CASCADE',
+ ],
+ 'fk_category_id' => [
+ 'type' => 'foreign',
+ 'columns' => ['category_id'],
+ 'references' => ['categories', 'id'],
+ 'update' => 'CASCADE',
+ 'delete' => 'CASCADE',
+ ],
+ ];
+ $actual = $this->columnParser->parseForeignKeys(['user_id:references', 'category_id:references']);
+ $this->assertEquals($expected, $actual);
+
+ // Test that non-reference fields are ignored
+ $expected = [];
+ $actual = $this->columnParser->parseForeignKeys(['name:string', 'age:integer']);
+ $this->assertEquals($expected, $actual);
+ }
}
diff --git a/tests/comparisons/Migration/testAddFieldWithReference.php b/tests/comparisons/Migration/testAddFieldWithReference.php
new file mode 100644
index 000000000..02991e218
--- /dev/null
+++ b/tests/comparisons/Migration/testAddFieldWithReference.php
@@ -0,0 +1,34 @@
+table('products');
+ $table->addColumn('category_id', 'integer', [
+ 'default' => null,
+ 'limit' => 11,
+ 'null' => false,
+ ]);
+ $table->addForeignKey(
+ $this->foreignKey('category_id')
+ ->setReferencedTable('categories')
+ ->setReferencedColumns('id')
+ ->setOnDelete('CASCADE')
+ ->setOnUpdate('CASCADE')
+ ->setName('fk_category_id')
+ );
+ $table->update();
+ }
+}
diff --git a/tests/comparisons/Migration/testCreateWithReferences.php b/tests/comparisons/Migration/testCreateWithReferences.php
new file mode 100644
index 000000000..6b31569a5
--- /dev/null
+++ b/tests/comparisons/Migration/testCreateWithReferences.php
@@ -0,0 +1,39 @@
+table('posts');
+ $table->addColumn('title', 'string', [
+ 'default' => null,
+ 'limit' => 255,
+ 'null' => false,
+ ]);
+ $table->addColumn('user_id', 'integer', [
+ 'default' => null,
+ 'limit' => 11,
+ 'null' => false,
+ ]);
+ $table->addForeignKey(
+ $this->foreignKey('user_id')
+ ->setReferencedTable('users')
+ ->setReferencedColumns('id')
+ ->setOnDelete('CASCADE')
+ ->setOnUpdate('CASCADE')
+ ->setName('fk_user_id')
+ );
+ $table->create();
+ }
+}
diff --git a/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php b/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php
new file mode 100644
index 000000000..b60934d63
--- /dev/null
+++ b/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php
@@ -0,0 +1,39 @@
+table('articles');
+ $table->addColumn('title', 'string', [
+ 'default' => null,
+ 'limit' => 255,
+ 'null' => false,
+ ]);
+ $table->addColumn('author_id', 'integer', [
+ 'default' => null,
+ 'limit' => 11,
+ 'null' => false,
+ ]);
+ $table->addForeignKey(
+ $this->foreignKey('author_id')
+ ->setReferencedTable('authors')
+ ->setReferencedColumns('id')
+ ->setOnDelete('CASCADE')
+ ->setOnUpdate('CASCADE')
+ ->setName('fk_author_id')
+ );
+ $table->create();
+ }
+}