diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst
index 80d46f69..2d483882 100644
--- a/docs/en/seeding.rst
+++ b/docs/en/seeding.rst
@@ -235,7 +235,7 @@ Idempotent Seeds
Some seeds are designed to be run multiple times safely (idempotent), such as seeds
that update configuration or reference data. For these seeds, you can override the
-``isIdempotent()`` method to skip tracking entirely:
+``isIdempotent()`` method:
.. code-block:: php
@@ -248,7 +248,7 @@ that update configuration or reference data. For these seeds, you can override t
{
/**
* Mark this seed as idempotent.
- * It will run every time without being tracked.
+ * It will run every time it is invoked.
*/
public function isIdempotent(): bool
{
@@ -280,8 +280,9 @@ that update configuration or reference data. For these seeds, you can override t
When ``isIdempotent()`` returns ``true``:
-- The seed will **not** be tracked in the ``cake_seeds`` table
- The seed will run **every time** you execute ``seeds run``
+- The last execution time is still tracked in the ``cake_seeds`` table
+- The ``seeds status`` command will show the seed as ``(idempotent)``
- You must ensure the seed's ``run()`` method handles duplicate executions safely
This is useful for:
diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php
index 63acbe58..9ccb761b 100644
--- a/src/Command/SeedCommand.php
+++ b/src/Command/SeedCommand.php
@@ -190,16 +190,33 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int
// Skip confirmation in quiet mode
if ($io->level() > ConsoleIo::QUIET) {
+ $force = (bool)$args->getOption('force');
+
+ // Determine which seeds will actually run
+ $willRun = [];
+ foreach ($availableSeeds as $seed) {
+ $displayName = Util::getSeedDisplayName($seed->getName());
+ if ($seed->isIdempotent()) {
+ $willRun[] = $displayName . ' (idempotent)';
+ } elseif ($force || !$manager->isSeedExecuted($seed)) {
+ $willRun[] = $displayName;
+ }
+ }
+
$io->out('');
+ if (!$willRun) {
+ $io->out('All seeds have already been executed. Use --force to re-run.');
+ $io->out('');
+
+ return self::CODE_SUCCESS;
+ }
+
$io->out('The following seeds will be executed:');
- foreach ($availableSeeds as $seed) {
- $io->out(' - ' . Util::getSeedDisplayName($seed->getName()));
+ foreach ($willRun as $name) {
+ $io->out(' - ' . $name);
}
$io->out('');
- if (!(bool)$args->getOption('force')) {
- $io->out('Note: Seeds that have already been executed will be skipped.');
- $io->out('Use --force to re-run seeds.');
- } else {
+ if ($force) {
$io->out('Warning: Running with --force will re-execute all seeds,');
$io->out('potentially creating duplicate data. Ensure your seeds are idempotent.');
}
diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php
index bf4038e1..49a49d66 100644
--- a/src/Command/SeedStatusCommand.php
+++ b/src/Command/SeedStatusCommand.php
@@ -138,6 +138,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
'plugin' => $plugin,
'status' => $executed ? 'executed' : 'pending',
'executedAt' => $executedAt,
+ 'idempotent' => $seed->isIdempotent(),
];
}
@@ -168,14 +169,15 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
foreach ($statuses as $status) {
$seedName = str_pad($status['seedName'], $maxNameLength);
$plugin = $status['plugin'] ? str_pad($status['plugin'], $maxPluginLength) : str_repeat(' ', $maxPluginLength);
+ $idempotent = $status['idempotent'] ? ' (idempotent)' : '';
if ($status['status'] === 'executed') {
$statusText = 'executed';
$date = $status['executedAt'] ? ' (' . $status['executedAt'] . ')' : '';
- $io->out(" {$statusText} {$plugin} {$seedName}{$date}");
+ $io->out(" {$statusText} {$plugin} {$seedName}{$date}{$idempotent}");
} else {
$statusText = 'pending ';
- $io->out(" {$statusText} {$plugin} {$seedName}");
+ $io->out(" {$statusText} {$plugin} {$seedName}{$idempotent}");
}
}
diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php
index e4979cb0..b6861cae 100644
--- a/src/Migration/Environment.php
+++ b/src/Migration/Environment.php
@@ -150,11 +150,13 @@ public function executeSeed(SeedInterface $seed): void
// Run the seeder
$seed->{SeedInterface::RUN}();
- // Record the seed execution (skip for idempotent seeds)
- if (!$seed->isIdempotent()) {
- $executedTime = date('Y-m-d H:i:s');
- $adapter->seedExecuted($seed, $executedTime);
+ // Record the seed execution
+ // For idempotent seeds, remove old record first to update the timestamp
+ if ($seed->isIdempotent()) {
+ $adapter->removeSeedFromLog($seed);
}
+ $executedTime = date('Y-m-d H:i:s');
+ $adapter->seedExecuted($seed, $executedTime);
// commit the transaction if the adapter supports it
if ($atomic) {
diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php
index ebf7a339..4a9f1886 100644
--- a/src/Migration/Manager.php
+++ b/src/Migration/Manager.php
@@ -545,22 +545,21 @@ public function executeMigration(MigrationInterface $migration, string $directio
*/
public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake = false): void
{
- $this->getIo()->out('');
-
// Skip the seed if it should not be executed
if (!$seed->shouldExecute()) {
+ $this->getIo()->out('');
$this->printSeedStatus($seed, 'skipped');
return;
}
- // Check if seed has already been executed (skip for idempotent seeds)
+ // Silently skip non-idempotent seeds that have already been executed
if (!$force && !$seed->isIdempotent() && $this->isSeedExecuted($seed)) {
- $this->printSeedStatus($seed, 'already executed');
-
return;
}
+ $this->getIo()->out('');
+
// Ensure seed schema table exists
$adapter = $this->getEnvironment()->getAdapter();
if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) {
@@ -568,16 +567,12 @@ public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake
}
if ($fake) {
- // Idempotent seeds are not tracked, so faking doesn't apply
- if ($seed->isIdempotent()) {
- $this->printSeedStatus($seed, 'skipped (idempotent)');
-
- return;
- }
-
// Record seed as executed without running it
$this->printSeedStatus($seed, 'faking');
+ if ($seed->isIdempotent()) {
+ $adapter->removeSeedFromLog($seed);
+ }
$executedTime = date('Y-m-d H:i:s');
$adapter->seedExecuted($seed, $executedTime);
diff --git a/src/SeedInterface.php b/src/SeedInterface.php
index 3d21972a..e20b81bb 100644
--- a/src/SeedInterface.php
+++ b/src/SeedInterface.php
@@ -218,9 +218,10 @@ public function shouldExecute(): bool;
*
* Returns false by default, meaning the seed will be tracked and only run once.
*
- * If you return true, the seed will NOT be tracked in the cake_seeds table,
- * allowing it to run every time. Make sure your seed is truly idempotent
- * (handles duplicate data safely) before returning true.
+ * If you return true, the seed will run every time it is invoked.
+ * The last execution time is still tracked in the cake_seeds table.
+ * Make sure your seed is truly idempotent (handles duplicate data safely)
+ * before returning true.
*
* @return bool
*/
diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php
index ac240171..e401e460 100644
--- a/tests/TestCase/Command/SeedCommandTest.php
+++ b/tests/TestCase/Command/SeedCommandTest.php
@@ -457,10 +457,9 @@ public function testSeedStateTracking(): void
$query = $connection->execute('SELECT COUNT(*) FROM numbers');
$this->assertEquals(1, $query->fetchColumn(0));
- // Second run should skip the seed (already executed)
+ // Second run should silently skip the seed (already executed)
$this->exec('seeds run -c test NumbersSeed');
$this->assertExitSuccess();
- $this->assertOutputContains('Numbers seed: already executed');
$this->assertOutputNotContains('seeding');
// Verify no additional data was inserted
@@ -543,9 +542,9 @@ public function testIdempotentSeed(): void
$query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99');
$this->assertEquals(2, $query->fetchColumn(0));
- // Verify the seed was NOT tracked in cake_seeds table
+ // Verify the seed WAS tracked in cake_seeds table (only one record, updated each run)
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\'');
- $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked');
+ $this->assertEquals(1, $seedLog->fetchColumn(0), 'Idempotent seeds should track last execution');
}
public function testNonIdempotentSeedIsTracked(): void
@@ -564,10 +563,9 @@ public function testNonIdempotentSeedIsTracked(): void
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\'');
$this->assertEquals(1, $seedLog->fetchColumn(0), 'Regular seeds should be tracked');
- // Run again - should be skipped
+ // Run again - should be silently skipped
$this->exec('seeds run -c test NumbersSeed');
$this->assertExitSuccess();
- $this->assertOutputContains('already executed');
$this->assertOutputNotContains('seeding');
}
@@ -594,10 +592,10 @@ public function testFakeSeedMarksAsExecuted(): void
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\'');
$this->assertEquals(1, $seedLog->fetchColumn(0), 'Fake seeds should be tracked');
- // Running again should show already executed
+ // Running again should be silently skipped
$this->exec('seeds run -c test NumbersSeed');
$this->assertExitSuccess();
- $this->assertOutputContains('already executed');
+ $this->assertOutputNotContains('seeding');
}
public function testFakeSeedWithForce(): void
@@ -692,7 +690,7 @@ public function testResetNonExistentSeed(): void
$this->assertErrorContains('Seed `NonExistent` does not exist');
}
- public function testFakeIdempotentSeedIsSkipped(): void
+ public function testFakeIdempotentSeedIsTracked(): void
{
$this->createTables();
@@ -702,12 +700,15 @@ public function testFakeIdempotentSeedIsSkipped(): void
// Run idempotent seed with --fake flag
$this->exec('seeds run -c test -s TestSeeds IdempotentTest --fake');
$this->assertExitSuccess();
- $this->assertOutputContains('skipped (idempotent)');
- $this->assertOutputNotContains('faking');
- $this->assertOutputNotContains('faked');
+ $this->assertOutputContains('faking');
+ $this->assertOutputContains('faked');
+
+ // Verify NO data was inserted
+ $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99');
+ $this->assertEquals(0, $query->fetchColumn(0), 'Fake seed should not insert data');
- // Verify the seed was NOT tracked (idempotent seeds are never tracked)
+ // Verify the seed WAS tracked
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\'');
- $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked even when faked');
+ $this->assertEquals(1, $seedLog->fetchColumn(0), 'Idempotent seeds should be tracked when faked');
}
}