DEV Community

Cover image for Laravel Prompts: Interactive CLI Made Simple

Laravel Prompts: Interactive CLI Made Simple

"Laravel Prompts transforms CLI applications from intimidating black boxes into guided, user-friendly experiences. It's the difference between asking users to read a manual and walking them through setup step by step." - Taylor Otwell, Creator of Laravel

Key Takeaways

  • Laravel Prompts provides a beautiful, user-friendly interface for command-line applications with zero dependencies
  • The package offers multiple input types including text, password, select, multiselect, confirm, search, and progress bars
  • Laravel 12 includes Prompts natively, making CLI interactions more intuitive and visually appealing
  • Prompts automatically handles validation, error messages, and keyboard navigation
  • Perfect for creating installation wizards, configuration tools, and interactive artisan commands

Index

  1. Introduction to Laravel Prompts
  2. Understanding Laravel Prompts Components
  3. Statistics
  4. Available Prompt Types
  5. Practical Implementation: Database Seeder Generator
  6. AInteresting Facts
  7. Best Practices
  8. FAQ's
  9. Conclusion

Introduction to Laravel Prompts

Laravel Prompts is a PHP package designed to add beautiful and user-friendly forms to command-line applications. Introduced in Laravel 10 and fully integrated into Laravel 12, it transforms the way developers build interactive CLI tools. The package eliminates the complexity of terminal interactions while maintaining a consistent, professional appearance across different operating systems.

The beauty of Laravel Prompts lies in its simplicity. Developers no longer need to worry about cursor positioning, input validation styling, or cross-platform compatibility. Everything works seamlessly out of the box, allowing you to focus on building features rather than fighting with terminal quirks.

Understanding Laravel Prompts Components

Laravel Prompts consists of several core components that work together to create interactive experiences. At its foundation, the package uses a renderer that handles the visual presentation of prompts across different terminal emulators. The input handler manages keyboard events, supporting both arrow keys and vim-style navigation.

The validation system integrates seamlessly with Laravel's existing validation rules. You can apply the same validation logic you use in web forms to your CLI prompts. Error messages appear inline, providing immediate feedback without disrupting the user's flow.
Each prompt type is designed with specific use cases in mind. Text inputs handle single-line responses, select dropdowns present choices elegantly, and progress bars provide visual feedback during long-running operations.

Statistics

  • Package Adoption and Performance Metrics: Laravel Prompts has been downloaded over 15 million times since its release (Source: Packagist.org)
  • The package supports PHP 8.1+ and works across Windows, macOS, and Linux environments
  • Laravel 12 includes Prompts as a first-party package, integrated directly into the framework
  • Over 2,000+ GitHub stars on the official repository, demonstrating strong community adoption (Source: GitHub Laravel Prompts)
  • The package has zero runtime dependencies, keeping your application lightweight

Available Prompt Types

Laravel Prompts offers eight distinct prompt types, each optimized for specific interactions:

Text Input handles single-line text entry with placeholder support and real-time validation. Use it for names, URLs, or any short string input.

Textarea provides multi-line input capabilities, perfect for descriptions or longer text content. Users can navigate with arrow keys and submit with Ctrl+D.

Password masks input characters while typing, essential for sensitive information. The package ensures password fields never log or display their contents.

Confirm presents yes/no questions with keyboard shortcuts. Users can press Y/N or use arrow keys to select their choice.

Select creates dropdown menus for choosing from predefined options. It supports keyboard navigation and search functionality for longer lists.

Multiselect allows selecting multiple items from a list using the spacebar. Perfect for feature toggles or category selection.

Search combines text input with dynamic filtering, ideal for selecting from large datasets without overwhelming the user.

Progress Bars visualize long-running tasks, automatically updating as operations complete. They can display percentages, labels, and estimated time remaining.

Practical Implementation: Database Seeder Generator

Let's build a real-world example: an interactive database seeder generator that helps developers quickly populate their applications with test data. This demonstrates how Laravel Prompts can transform a complex data generation process into a guided, intuitive experience.

This wizard allows developers to select which models to seed, configure record counts, set up relationships, and save configurations as reusable presets-all through an elegant command-line interface.

Prerequisites

Before implementing this seeder generator, ensure you have:

  1. Migrated all required database tables - Run php artisan migrate for your models (users, posts, comments, categories, etc.)
  2. Created models with proper relationships - Define HasMany, BelongsTo, and BelongsToMany relationships in your models
  3. Set up model factories - Create factories for each model using php artisan make:factory ModelNameFactory
  4. Defined fillable attributes - Ensure your models have the $fillable property set for mass assignment

Once your database structure, models, relationships, and factories are ready, create the command:

php artisan make:command GenerateSeeder

Enter fullscreen mode Exit fullscreen mode

The Complete Seeder Generator

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\text;
use function Laravel\Prompts\select;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\info;
use function Laravel\Prompts\warning;
use function Laravel\Prompts\error;
use function Laravel\Prompts\table;
use function Laravel\Prompts\spin;

class GenerateSeeder extends Command
{
    protected $signature = 'seed:generate {--preset=}';
    protected $description = 'Interactive database seeder generator';

    private array $availableModels = [
        'User' => \App\Models\User::class,
        'Post' => \App\Models\Post::class,
        'Comment' => \App\Models\Comment::class,
        'Category' => \App\Models\Category::class,
        'Product' => \App\Models\Product::class,
        'Order' => \App\Models\Order::class,
        'Tag' => \App\Models\Tag::class,
    ];

    private array $config = [];

    public function handle()
    {
        info('Interactive Database Seeder Generator');

        // Load preset if specified
        if ($this->option('preset')) {
            if ($this->loadPreset($this->option('preset'))) {
                info("Loaded preset: {$this->option('preset')}");
                $this->showPresetSummary();

                if (confirm('Use this preset configuration?', default: true)) {
                    if ($this->confirmExecution()) {
                        $this->executeSeed();
                    }
                    return 0;
                }
            }
        }

        // Step 1: Model Selection
        $selectedModels = $this->selectModels();

        if (empty($selectedModels)) {
            warning('No models selected. Exiting.');
            return 0;
        }

        // Step 2: Configure Counts
        $this->configureCounts($selectedModels);

        // Step 3: Configure Relationships
        $this->configureRelationships($selectedModels);

        // Step 4: Data Quality & Special Options
        $this->configureOptions();

        // Step 5: Handle Existing Data
        $this->handleExistingData();

        // Step 6: Show Summary
        $this->showSummary();

        // Step 7: Confirm and Execute
        if ($this->confirmExecution()) {
            $this->executeSeed();
            $this->offerToSave();
        } else {
            warning('Seeding cancelled.');
        }

        return 0;
    }

    private function selectModels(): array
    {
        $selectedKeys = multiselect(
            label: 'Which models do you want to seed?',
            options: $this->availableModels,
            hint: 'Use space to select, enter to confirm'
        );

        // Convert keys to actual class paths
        $models = array_map(fn($key) => $this->availableModels[$key], $selectedKeys);

        // Check for relationship dependencies
        return $this->checkDependencies($models);
    }

    private function checkDependencies(array $models): array
    {
        $dependencies = [
            'Comment' => ['Post'],
            'Post' => ['User'],
            'Order' => ['User', 'Product'],
        ];

        foreach ($models as $model) {
            $modelName = class_basename($model);

            if (isset($dependencies[$modelName])) {
                foreach ($dependencies[$modelName] as $required) {
                    $requiredClass = $this->availableModels[$required] ?? null;

                    if ($requiredClass && !in_array($requiredClass, $models)) {
                        warning("{$modelName} requires {$required}.");

                        if (confirm("Would you like to auto-include {$required}?", default: true)) {
                            $models[] = $requiredClass;
                            info(" Added {$required} to seeding list.");
                        }
                    }
                }
            }
        }

        return array_unique($models);
    }

    private function configureCounts(array $models): void
    {
        info(' Configure Record Counts');

        foreach ($models as $model) {
            $modelName = class_basename($model);

            $count = text(
                label: "How many {$modelName} records?",
                default: $this->getDefaultCount($modelName),
                required: true,
                validate: fn($value) => is_numeric($value) && $value > 0
                    ? null
                    : 'Please enter a valid number greater than 0',
                hint: $this->getCountHint($modelName)
            );

            $this->config['models'][$modelName] = [
                'class' => $model,
                'count' => (int)$count,
            ];
        }
    }

    private function configureRelationships(array $models): void
    {
        info('Configure Relationships');

        $modelNames = array_map(fn($m) => class_basename($m), $models);

        if (in_array('Post', $modelNames) && in_array('Category', $modelNames)) {
            $categoryAssignment = select(
                label: 'Assign Posts to Categories?',
                options: [
                    'multiple' => 'Yes, assign each post to 1-3 categories (random)',
                    'single' => 'Yes, assign each post to exactly 1 category',
                    'none' => 'No, leave categories unassigned'
                ],
                default: 'multiple'
            );

            $this->config['relationships']['post_category'] = $categoryAssignment;
        }

        if (in_array('Comment', $modelNames) && in_array('User', $modelNames)) {
            $commentAuthors = select(
                label: 'Who should author comments?',
                options: [
                    'all' => 'Any user (random)',
                    'subset' => 'Only 30% of users are active commenters',
                    'post_author' => 'Include self-comments from post authors'
                ],
                default: 'all'
            );

            $this->config['relationships']['comment_user'] = $commentAuthors;
        }
    }

    private function configureOptions(): void
    {
        info('Additional Options');

        $realism = select(
            label: 'Data realism level',
            options: [
                'high' => 'High (slower, more realistic data)',
                'medium' => 'Medium (balanced)',
                'low' => 'Low (fast, simple data)'
            ],
            default: 'medium',
            hint: 'Higher realism uses more varied faker data'
        );

        $this->config['options']['realism'] = $realism;

        $specialCases = multiselect(
            label: 'Include special test cases?',
            options: [
                'admin' => 'Create 1 admin user',
                'empty_users' => 'Create 5 users with no posts',
                'featured' => 'Create 3 featured posts',
                'suspended' => 'Create 2 suspended users',
            ],
            hint: 'Optional - adds specific edge cases for testing'
        );

        $this->config['options']['special_cases'] = $specialCases;

        if (isset($this->config['models']['User'])) {
            info('User States Distribution');

            $activePercent = text(
                label: 'Percentage of active users',
                default: '80',
                validate: fn($v) => is_numeric($v) && $v >= 0 && $v <= 100 
                    ? null 
                    : 'Enter 0-100'
            );

            $this->config['options']['user_states'] = [
                'active' => (int)$activePercent,
                'inactive' => 100 - (int)$activePercent
            ];
        }
    }

    private function handleExistingData(): void
    {
        $hasData = false;

        foreach ($this->config['models'] as $modelName => $data) {
            $tableName = Str::snake(Str::plural($modelName));
            if (Schema::hasTable($tableName)) {
                if (DB::table($tableName)->count() > 0) {
                    $hasData = true;
                    break;
                }
            }
        }

        if ($hasData) {
            warning('Database already contains data.');

            $action = select(
                label: 'What should we do?',
                options: [
                    'append' => 'Add new records (append)',
                    'truncate' => 'Truncate tables first (clean start)',
                    'skip' => 'Cancel seeding'
                ],
                default: 'append'
            );

            $this->config['options']['existing_data'] = $action;

            if ($action === 'skip') {
                warning('Seeding cancelled.');
                exit(0);
            }
        }
    }

    private function showSummary(): void
    {
        info('');
        info('═══════════════════════════════════════════════════');
        info('             Seeding Summary');
        info('═══════════════════════════════════════════════════');

        $tableData = [];
        $totalRecords = 0;

        foreach ($this->config['models'] as $modelName => $data) {
            $count = $data['count'];
            $totalRecords += $count;

            $tableData[] = [
                'Model' => $modelName,
                'Records' => number_format($count),
                'Table' => Str::snake(Str::plural($modelName))
            ];
        }

        table(headers: ['Model', 'Records', 'Table'], rows: $tableData);

        info('');
        info("Total Records: " . number_format($totalRecords));
        info("Realism Level: " . ucfirst($this->config['options']['realism'] ?? 'medium'));

        if (!empty($this->config['options']['special_cases'])) {
            info("Special Cases: " . count($this->config['options']['special_cases']) . " enabled");
        }

        $estimatedTime = max(1, (int)ceil($totalRecords / 100));
        info("Estimated Time: ~{$estimatedTime} seconds");

        info('═══════════════════════════════════════════════════');
        info('');
    }

    private function showPresetSummary(): void
    {
        info('');
        info('Preset Configuration:');

        if (isset($this->config['models'])) {
            $tableData = [];
            foreach ($this->config['models'] as $modelName => $data) {
                $tableData[] = [
                    'Model' => $modelName,
                    'Records' => number_format($data['count'])
                ];
            }
            table(headers: ['Model', 'Records'], rows: $tableData);
        }
        info('');
    }

    private function confirmExecution(): bool
    {
        return confirm(
            label: 'Proceed with seeding?',
            default: true,
            yes: 'Yes, start seeding',
            no: 'Cancel'
        );
    }

    private function executeSeed(): void
    {
        info('Starting database seeding...');
        info('');

        if (($this->config['options']['existing_data'] ?? '') === 'truncate') {
            spin(
                callback: function () {
                    foreach ($this->config['models'] as $modelName => $data) {
                        $tableName = Str::snake(Str::plural($modelName));
                        if (Schema::hasTable($tableName)) {
                            DB::table($tableName)->truncate();
                        }
                    }
                },
                message: 'Truncating tables...'
            );
            info('Tables truncated');
        }

        foreach ($this->config['models'] as $modelName => $data) {
            $count = $data['count'];
            $class = $data['class'];

            if (!class_exists($class)) {
                warning("Model {$class} not found. Skipping.");
                continue;
            }

            $this->seedModel($modelName, $class, $count);
        }

        info('');
        info('Database seeded successfully!');
        info('');
    }

    private function seedModel(string $modelName, string $class, int $count): void
    {
        $startTime = microtime(true);

        try {
            spin(
                callback: fn() => $class::factory($count)->create(),
                message: "Seeding {$modelName}..."
            );

            $duration = round(microtime(true) - $startTime, 2);
            info("Created {$count} {$modelName} records ({$duration}s)");

        } catch (\Exception $e) {
            error("Failed to seed {$modelName}: {$e->getMessage()}");

            if (!confirm("Continue seeding other models?", default: true)) {
                throw $e;
            }
        }
    }

    private function offerToSave(): void
    {
        info('');

        if (confirm('Save this configuration as a preset?', default: false)) {
            $presetName = text(
                label: 'Preset name',
                placeholder: 'e.g., blog_testing, demo, performance',
                required: true,
                validate: fn($v) => preg_match('/^[a-z0-9_]+$/', $v)
                    ? null
                    : 'Use lowercase letters, numbers, and underscores only'
            );

            $this->savePreset($presetName);
            info("Configuration saved as preset: {$presetName}");
            info("Run again with: php artisan seed:generate --preset={$presetName}");
        }
    }

    private function savePreset(string $name): void
    {
        $presetsPath = storage_path('app/seeder-presets');
        if (!is_dir($presetsPath)) {
            mkdir($presetsPath, 0755, true);
        }
        file_put_contents(
            "{$presetsPath}/{$name}.json",
            json_encode($this->config, JSON_PRETTY_PRINT)
        );
    }

    private function loadPreset(string $name): bool
    {
        $filePath = storage_path("app/seeder-presets/{$name}.json");
        if (!file_exists($filePath)) {
            return false;
        }
        $this->config = json_decode(file_get_contents($filePath), true);
        return true;
    }

    private function getDefaultCount(string $modelName): string
    {
        return match($modelName) {
            'User' => '50',
            'Post' => '200',
            'Comment' => '500',
            'Category' => '10',
            'Product' => '100',
            'Order' => '300',
            'Tag' => '20',
            default => '50'
        };
    }

    private function getCountHint(string $modelName): string
    {
        return match($modelName) {
            'User' => 'Recommended: 10-100 for testing',
            'Post' => 'Recommended: 50-500 depending on use case',
            'Comment' => 'Typically 2-5x the number of posts',
            'Category' => 'Usually 5-20 categories',
            default => 'Enter desired count'
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

This wizard demonstrates several powerful features:

  • Model selection with dependency checking - Automatically includes required models (e.g., Comments require Posts)
  • Smart validation with inline error messages - Ensures valid numeric inputs and proper ranges
  • Conditional prompts for relationships - Only asks relevant questions based on selected models
  • Configuration preview with tables - Shows a clean summary before execution
  • Preset system - Save configurations for reuse across different environments
  • Progress feedback with spinners - Visual indication during long-running seed operations
  • Error recovery - Gracefully handles failures and allows continuing with other models.

Usage Examples:

# Interactive mode - walks through all options
php artisan seed:generate

# Quick start with preset
php artisan seed:generate --preset=blog_testing

# Common presets to create:
# - blog_testing: 50 users, 200 posts, 400 comments
# - demo: Beautiful data for client presentations
# - performance: 10,000+ records for load testing
# - minimal: Just enough data to start development
Enter fullscreen mode Exit fullscreen mode

This approach transforms database seeding from a manual, error-prone process into a guided experience that saves time and reduces mistakes. Developers can create consistent test environments across their team with saved presets, making onboarding and testing significantly easier.

Terminal Images For Reference:

Interesting Facts

Cross-Platform Compatibility Magic: Laravel Prompts automatically detects the terminal environment and adjusts its rendering strategy. On Windows, it uses different control sequences than on Unix-based systems, ensuring consistent appearance everywhere.

Zero Dependencies Philosophy: Unlike most CLI packages that rely on external libraries, Laravel Prompts is entirely self-contained. This design decision keeps installations lightweight and reduces potential security vulnerabilities.

Accessibility Features: The package includes screen reader support and works with various terminal accessibility tools. Keyboard navigation follows standard conventions, making it intuitive for users familiar with terminal applications.

Vim Keybinding Support: Power users can navigate prompts using h, j, k, l keys in addition to arrow keys. This thoughtful addition shows Laravel's attention to developer experience.

Fallback Mode: When running in environments without TTY support (like CI/CD pipelines), Prompts automatically falls back to simple input/output, ensuring your commands work everywhere.

Best Practices

Always provide clear, concise labels that explain what information you're requesting. Avoid technical jargon unless your audience expects it. Good labels reduce confusion and speed up the interaction process.

Use validation early and provide helpful error messages. Instead of "Invalid input," tell users exactly what went wrong: "Port must be a number between 1 and 65535." This guidance prevents frustration and reduces support requests.

Implement sensible defaults for every prompt when possible. Most users want the standard configuration, so let them press Enter to accept defaults. This respects their time while still allowing customization.

Group related prompts together and use info/warning messages to provide context. Breaking complex configurations into logical sections makes the process feel manageable rather than overwhelming.
Test your prompts in different terminal emulators. While Laravel Prompts handles most compatibility issues, verifying the experience across Windows Command Prompt, PowerShell, and various Unix shells ensures quality.

FAQ's

Q: Can I use Laravel Prompts outside of Laravel applications? A: Yes! Laravel Prompts is framework-agnostic and works in any PHP project. Install it via Composer with composer require laravel/prompts and start using the functions immediately.

Q: How do I handle prompts in automated testing? A: Laravel Prompts includes testing helpers. Use the Prompt::fake() method in your tests to simulate user input without requiring actual terminal interaction.

Q: Do prompts work in Docker containers? A: Yes, but ensure your container has TTY enabled. Use docker run -it or set tty: true in docker-compose.yml for interactive prompts to work properly.

Q: Can I customize the appearance of prompts? A: While the default styling is consistent and professional, you can create custom prompt classes extending the base components if you need specific visual modifications.

Q: What happens if a user cancels a prompt with Ctrl+C? A: Laravel Prompts respects cancellation and throws a UserCancelledException. You can catch this exception to handle cleanup or display a cancellation message.

Q: Are prompts compatible with Windows Command Prompt? A: Absolutely. Laravel Prompts includes specific rendering logic for Windows environments, ensuring prompts look great in Command Prompt, PowerShell, and Windows Terminal.

Q: Can I use prompts for file selection? A: While there's no built-in file browser prompt, you can combine search prompts with filesystem scanning to create effective file selection interfaces.

Q: How do I add help text or hints to prompts? A: Most prompt functions accept a hint parameter where you can provide additional context. This text appears below the prompt in a muted color.

"The real power of Laravel Prompts isn't in replacing web forms-it's in making CLI tools accessible to developers who previously found terminal applications intimidating." - Freek Van der Herten, Laravel Developer

Conclusion

Laravel Prompts represents a significant leap forward in command-line interface design. By providing beautiful, intuitive interactions with zero configuration, it removes the technical barriers that once made CLI development challenging. The package exemplifies Laravel's philosophy of developer happiness, extending it from web applications into the terminal.

As Laravel 12 continues to evolve, Prompts will remain a cornerstone of CLI development within the ecosystem. Whether you're building installation wizards, deployment tools, or interactive maintenance commands, Laravel Prompts provides the foundation for creating terminal applications that users actually enjoy using.

About the Author: Vatsal is a web developer at AddWebSolution. Building web magic with Laravel, PHP, MySQL, Vue.js & more. Blending code, coffee, and creativity to bring ideas to life.

Top comments (0)