Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ composer require webrium/console
| `make:controller` | Generate a controller file |
| `make:route` | Generate a route file |
| `make:migration` | Generate a database migration file |
| `make:seeder` | Generate a database seeder file |
| `migrate` | Run, roll back, or inspect database migrations |
| `db:seed` | Run database seeders |
| `call` | Call a method on a controller or model |
| `db` | Manage databases |
| `table` | Manage database tables and execute SQL files |
Expand Down Expand Up @@ -178,6 +180,7 @@ php webrium migrate [<action>] [--step=<n>] [--connection=<name>] [--force]
|---|---|
| `--step` | Limit `run`/`rollback` to a specific number of migrations |
| `--connection, -c` | Run against a named connection instead of the default one |
| `--seed` | After a successful `run` or `refresh`, also run every seeder in `database/seeders` |
| `--force, -f` | Skip the confirmation prompt for `reset`/`refresh` |

```bash
Expand Down Expand Up @@ -205,12 +208,105 @@ php webrium migrate refresh --force

# Run against a non-default connection
php webrium migrate --connection=secondary

# Apply pending migrations, then run all seeders
php webrium migrate --seed

# Reset, re-run, and re-seed in one command
php webrium migrate refresh --seed --force
```

Each migration runs inside its own database transaction. If a migration fails, `migrate` stops and reports it — earlier migrations in the same run stay applied, matching the underlying `Migrator::run()` behavior.

---

## `make:seeder`

Generates a seeder class in `database/seeders`. Seeders populate the database with default or test data (admin users, lookup tables, categories, etc.) and are built on top of [`webrium/foxdb`](https://github.com/webrium/foxdb)'s `Foxdb\Seeders\Seeder` base class.

```bash
php webrium make:seeder <Name> [--force]
```

| Argument / Option | Description |
|---|---|
| `Name` | Seeder class name (e.g. `UsersSeeder`). Auto-converted to PascalCase if given in snake_case |
| `--force, -f` | Overwrite if the file already exists |

```bash
php webrium make:seeder UsersSeeder
php webrium make:seeder roles_seeder # generated as RolesSeeder.php
php webrium make:seeder UsersSeeder --force
```

The generated stub looks like:

```php
<?php

use Foxdb\DB;
use Foxdb\Seeders\Seeder;

class UsersSeeder extends Seeder
{
public function run(): void
{
// DB::table('users')->insert([
// 'name' => 'Admin',
// 'email' => 'admin@example.com',
// ]);

// To call other seeders:
// $this->call(RolesSeeder::class);
}
}
```

Inside a seeder you can chain to other seeders with `$this->call(...)`, which accepts a class name or an array of class names. This is the recommended way to build a master seeder that orchestrates the others.

---

## `db:seed`

Runs database seeders from `database/seeders` using [`webrium/foxdb`](https://github.com/webrium/foxdb)'s `SeederRunner`. Unlike migrations, seeders are **not tracked** — every invocation runs them fresh, so they should be written to be idempotent if you intend to run them more than once.

```bash
php webrium db:seed [<class>] [--connection=<name>] [--no-transaction] [--force]
```

| Argument / Option | Description |
|---|---|
| `class` | Optional. Class or file name of a single seeder to run. If omitted, every seeder in `database/seeders` is executed in alphabetical order |
| `--connection, -c` | Run against a named connection instead of the default one |
| `--no-transaction` | Do not wrap each seeder in a transaction (use when seeders contain DDL or are intentionally non-atomic) |
| `--force, -f` | Skip the production confirmation prompt (relevant only when `APP_ENV=production`) |

```bash
# Run every seeder in database/seeders
php webrium db:seed

# Run a single seeder by file name
php webrium db:seed UsersSeeder

# Run a single seeder by fully qualified class name
php webrium db:seed "App\\Seeders\\UsersSeeder"

# Use a non-default connection
php webrium db:seed --connection=secondary

# Disable the per-seeder transaction
php webrium db:seed --no-transaction

# Run in production without an interactive prompt
APP_ENV=production php webrium db:seed --force
```

Each seeder runs inside its own transaction by default, so a failure mid-seed rolls back any inserts from that seeder. If a seeder fails, `db:seed` stops and reports it — seeders that already completed remain applied.

When `APP_ENV` is set to `production` (or `prod`), `db:seed` asks for confirmation before running. Pass `--force` to bypass the prompt in automated environments.

---

## `call`

Calls a method on a controller or model class directly from the terminal.
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
},
"require-dev": {
"webrium/core": "dev-master",
"webrium/foxdb": "^4.0",
"webrium/foxdb": "dev-main",
"phpunit/phpunit": "^9.6"
}
}
}
23 changes: 23 additions & 0 deletions src/Files/Framework/SeederFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

use Foxdb\DB;
use Foxdb\Seeders\Seeder;

class SeederClass extends Seeder
{
/**
* Run the seeder.
*
* @return void
*/
public function run(): void
{
// DB::table('table_name')->insert([
// 'name' => 'Example',
// 'email' => 'example@example.com',
// ]);

// To call other seeders:
// $this->call(AnotherSeeder::class);
}
}
102 changes: 102 additions & 0 deletions src/GenerateSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
namespace Webrium\Console;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\SymfonyStyle;
use Webrium\File;
use Webrium\Directory;

class GenerateSeeder extends Command
{
protected static $defaultName = 'make:seeder';

private const TEMPLATE = 'Files/Framework/SeederFile.php';

/**
* Configures the command, defining the seeder name argument.
*/
protected function configure()
{
Directory::initDefaultStructure();
$this
->setDescription('Create a new database seeder class')
->addArgument('name', InputArgument::REQUIRED, 'Seeder name (e.g. UsersSeeder)')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force overwrite if the seeder already exists');
}

/**
* Converts a snake_case or kebab-case name to PascalCase.
*
* @param string $input
* @return string
*/
private function convertToPascalCase(string $input): string
{
$input = str_replace('-', '_', $input);
return str_replace('_', '', ucwords($input, '_'));
}

/**
* Executes the command to generate a seeder file.
*
* @return int Command::SUCCESS or Command::FAILURE
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('name');
$force = $input->getOption('force');

// Validate seeder name
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) {
$io->error("Invalid seeder name '$name'. Names must contain only letters, numbers, and underscores, and start with a letter or underscore.");
return Command::FAILURE;
}

// Resolve directory
$seeders_dir = Directory::path('seeders');
if (!is_dir($seeders_dir)) {
File::makeDirectory($seeders_dir, 0755, true);
}
if (!is_writable($seeders_dir)) {
$io->error("The seeders directory '$seeders_dir' is not writable.");
return Command::FAILURE;
}

// Check template
$root = __DIR__;
$template_file = self::TEMPLATE;
if (!File::exists("$root/$template_file")) {
$io->error("Template file '$template_file' not found.");
return Command::FAILURE;
}

// Class & file name
$class_name = $this->convertToPascalCase($name);
$file_name = "{$class_name}.php";
$file_path = "$seeders_dir/$file_name";

// Existence check
if (file_exists($file_path) && !$force) {
$io->error("A seeder named '$class_name' already exists at '$file_path'. Use --force to overwrite.");
return Command::FAILURE;
}

// Render template
$seeder_string = File::getContent("$root/$template_file");
$seeder_string = str_replace('SeederClass', $class_name, $seeder_string);

// Write
File::putContent($file_path, $seeder_string);

// Output
$io->title('Seeder Generation');
$io->writeln("<fg=green>✔ Seeder '$class_name' created successfully at '$file_path'.</>");

return Command::SUCCESS;
}
}
65 changes: 63 additions & 2 deletions src/MigrateAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use Symfony\Component\Console\Style\SymfonyStyle;
use Foxdb\Migrations\Migrator;
use Foxdb\Migrations\MigrationResult;
use Foxdb\Seeders\SeederRunner;
use Foxdb\Seeders\SeederResult;
use Webrium\Directory;

class MigrateAction extends Command
Expand All @@ -34,6 +36,7 @@ protected function configure()
->addArgument('action', InputArgument::OPTIONAL, 'Action to perform (run, rollback, reset, refresh, status)', self::ACTION_RUN)
->addOption('step', null, InputOption::VALUE_OPTIONAL, 'Number of migrations to run or roll back', null)
->addOption('connection', 'c', InputOption::VALUE_OPTIONAL, 'Named database connection to use', null)
->addOption('seed', null, InputOption::VALUE_NONE, 'Run seeders after a successful run or refresh')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the operation without confirmation');
}

Expand Down Expand Up @@ -89,7 +92,13 @@ private function runMigrations(InputInterface $input, OutputInterface $output, M

$results = $migrator->run($step);

return $this->renderResults($io, $results, 'Migrating');
$status = $this->renderResults($io, $results, 'Migrating');

if ($status === Command::SUCCESS && $input->getOption('seed')) {
return $this->runSeeders($input, $output, $io);
}

return $status;
}

/**
Expand Down Expand Up @@ -156,7 +165,7 @@ private function refreshMigrations(InputInterface $input, OutputInterface $outpu
$up_status = $this->renderResults($io, $result['up'], 'Migrating');

return ($down_status === Command::SUCCESS && $up_status === Command::SUCCESS)
? Command::SUCCESS
? ($input->getOption('seed') ? $this->runSeeders($input, $output, $io) : Command::SUCCESS)
: Command::FAILURE;
}

Expand Down Expand Up @@ -259,4 +268,56 @@ private function renderResults(SymfonyStyle $io, array $results, string $title):
$io->success(ucfirst($title) . ' completed successfully.');
return Command::SUCCESS;
}

/**
* Runs all seeders found in the seeders directory.
* Used when the --seed flag is passed to migrate run / refresh.
*
* @return int Command::SUCCESS or Command::FAILURE
*/
private function runSeeders(InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$seeders_dir = Directory::path('seeders');
if (!is_dir($seeders_dir)) {
$io->writeln('<info>No seeders directory; skipping --seed.</info>');
return Command::SUCCESS;
}

$runner = new SeederRunner($seeders_dir, $input->getOption('connection'));

$files = $runner->getSeederFiles();
if (empty($files)) {
$io->writeln('<info>No seeders to run.</info>');
return Command::SUCCESS;
}

$results = $runner->runAll();

$io->title('Seeding');

$rows = array_map(function (SeederResult $result) {
return [
$result->name,
$result->success ? '<fg=green>OK</>' : '<fg=red>FAILED</>',
number_format($result->timeMs, 2) . ' ms',
];
}, $results);

$table = new \Symfony\Component\Console\Helper\Table($output);
$table->setHeaders(['Seeder', 'Status', 'Time']);
$table->setRows($rows);
$table->render();

$failed = array_filter($results, fn(SeederResult $r) => !$r->success);

if (!empty($failed)) {
foreach ($failed as $result) {
$io->error("{$result->name}: {$result->error}");
}
return Command::FAILURE;
}

$io->success('Seeding completed successfully.');
return Command::SUCCESS;
}
}
Loading