diff --git a/docs/en/advanced/integration-and-deployment.md b/docs/en/advanced/integration-and-deployment.md
index 7b1f57b90..6fc1036f7 100644
--- a/docs/en/advanced/integration-and-deployment.md
+++ b/docs/en/advanced/integration-and-deployment.md
@@ -62,6 +62,19 @@ bin/cake migrations status -p PluginName
bin/cake migrations migrate -p PluginName
```
+To check the app and every loaded plugin in a single call — a typical need on
+deploy gates — use `migrations status --all` instead of looping over plugins
+manually:
+
+```bash
+bin/cake migrations status --all
+```
+
+The command prints a compact per-section summary and exits non-zero when
+anything is pending, so it slots straight into a CI step. See
+[Checking All Plugins at Once](../getting-started/running-and-managing-migrations#checking-all-plugins-at-once)
+for the full description of the output and exit codes.
+
## Running Migrations in a Non-shell Environment
While typical usage of migrations is from the command line, you can also run
diff --git a/docs/en/getting-started/running-and-managing-migrations.md b/docs/en/getting-started/running-and-managing-migrations.md
index f3c98fda8..7c51a4f9a 100644
--- a/docs/en/getting-started/running-and-managing-migrations.md
+++ b/docs/en/getting-started/running-and-managing-migrations.md
@@ -58,6 +58,38 @@ bin/cake migrations status --format json
You can also use the `--source`, `--connection`, and `--plugin` options just
like for the `migrate` command.
+### Checking All Plugins at Once
+
+The `--all` flag prints the status for the app and every loaded plugin that
+ships migrations in a single call:
+
+```bash
+bin/cake migrations status --all
+```
+
+The default output is a compact summary listing only sections that need
+action:
+
+```text
+Summary: 2 of 3 sections require action:
+ - APP: 2 pending
+ - Migrator: 1 pending
+```
+
+When everything is migrated, it collapses to a single
+`Summary: all N sections are up to date.` line. Add `-v` to also print the
+full per-section migration tables before the summary.
+
+The exit code reflects the worst state across all sections — `0` when clean,
+`3` (`CODE_STATUS_DOWN`) when migrations are pending, `2`
+(`CODE_STATUS_MISSING`) when entries in the tracking table no longer have
+matching files. This makes `status --all` directly usable as a deploy gate
+in CI.
+
+`--format json` returns one combined object keyed by section name,
+e.g. `{"app": [...], "PluginName": [...]}`. `--all` cannot be combined with
+`--plugin` or `--cleanup`.
+
### Cleaning Up Missing Migrations
Sometimes migration files may be deleted from the filesystem but still exist in
diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php
index 7fb7defcd..39e093125 100644
--- a/src/Command/StatusCommand.php
+++ b/src/Command/StatusCommand.php
@@ -17,6 +17,7 @@
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
+use Cake\Core\Plugin;
use Migrations\Config\ConfigInterface;
use Migrations\Db\Adapter\UnifiedMigrationsTableStorage;
use Migrations\Migration\ManagerFactory;
@@ -64,11 +65,20 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption
'',
'migrations status -c secondary',
'migrations status -c secondary -f json',
+ 'migrations status --all',
+ 'Print a summary for the app and every loaded plugin that has migrations.',
+ 'Add -v to also print the per-section migration tables.',
'migrations status --cleanup',
'Remove *MISSING* migrations from the migration tracking table',
])->addOption('plugin', [
'short' => 'p',
'help' => 'The plugin to run migrations for',
+ ])->addOption('all', [
+ 'help' => 'Print a status summary for the app and every loaded plugin that has migrations. '
+ . 'Use -v to also print the per-section migration tables. '
+ . 'Cannot be combined with --plugin or --cleanup.',
+ 'boolean' => true,
+ 'default' => false,
])->addOption('connection', [
'short' => 'c',
'help' => 'The datasource connection to use',
@@ -103,6 +113,22 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
/** @var string|null $format */
$format = $args->getOption('format');
$clean = $args->getOption('cleanup');
+ $all = (bool)$args->getOption('all');
+
+ if ($all) {
+ if ($args->getOption('plugin')) {
+ $io->err('The --all option cannot be combined with --plugin.');
+
+ return Command::CODE_ERROR;
+ }
+ if ($clean) {
+ $io->err('The --all option cannot be combined with --cleanup.');
+
+ return Command::CODE_ERROR;
+ }
+
+ return $this->executeAll($args, $io, $format);
+ }
$factory = new ManagerFactory([
'plugin' => $args->getOption('plugin'),
@@ -143,6 +169,172 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
return Command::CODE_SUCCESS;
}
+ /**
+ * Execute the status command for the app and every loaded plugin
+ * that ships migrations.
+ *
+ * @param \Cake\Console\Arguments $args The command arguments.
+ * @param \Cake\Console\ConsoleIo $io The console io.
+ * @param string|null $format Output format.
+ * @return int The exit code: CODE_STATUS_MISSING (2) when there are missing entries,
+ * CODE_STATUS_DOWN (3) when there are pending down migrations, CODE_SUCCESS otherwise.
+ */
+ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): int
+ {
+ $sections = ['app' => null];
+ foreach (Plugin::loaded() as $pluginName) {
+ $migrationsPath = Plugin::path($pluginName) . 'config' . DS
+ . $args->getOption('source') . DS;
+ if (!is_dir($migrationsPath)) {
+ continue;
+ }
+ $sections[$pluginName] = $pluginName;
+ }
+
+ $verbose = (bool)$args->getOption('verbose');
+ $jsonResults = [];
+ $summary = [];
+ $exitCode = Command::CODE_SUCCESS;
+
+ foreach ($sections as $label => $plugin) {
+ $factory = new ManagerFactory([
+ 'plugin' => $plugin,
+ 'source' => $args->getOption('source'),
+ 'connection' => $args->getOption('connection'),
+ 'dry-run' => $args->getOption('dry-run'),
+ ]);
+ $manager = $factory->createManager($io);
+ $migrations = $manager->printStatus($format);
+
+ $sectionExit = $this->statusExitCode($migrations);
+ // Precedence: MISSING > DOWN > SUCCESS — once we see MISSING anywhere, keep it.
+ if ($sectionExit === self::CODE_STATUS_MISSING) {
+ $exitCode = self::CODE_STATUS_MISSING;
+ } elseif ($sectionExit === self::CODE_STATUS_DOWN && $exitCode === Command::CODE_SUCCESS) {
+ $exitCode = self::CODE_STATUS_DOWN;
+ }
+
+ $summary[$label] = $this->countActions($migrations);
+
+ if ($format === 'json') {
+ $jsonResults[$label] = $migrations;
+ continue;
+ }
+
+ if (!$verbose) {
+ continue;
+ }
+
+ $heading = $label === 'app' ? 'APP' : $label;
+ $io->out('');
+ $io->out('==================================================');
+ $io->out(sprintf('%s', $heading));
+ $io->out('==================================================');
+ $this->display($migrations, $io, $manager->getSchemaTableName());
+ }
+
+ if ($format === 'json') {
+ $flags = 0;
+ if ($verbose) {
+ $flags = JSON_PRETTY_PRINT;
+ }
+ $io->out((string)json_encode($jsonResults, $flags));
+
+ return $exitCode;
+ }
+
+ $this->displaySummary($io, $summary);
+
+ return $exitCode;
+ }
+
+ /**
+ * Count actionable items (down + missing) in a section's migrations array.
+ *
+ * @param array $migrations The result of {@see Manager::printStatus()}.
+ * @return array{down: int, missing: int}
+ */
+ protected function countActions(array $migrations): array
+ {
+ $down = 0;
+ $missing = 0;
+ foreach ($migrations as $migration) {
+ if (!empty($migration['missing'])) {
+ $missing++;
+ continue;
+ }
+ if (($migration['status'] ?? null) === 'down') {
+ $down++;
+ }
+ }
+
+ return ['down' => $down, 'missing' => $missing];
+ }
+
+ /**
+ * Render the trailing summary block listing sections that need action.
+ *
+ * @param \Cake\Console\ConsoleIo $io The console io.
+ * @param array $summary
+ * @return void
+ */
+ protected function displaySummary(ConsoleIo $io, array $summary): void
+ {
+ $needsAction = array_filter(
+ $summary,
+ fn(array $counts): bool => $counts['down'] > 0 || $counts['missing'] > 0,
+ );
+
+ $io->out('');
+ if (!$needsAction) {
+ $io->out(sprintf(
+ 'Summary: all %d sections are up to date.',
+ count($summary),
+ ));
+
+ return;
+ }
+
+ $io->out(sprintf(
+ 'Summary: %d of %d sections require action:',
+ count($needsAction),
+ count($summary),
+ ));
+ foreach ($needsAction as $label => $counts) {
+ $heading = $label === 'app' ? 'APP' : $label;
+ $parts = [];
+ if ($counts['down'] > 0) {
+ $parts[] = sprintf('%d pending', $counts['down']);
+ }
+ if ($counts['missing'] > 0) {
+ $parts[] = sprintf('%d missing', $counts['missing']);
+ }
+ $io->out(sprintf(' - %s: %s', $heading, implode(', ', $parts)));
+ }
+ }
+
+ /**
+ * Compute the appropriate status exit code for a single section's migrations array.
+ *
+ * @param array $migrations The result of {@see Manager::printStatus()}.
+ * @return int CODE_STATUS_MISSING when missing entries exist, CODE_STATUS_DOWN when
+ * any migration is pending (down), otherwise CODE_SUCCESS.
+ */
+ protected function statusExitCode(array $migrations): int
+ {
+ $hasDown = false;
+ foreach ($migrations as $migration) {
+ if (!empty($migration['missing'])) {
+ return self::CODE_STATUS_MISSING;
+ }
+ if (($migration['status'] ?? null) === 'down') {
+ $hasDown = true;
+ }
+ }
+
+ return $hasDown ? self::CODE_STATUS_DOWN : Command::CODE_SUCCESS;
+ }
+
/**
* Print migration status to stdout.
*
diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php
index a1917c42f..d61686dcb 100644
--- a/tests/TestCase/Command/CompletionTest.php
+++ b/tests/TestCase/Command/CompletionTest.php
@@ -134,7 +134,7 @@ public function testMigrationsOptionsStatus(): void
$this->exec('completion options migrations.migrations status');
$this->assertCount(1, $this->_out->messages());
$output = $this->_out->messages()[0];
- $expected = '--cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s --verbose -v';
+ $expected = '--all --cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s --verbose -v';
$outputExplode = explode(' ', trim($output));
sort($outputExplode);
$expectedExplode = explode(' ', $expected);
diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php
index 6b3b2f7e1..929b07071 100644
--- a/tests/TestCase/Command/StatusCommandTest.php
+++ b/tests/TestCase/Command/StatusCommandTest.php
@@ -5,6 +5,7 @@
use Cake\Console\TestSuite\StubConsoleOutput;
use Cake\Core\Exception\MissingPluginException;
+use Migrations\Command\StatusCommand;
use Migrations\Test\TestCase\TestCase;
use RuntimeException;
@@ -105,4 +106,80 @@ public function testCleanHelp(): void
$this->assertOutputContains('--cleanup');
$this->assertOutputContains('Remove MISSING migrations from the');
}
+
+ public function testAllHelp(): void
+ {
+ $this->exec('migrations status --help');
+ $this->assertExitSuccess();
+ $this->assertOutputContains('--all');
+ $this->assertOutputContains('every loaded plugin');
+ }
+
+ public function testAllAppOnlyExitCodeDownWhenPending(): void
+ {
+ $this->exec('migrations status -c test --all');
+ // App has unmigrated migrations, so the exit code signals pending.
+ $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN);
+ // Default output is summary only — no per-section table.
+ $this->assertOutputContains('Summary:');
+ $this->assertOutputContains('APP:');
+ $this->assertOutputContains('pending');
+ $this->assertOutputNotContains('Migration ID');
+ }
+
+ public function testAllIncludesLoadedPluginWithMigrations(): void
+ {
+ $this->loadPlugins(['Migrator']);
+ $this->exec('migrations status -c test --all');
+ $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN);
+ $this->assertOutputContains('Summary:');
+ $this->assertOutputContains('- APP:');
+ $this->assertOutputContains('- Migrator:');
+ $this->assertOutputNotContains('Plugin:');
+ }
+
+ public function testAllVerboseShowsPerSectionTables(): void
+ {
+ $this->loadPlugins(['Migrator']);
+ $this->exec('migrations status -c test --all -v');
+ $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN);
+ $this->assertOutputContains('Migration ID');
+ // Plugin section header reads as just the plugin name now.
+ $this->assertOutputContains('Migrator');
+ $this->assertOutputNotContains('Plugin: Migrator');
+ // Summary still rendered after the tables.
+ $this->assertOutputContains('Summary:');
+ }
+
+ public function testAllJsonOutput(): void
+ {
+ $this->loadPlugins(['Migrator']);
+ $this->exec('migrations status -c test --all --format json');
+ $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN);
+
+ assert($this->_out instanceof StubConsoleOutput);
+ $messages = $this->_out->messages();
+ $jsonLine = end($messages);
+ $parsed = json_decode((string)$jsonLine, true);
+ $this->assertIsArray($parsed);
+ $this->assertArrayHasKey('app', $parsed);
+ $this->assertArrayHasKey('Migrator', $parsed);
+ $this->assertIsArray($parsed['app']);
+ $this->assertIsArray($parsed['Migrator']);
+ }
+
+ public function testAllRejectsPluginOption(): void
+ {
+ $this->loadPlugins(['Migrator']);
+ $this->exec('migrations status -c test --all -p Migrator');
+ $this->assertExitError();
+ $this->assertErrorContains('cannot be combined with --plugin');
+ }
+
+ public function testAllRejectsCleanupOption(): void
+ {
+ $this->exec('migrations status -c test --all --cleanup');
+ $this->assertExitError();
+ $this->assertErrorContains('cannot be combined with --cleanup');
+ }
}