diff --git a/README.md b/README.md
index 7ee2c24..266f7d7 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,42 @@ class UserService
Available helpers are `query($sql, $parameters)`, `execute($sql, $parameters)`, `pdo()`, and `transaction($callback)`. Query results expose `all()`, `first()`, `value()`, `affectedRows()`, and the raw `PDOStatement`.
+### Migrations
+
+Create migration files under `database/migrations`:
+
+```sh
+./shift create:migration create_users_table
+```
+
+Migration files return an anonymous `Shift\Database\Migration` instance:
+
+```php
+use Shift\Database\Database;
+use Shift\Database\Migration;
+
+return new class extends Migration
+{
+ public function up(Database $db): void
+ {
+ $db->execute('create table users (id integer primary key autoincrement, email text not null)');
+ }
+
+ public function down(Database $db): void
+ {
+ $db->execute('drop table users');
+ }
+};
+```
+
+Run pending migrations, inspect their status, or roll back the latest batch:
+
+```sh
+./shift migrate
+./shift migrate:status
+./shift migrate:rollback
+```
+
### Query Builder and Models
Use the table query builder for simple fluent queries:
@@ -347,6 +383,13 @@ http://127.0.0.1:8000/health
## CLI
+Show all commands or command-specific help:
+
+```sh
+./shift help
+./shift help migrate
+```
+
List registered API routes:
```sh
@@ -384,6 +427,15 @@ List discovered modules:
./shift module:list
```
+Run database migrations:
+
+```sh
+./shift create:migration create_users_table
+./shift migrate
+./shift migrate:status
+./shift migrate:rollback
+```
+
Generate module scaffolding:
```sh
diff --git a/REFACTORING.md b/REFACTORING.md
index 9b0d00e..ba91e8c 100644
--- a/REFACTORING.md
+++ b/REFACTORING.md
@@ -31,6 +31,8 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
- [x] Native PDO database configuration, lazy connection, and basic query API.
- [x] Fluent query builder and attribute-driven database models.
- [x] CLI diagnostics for tests, runtime info, environment, database, and modules.
+- [x] CLI help listing and command-specific usage.
+- [x] Database migrations with create, migrate, status, and rollback commands.
- [x] Removal of view storage and example page assets from runtime.
- [x] Removal of legacy `application/controllers` and `application/routes.php`.
- [x] Domain-oriented framework namespaces:
@@ -158,5 +160,5 @@ Internal errors return a generic `500` message unless `display_errors` is enable
- [ ] Structured logging for exceptions.
- [ ] Module discovery cache for production.
-- [ ] CLI command namespaces and command metadata.
+- [ ] CLI command aliases and richer command metadata.
- [ ] Basic package-quality checks, for example static analysis and coding style.
diff --git a/database/migrations/.gitkeep b/database/migrations/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/database/migrations/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/docs/index.html b/docs/index.html
index 0f45edd..cdc455f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -24,11 +24,12 @@
Contents
Responses
Validation and DTOs
Middleware
- Service Container
- Environment and Database
- CLI
- Errors
- Testing
+ Service Container
+ Environment and Database
+ Migrations
+ CLI
+ Errors
+ Testing
@@ -469,11 +470,44 @@ Models
#[Guarded] fields are ignored during mass assignment through create() and query update(), but can be set explicitly before save(). Supported casts include int, float, bool, string, array, date, datetime, and class names. Class casts use fromArray() when available.
+
+ Migrations
+ Migration files live in database/migrations. Create a migration with the CLI:
+
+ ./shift create:migration create_users_table
+
+ A migration returns an anonymous class extending Shift\Database\Migration.
+
+ use Shift\Database\Database;
+use Shift\Database\Migration;
+
+return new class extends Migration
+{
+ public function up(Database $db): void
+ {
+ $db->execute('create table users (id integer primary key autoincrement, email text not null)');
+ }
+
+ public function down(Database $db): void
+ {
+ $db->execute('drop table users');
+ }
+};
+
+ The migration runner stores applied migrations in the migrations table and runs each migration inside a database transaction.
+
+ ./shift migrate
+./shift migrate:status
+./shift migrate:rollback
+
+
CLI
The CLI entry point is shift. Built-in commands live under Console\Commands, and module commands are loaded from module command mappings.
- ./shift route:list
+ ./shift help
+./shift help migrate
+./shift route:list
./shift test
./shift health
./shift about
@@ -490,7 +524,14 @@ CLI
./shift create:service Billing:Invoice
./shift create:command Billing:SyncInvoices
./shift create:middleware Billing:Audit
-./shift create:dto Billing:CreateInvoice
+./shift create:dto Billing:CreateInvoice
+./shift create:migration create_users_table
+
+ Migration commands manage database schema changes:
+
+ ./shift migrate
+./shift migrate:status
+./shift migrate:rollback
Controller, service, middleware, and DTO generators add the expected class suffix when it is missing. Commands accept either --module=Billing Name or Billing:Name.
Generator templates live in src/Console/Generator/stubs.
diff --git a/src/Console/Commands/CreateMigration.php b/src/Console/Commands/CreateMigration.php
new file mode 100644
index 0000000..b3a168c
--- /dev/null
+++ b/src/Console/Commands/CreateMigration.php
@@ -0,0 +1,44 @@
+error($this->getHelp());
+ return;
+ }
+
+ $directory = APP_ROOT . '/database/migrations';
+ $migrationName = str_replace('-', '_', NameFormatter::slug($name));
+ $file = $directory . '/' . date('Y_m_d_His') . '_' . $migrationName . '.php';
+ $content = (new StubRenderer())->render('migration', [
+ 'name' => $migrationName,
+ ]);
+
+ $files = new FileGenerator();
+ $files->writeFile($file, $content);
+ $cli->success('Created: ' . $file);
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift create:migration {name}';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Create a database migration file.';
+ }
+}
diff --git a/src/Console/Commands/Help.php b/src/Console/Commands/Help.php
index a463f8f..832014c 100644
--- a/src/Console/Commands/Help.php
+++ b/src/Console/Commands/Help.php
@@ -8,70 +8,139 @@
namespace Console\Commands;
+use Shift\Console\Cli;
use Shift\Console\CommandInterface;
+use Shift\Modules\ModuleLoader;
class Help implements CommandInterface
{
- /** @var array */
- private array $mappings = [
- [
- 'dir' => APP_PATH . '/console/',
- 'namespace' => 'AppConsole\\Commands\\'
- ],
- [
- 'dir' => APP_ROOT . '/src/Console/Commands/',
- 'namespace' => 'Console\\Commands\\'
- ],
- ];
-
- /**
- * @param mixed ...$args
- */
public function execute(mixed ...$args): void
{
- $commandName = $args[0] ?? '';
+ $commandName = $args[0] ?? null;
- if ($commandName) {
+ if (is_string($commandName) && $commandName !== '') {
$this->displayHelpForCommand($commandName);
- } else {
- $this->displayFullHelp();
+ return;
}
+
+ $this->displayFullHelp();
}
- /**
- * @param string $command
- */
private function displayHelpForCommand(string $command): void
{
- $found = false;
+ $cli = new Cli();
+ $class = $this->findCommandClass($this->normalizeCommandName($command));
- foreach ($this->mappings as $mapping) {
- if (!$found && file_exists($mapping['dir'] . $command . '.php')) {
- require_once($mapping['dir'] . $command . '.php');
- $found = $mapping['namespace'] . $command;
- }
+ if ($class === null) {
+ $cli->error('Command not found: ' . $command);
+ return;
}
+
+ $instance = new $class();
+ $cli->info($this->classToCommand($this->shortClass($class)));
+ $cli->debug($instance->getDescription());
+ $cli->debug($instance->getHelp());
}
private function displayFullHelp(): void
{
+ $cli = new Cli();
+ $rows = [];
+
+ foreach ($this->commandClasses() as $className => $class) {
+ $instance = new $class();
+ $rows[] = [
+ $this->classToCommand($className),
+ $instance->getDescription(),
+ ];
+ }
+
+ usort($rows, static fn (array $left, array $right): int => strcmp($left[0], $right[0]));
+
+ $cli->table(['Command', 'Description'], $rows);
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift help [command]';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Show available commands.';
+ }
+
+ private function findCommandClass(string $className): ?string
+ {
+ return $this->commandClasses()[$className] ?? null;
}
/**
- * @return string
+ * @return array>
*/
- public function getHelp(): string
+ private function commandClasses(): array
{
- // TODO: Implement getHelp() method.
- return '';
+ $classes = [];
+
+ foreach ($this->mappings() as $mapping) {
+ if (!is_dir($mapping['dir'])) {
+ continue;
+ }
+
+ foreach (glob($mapping['dir'] . '*.php') ?: [] as $file) {
+ $className = pathinfo($file, PATHINFO_FILENAME);
+ require_once $file;
+ $class = $mapping['namespace'] . $className;
+
+ if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
+ $classes[$className] = $class;
+ }
+ }
+ }
+
+ return $classes;
}
/**
- * @return string
+ * @return list
*/
- public function getDescription(): string
+ private function mappings(): array
+ {
+ return array_merge(
+ [
+ [
+ 'dir' => APP_PATH . '/console/',
+ 'namespace' => 'AppConsole\\Commands\\',
+ ],
+ [
+ 'dir' => APP_ROOT . '/src/Console/Commands/',
+ 'namespace' => 'Console\\Commands\\',
+ ],
+ ],
+ (new ModuleLoader())->load()->getCommandMappings()
+ );
+ }
+
+ private function normalizeCommandName(string $command): string
+ {
+ $parts = preg_split('/[:\-_]/', $command) ?: [];
+ $parts = array_map(static fn (string $part): string => ucfirst($part), $parts);
+
+ return implode('', $parts);
+ }
+
+ private function classToCommand(string $class): string
{
- // TODO: Implement getDescription() method.
- return '';
+ $parts = preg_split('/(?=[A-Z])/', $class, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+ $parts = array_map(static fn (string $part): string => strtolower($part), $parts);
+
+ return implode(':', $parts);
+ }
+
+ private function shortClass(string $class): string
+ {
+ $parts = explode('\\', $class);
+
+ return end($parts) ?: $class;
}
}
diff --git a/src/Console/Commands/Migrate.php b/src/Console/Commands/Migrate.php
new file mode 100644
index 0000000..25e2c59
--- /dev/null
+++ b/src/Console/Commands/Migrate.php
@@ -0,0 +1,37 @@
+migrate();
+
+ if ($ran === []) {
+ $cli->info('Nothing to migrate.');
+ return;
+ }
+
+ foreach ($ran as $migration) {
+ $cli->success('Migrated: ' . $migration);
+ }
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift migrate';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Run pending database migrations.';
+ }
+}
diff --git a/src/Console/Commands/MigrateRollback.php b/src/Console/Commands/MigrateRollback.php
new file mode 100644
index 0000000..7f9461a
--- /dev/null
+++ b/src/Console/Commands/MigrateRollback.php
@@ -0,0 +1,37 @@
+rollback();
+
+ if ($rolledBack === []) {
+ $cli->info('Nothing to rollback.');
+ return;
+ }
+
+ foreach ($rolledBack as $migration) {
+ $cli->success('Rolled back: ' . $migration);
+ }
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift migrate:rollback';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Rollback the last migration batch.';
+ }
+}
diff --git a/src/Console/Commands/MigrateStatus.php b/src/Console/Commands/MigrateStatus.php
new file mode 100644
index 0000000..0d36070
--- /dev/null
+++ b/src/Console/Commands/MigrateStatus.php
@@ -0,0 +1,43 @@
+status() as $migration) {
+ $rows[] = [
+ $migration['name'],
+ $migration['ran'] ? 'yes' : 'no',
+ $migration['batch'] === null ? '' : (string) $migration['batch'],
+ ];
+ }
+
+ if ($rows === []) {
+ $cli->warning('No migrations found.');
+ return;
+ }
+
+ $cli->table(['Migration', 'Ran', 'Batch'], $rows);
+ }
+
+ public function getHelp(): string
+ {
+ return 'Usage: ./shift migrate:status';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Show database migration status.';
+ }
+}
diff --git a/src/Console/Commands/Serve.php b/src/Console/Commands/Serve.php
index 523afcc..190dfec 100644
--- a/src/Console/Commands/Serve.php
+++ b/src/Console/Commands/Serve.php
@@ -45,13 +45,11 @@ private function getOpenCommand(): string
public function getHelp(): string
{
- // TODO: Implement getHelp() method.
- return '';
+ return 'Usage: ./shift serve [host:port]';
}
public function getDescription(): string
{
- // TODO: Implement getDescription() method.
- return '';
+ return 'Start the local PHP development server.';
}
}
diff --git a/src/Console/Generator/stubs/migration.stub b/src/Console/Generator/stubs/migration.stub
new file mode 100644
index 0000000..e89c55f
--- /dev/null
+++ b/src/Console/Generator/stubs/migration.stub
@@ -0,0 +1,15 @@
+
+ */
+ public function migrate(): array
+ {
+ $this->ensureRepository();
+ $batch = $this->nextBatch();
+ $ran = [];
+
+ foreach ($this->pendingFiles() as $file) {
+ $name = $this->migrationName($file);
+ $migration = $this->loadMigration($file);
+
+ $this->database->transaction(function (Database $db) use ($migration, $name, $batch): void {
+ $migration->up($db);
+ $db->table('migrations')->insert([
+ 'migration' => $name,
+ 'batch' => $batch,
+ 'migrated_at' => date(DATE_ATOM),
+ ]);
+ });
+
+ $ran[] = $name;
+ }
+
+ return $ran;
+ }
+
+ /**
+ * @return list
+ */
+ public function rollback(): array
+ {
+ $this->ensureRepository();
+ $batch = $this->lastBatch();
+
+ if ($batch === null) {
+ return [];
+ }
+
+ $rolledBack = [];
+ $files = $this->filesByName();
+ $records = $this->database
+ ->table('migrations')
+ ->where('batch', $batch)
+ ->orderBy('migration', 'desc')
+ ->get();
+
+ foreach ($records as $record) {
+ $name = (string) $record['migration'];
+ $file = $files[$name] ?? null;
+
+ if ($file === null) {
+ throw new DatabaseException("Migration file {$name} not found.");
+ }
+
+ $migration = $this->loadMigration($file);
+
+ $this->database->transaction(function (Database $db) use ($migration, $name): void {
+ $migration->down($db);
+ $db->table('migrations')->where('migration', $name)->delete();
+ });
+
+ $rolledBack[] = $name;
+ }
+
+ return $rolledBack;
+ }
+
+ /**
+ * @return list
+ */
+ public function status(): array
+ {
+ $this->ensureRepository();
+ $applied = $this->applied();
+ $rows = [];
+
+ foreach ($this->migrationFiles() as $file) {
+ $name = $this->migrationName($file);
+ $rows[] = [
+ 'name' => $name,
+ 'ran' => array_key_exists($name, $applied),
+ 'batch' => $applied[$name] ?? null,
+ ];
+ }
+
+ return $rows;
+ }
+
+ public function ensureRepository(): void
+ {
+ $this->database->execute(
+ 'create table if not exists migrations (
+ migration varchar(255) primary key,
+ batch integer not null,
+ migrated_at varchar(255) not null
+ )'
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private function pendingFiles(): array
+ {
+ $applied = $this->applied();
+
+ return array_values(array_filter(
+ $this->migrationFiles(),
+ fn (string $file): bool => !array_key_exists($this->migrationName($file), $applied)
+ ));
+ }
+
+ /**
+ * @return list
+ */
+ private function migrationFiles(): array
+ {
+ if (!is_dir($this->path)) {
+ return [];
+ }
+
+ $files = glob(rtrim($this->path, '/') . '/*.php') ?: [];
+ sort($files);
+
+ return $files;
+ }
+
+ /**
+ * @return array
+ */
+ private function applied(): array
+ {
+ try {
+ $rows = $this->database->table('migrations')->select('migration', 'batch')->get();
+ } catch (Throwable) {
+ return [];
+ }
+
+ $applied = [];
+ foreach ($rows as $row) {
+ $applied[(string) $row['migration']] = (int) $row['batch'];
+ }
+
+ return $applied;
+ }
+
+ /**
+ * @return array
+ */
+ private function filesByName(): array
+ {
+ $files = [];
+
+ foreach ($this->migrationFiles() as $file) {
+ $files[$this->migrationName($file)] = $file;
+ }
+
+ return $files;
+ }
+
+ private function loadMigration(string $file): Migration
+ {
+ $migration = require $file;
+
+ if (!$migration instanceof Migration) {
+ throw new DatabaseException("Migration {$file} must return an instance of " . Migration::class . '.');
+ }
+
+ return $migration;
+ }
+
+ private function nextBatch(): int
+ {
+ $lastBatch = $this->lastBatch();
+
+ return $lastBatch === null ? 1 : $lastBatch + 1;
+ }
+
+ private function lastBatch(): ?int
+ {
+ $batch = $this->database->query('select max(batch) as batch from migrations')->value('batch');
+
+ return $batch === null ? null : (int) $batch;
+ }
+
+ private function migrationName(string $file): string
+ {
+ return pathinfo($file, PATHINFO_FILENAME);
+ }
+}
diff --git a/tests/Feature/CliHelpTest.php b/tests/Feature/CliHelpTest.php
new file mode 100644
index 0000000..b68a103
--- /dev/null
+++ b/tests/Feature/CliHelpTest.php
@@ -0,0 +1,23 @@
+ function (): void {
+ ob_start();
+ (new Help())->execute();
+ $output = ob_get_clean();
+
+ assertStringContains('help', $output, 'Help list should include itself.');
+ assertStringContains('migrate', $output, 'Help list should include migration commands.');
+ assertStringContains('create:migration', $output, 'Help list should include migration generator.');
+ },
+ 'help command shows a single command description and usage' => function (): void {
+ ob_start();
+ (new Help())->execute('migrate:status');
+ $output = ob_get_clean();
+
+ assertStringContains('migrate:status', $output, 'Command help should include normalized command name.');
+ assertStringContains('Usage: ./shift migrate:status', $output, 'Command help should include command usage.');
+ },
+];
diff --git a/tests/Feature/MigrationTest.php b/tests/Feature/MigrationTest.php
new file mode 100644
index 0000000..f41bfb7
--- /dev/null
+++ b/tests/Feature/MigrationTest.php
@@ -0,0 +1,111 @@
+ function (): void {
+ $root = makeTempMigrationPath();
+
+ try {
+ writeTestMigration(
+ $root . '/migrations/2026_06_17_120000_create_widgets_table.php',
+ "create table widgets (id integer primary key autoincrement, name text not null)",
+ 'drop table widgets'
+ );
+
+ $db = makeMigrationDatabase();
+ $runner = new MigrationRunner($db, $root . '/migrations');
+
+ $pendingStatus = $runner->status();
+ assertSameValue(false, $pendingStatus[0]['ran'] ?? null, 'Migration should be pending before migrate runs.');
+
+ $ran = $runner->migrate();
+ assertSameValue(['2026_06_17_120000_create_widgets_table'], $ran, 'Pending migration should run once.');
+
+ $count = $db->query('select count(*) as count from migrations')->value('count');
+ assertSameValue(1, (int) $count, 'Migration repository should store applied migration.');
+
+ $status = $runner->status();
+ assertSameValue(true, $status[0]['ran'] ?? null, 'Status should mark applied migrations as ran.');
+ assertSameValue(1, $status[0]['batch'] ?? null, 'First migration batch should be 1.');
+
+ $secondRun = $runner->migrate();
+ assertSameValue([], $secondRun, 'Already applied migrations should not run again.');
+ } finally {
+ removeDirectory($root);
+ }
+ },
+ 'migration runner rolls back the latest batch' => function (): void {
+ $root = makeTempMigrationPath();
+
+ try {
+ writeTestMigration(
+ $root . '/migrations/2026_06_17_120000_create_widgets_table.php',
+ "create table widgets (id integer primary key autoincrement, name text not null)",
+ 'drop table widgets'
+ );
+
+ $db = makeMigrationDatabase();
+ $runner = new MigrationRunner($db, $root . '/migrations');
+ $runner->migrate();
+
+ $rolledBack = $runner->rollback();
+ assertSameValue(['2026_06_17_120000_create_widgets_table'], $rolledBack, 'Rollback should undo the last batch.');
+
+ $stored = $db->query('select count(*) as count from migrations')->value('count');
+ assertSameValue(0, (int) $stored, 'Rollback should remove migration repository records.');
+
+ $missingTable = false;
+ try {
+ $db->query('select count(*) as count from widgets')->value('count');
+ } catch (Throwable) {
+ $missingTable = true;
+ }
+
+ assertSameValue(true, $missingTable, 'Rollback should call the migration down method.');
+ } finally {
+ removeDirectory($root);
+ }
+ },
+];
+
+function makeMigrationDatabase(): Database
+{
+ return new Database(new DatabaseConfig(
+ driver: 'sqlite',
+ database: ':memory:'
+ ));
+}
+
+function makeTempMigrationPath(): string
+{
+ $root = sys_get_temp_dir() . '/shift-migrations-' . bin2hex(random_bytes(6));
+ mkdir($root . '/migrations', 0775, true);
+
+ return $root;
+}
+
+function writeTestMigration(string $path, string $upSql, string $downSql): void
+{
+ file_put_contents($path, <<execute('{$upSql}');
+ }
+
+ public function down(Database \$db): void
+ {
+ \$db->execute('{$downSql}');
+ }
+};
+PHP);
+}