The Laravel Feature That Replaced My Entire Admin Panel in 47 Lines of Code

Laravel Prompts, terminal-based UIs, Artisan commands with interactive menus, progress bars, and table output — the internal tooling stack that lets solo developers and small teams manage production data without building a full Filament panel.


Every project hits the moment where someone — you, a colleague, a non-technical co-founder — needs to manage something in production. Impersonate a user. Grant a subscription. Trigger a one-off refund. Bulk-update a set of records.

The typical response: build an admin panel. Filament, Nova, or a custom dashboard. Hours of work, a new dependency, authentication to set up, routes to secure, a whole UI surface area to maintain.

The better response for 80% of these use cases: a 47-line Artisan command with Laravel Prompts.

Laravel Prompts — first-party, ships with Laravel, fully integrated since Laravel 10, further enhanced in v0.3.15 (March 2026) with new primitives from the Laravel Cloud CLI team — turns terminal commands into interactive interfaces that feel genuinely polished. Searchable selects. Multi-step forms. Progress bars. Real-time validation. Table output. Confirmation dialogs before destructive actions.

This is the complete guide to building internal tooling that actually works.


Why Terminal Tooling Over an Admin Panel

Before the code, the argument. Admin panels are the right choice when:

  • Non-technical staff need to manage data daily
  • The operations are complex enough to warrant a visual UI
  • You have time to build and secure the admin layer properly

Terminal tooling is the right choice when:

  • Only developers or technical staff will run it
  • The operation is infrequent (weekly or less)
  • You need it working today, not after building a UI
  • The risk of mistakes is high — confirmation dialogs are a feature, not a limitation

A 47-line command that a developer runs three times a month is better than a half-finished admin panel that nobody secured properly.


What Laravel Prompts Provides

use function Laravel\Prompts\text;
use function Laravel\Prompts\password;
use function Laravel\Prompts\select;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\search;
use function Laravel\Prompts\multisearch;
use function Laravel\Prompts\suggest;
use function Laravel\Prompts\pause;
use function Laravel\Prompts\spin;
use function Laravel\Prompts\note;
use function Laravel\Prompts\info;
use function Laravel\Prompts\warning;
use function Laravel\Prompts\error;
use function Laravel\Prompts\alert;
use function Laravel\Prompts\table;
use function Laravel\Prompts\progress;
use function Laravel\Prompts\form;

Every input type you’d build in a web form is available as a terminal primitive. And they have features web forms take granted but CLI tools rarely have: placeholder text, default values, real-time validation, hint text below the input, required field enforcement.


The 47-Line Command That Replaced My Admin Panel

The scenario: a SaaS application. Users occasionally need their subscription manually upgraded, their trial extended, or their account impersonated for support purposes. Previously this meant an admin panel. Now it’s one command:

// app/Console/Commands/ManageUser.php
<?php

namespace App\Console\Commands;

use App\Models\User;
use App\Models\Subscription;
use Illuminate\Console\Command;
use function Laravel\Prompts\search;
use function Laravel\Prompts\select;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\table;
use function Laravel\Prompts\info;
use function Laravel\Prompts\warning;
use function Laravel\Prompts\spin;

class ManageUser extends Command
{
    protected $signature   = 'admin:user';
    protected $description = 'Manage a user account from the terminal';

    public function handle(): int
    {
        // Step 1: Find the user with a searchable select
        $userId = search(
            label:       'Find a user',
            placeholder: 'Search by name or email...',
            options:     fn(string $search) => User::where('name', 'like', "%{$search}%")
                             ->orWhere('email', 'like', "%{$search}%")
                             ->limit(10)
                             ->get()
                             ->mapWithKeys(fn($u) => [$u->id => "{$u->name} ({$u->email})"])
                             ->all(),
        );

        $user = User::with('subscription')->findOrFail($userId);

        // Step 2: Show current user state
        table(
            headers: ['Field', 'Value'],
            rows: [
                ['Name',         $user->name],
                ['Email',        $user->email],
                ['Plan',         $user->subscription?->plan ?? 'None'],
                ['Trial ends',   $user->trial_ends_at?->diffForHumans() ?? 'N/A'],
                ['Created',      $user->created_at->diffForHumans()],
                ['Status',       $user->active ? 'Active' : 'Suspended'],
            ]
        );

        // Step 3: Choose action
        $action = select(
            label:   'What do you want to do?',
            options: [
                'upgrade'   => 'Upgrade subscription plan',
                'trial'     => 'Extend trial period',
                'suspend'   => 'Suspend / Reactivate account',
                'impersonate' => 'Generate impersonation link',
                'cancel'    => 'Cancel — do nothing',
            ]
        );

        return match($action) {
            'upgrade'     => $this->upgradeSubscription($user),
            'trial'       => $this->extendTrial($user),
            'suspend'     => $this->toggleSuspension($user),
            'impersonate' => $this->generateImpersonationLink($user),
            'cancel'      => (info('Nothing changed.') ?: self::SUCCESS),
        };
    }
}

That’s the entire skeleton. Fifty-ish lines. A searchable user search. A summary table of the user’s current state. A menu of actions. Let’s build out each action.


Each Action: The Full Implementation

Upgrading a Subscription

private function upgradeSubscription(User $user): int
{
    $currentPlan = $user->subscription?->plan ?? 'none';

    $newPlan = select(
        label:   'Select new plan',
        options: ['starter' => 'Starter ($29/mo)', 'pro' => 'Pro ($79/mo)', 'enterprise' => 'Enterprise ($199/mo)'],
        default: $currentPlan,
        hint:    "Current plan: {$currentPlan}",
    );

    if ($newPlan === $currentPlan) {
        warning('Plan unchanged — already on that plan.');
        return self::SUCCESS;
    }

    if (!confirm("Upgrade {$user->name} from {$currentPlan} to {$newPlan}?")) {
        info('Cancelled.');
        return self::SUCCESS;
    }

    spin(
        fn() => $user->subscription()->updateOrCreate(
            ['user_id' => $user->id],
            ['plan' => $newPlan, 'updated_at' => now()]
        ),
        'Updating subscription...'
    );

    info("✓ {$user->name} upgraded to {$newPlan}.");
    return self::SUCCESS;
}

Extending a Trial

private function extendTrial(User $user): int
{
    $currentExpiry = $user->trial_ends_at
        ? $user->trial_ends_at->format('Y-m-d')
        : now()->format('Y-m-d');

    $days = select(
        label:   'Extend trial by how many days?',
        options: ['7' => '7 days', '14' => '14 days', '30' => '30 days', '90' => '90 days'],
        hint:    "Current trial expiry: {$currentExpiry}",
    );

    $newExpiry = now()->addDays((int) $days);

    if (!confirm("Extend {$user->name}'s trial until {$newExpiry->format('M j, Y')}?")) {
        info('Cancelled.');
        return self::SUCCESS;
    }

    $user->update(['trial_ends_at' => $newExpiry]);
    info("✓ Trial extended to {$newExpiry->format('M j, Y')}.");
    return self::SUCCESS;
}

Toggle Account Suspension

private function toggleSuspension(User $user): int
{
    $action = $user->active ? 'suspend' : 'reactivate';
    $verb   = $user->active ? 'Suspend' : 'Reactivate';

    if (!confirm("{$verb} {$user->name}'s account?", default: false)) {
        info('Cancelled.');
        return self::SUCCESS;
    }

    spin(
        fn() => $user->update(['active' => !$user->active]),
        "{$verb}ing account..."
    );

    $status = $user->fresh()->active ? 'active' : 'suspended';
    info("✓ Account is now {$status}.");
    return self::SUCCESS;
}

Impersonation Link

private function generateImpersonationLink(User $user): int
{
    $token = \Str::random(64);
    $expiry = now()->addMinutes(30);

    \Cache::put("impersonate:{$token}", $user->id, $expiry);

    $url = url("/admin/impersonate?token={$token}");

    table(
        headers: ['Field', 'Value'],
        rows: [
            ['User',    $user->name],
            ['Expires', $expiry->format('H:i:s')],
            ['URL',     $url],
        ]
    );

    warning('This link expires in 30 minutes. Keep it confidential.');
    return self::SUCCESS;
}

The Full Prompt Toolkit

search() — The Most Useful Prompt for Admin Work

The search() prompt is the reason this approach works for admin tooling. It does a live database search as you type:

// Search users, orders, products — anything with a database
$orderId = search(
    label:       'Find an order',
    placeholder: 'Search by order number or customer email...',
    options: fn(string $value) => Order::with('user')
        ->where('order_number', 'like', "%{$value}%")
        ->orWhereHas('user', fn($q) => $q->where('email', 'like', "%{$value}%"))
        ->limit(10)
        ->get()
        ->mapWithKeys(fn($o) => [$o->id => "#{$o->order_number} — {$o->user->email} — {$o->created_at->format('M j')}"])
        ->all(),
    validate: fn($value) => $value ? null : 'Please select an order.',
);

form() — Multi-Step Wizard

form() groups multiple prompts into a single structured interaction. Values from earlier steps are available to later steps:

use function Laravel\Prompts\form;

$responses = form()
    ->text(
        label:    'Company name',
        required: true,
        name:     'company',
    )
    ->text(
        label:    'Admin email',
        validate: fn($val) => filter_var($val, FILTER_VALIDATE_EMAIL) ? null : 'Invalid email',
        name:     'email',
    )
    ->select(
        label:   'Starting plan',
        options: ['starter', 'pro', 'enterprise'],
        name:    'plan',
    )
    ->confirm(
        label: fn($responses) => "Create account for {$responses['company']}?",
        name:  'confirmed',
    )
    ->submit();

if ($responses['confirmed']) {
    Tenant::provision($responses['company'], $responses['email'], $responses['plan']);
    info('Tenant provisioned.');
}

progress() — Bulk Operations with Visual Feedback

use function Laravel\Prompts\progress;

// Process 500 users with a visual progress bar
$results = progress(
    label:   'Sending renewal reminders',
    steps:   User::whereSubscriptionExpiresSoon()->get(),
    callback: function (User $user) {
        Mail::to($user)->send(new RenewalReminder($user));
        return $user->email;
    },
    hint:    'This may take a few minutes...',
);

info(count($results) . ' reminders sent.');

spin() — Loading Spinner for Async Operations

use function Laravel\Prompts\spin;

$invoice = spin(
    fn() => $this->stripe->invoices->retrieve($invoiceId),
    'Fetching invoice from Stripe...'
);

// Or with a longer label
spin(
    function () use ($tenant) {
        sleep(2);  // simulate slow operation
        $tenant->runMigrations();
    },
    'Running tenant migrations...'
);

table() — Formatted Data Display

use function Laravel\Prompts\table;

// Show a table of pending jobs
$jobs = DB::table('jobs')->latest()->limit(20)->get();

table(
    headers: ['ID', 'Queue', 'Attempts', 'Reserved At', 'Available At'],
    rows: $jobs->map(fn($j) => [
        $j->id,
        $j->queue,
        $j->attempts,
        $j->reserved_at ? \Carbon\Carbon::createFromTimestamp($j->reserved_at)->diffForHumans() : 'Available',
        \Carbon\Carbon::createFromTimestamp($j->available_at)->diffForHumans(),
    ])->all()
);

multiselect() — Bulk Actions on Selected Items

use function Laravel\Prompts\multiselect;

// Let the operator select which features to enable for a tenant
$features = multiselect(
    label:    'Enable features for this tenant',
    options:  [
        'advanced-reporting' => 'Advanced Reporting',
        'api-access'         => 'API Access',
        'custom-branding'    => 'Custom Branding',
        'sso'                => 'Single Sign-On',
        'webhooks'           => 'Webhooks',
    ],
    default:  $tenant->features ?? [],
    hint:     'Space to select, Enter to confirm',
    required: true,
);

$tenant->update(['features' => $features]);

Building a Command Hub

For applications with many admin operations, build a single entry-point command that routes to sub-commands:

// app/Console/Commands/AdminHub.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use function Laravel\Prompts\select;
use function Laravel\Prompts\info;

class AdminHub extends Command
{
    protected $signature   = 'admin';
    protected $description = 'Internal admin tooling hub';

    public function handle(): int
    {
        $this->displayBanner();

        $section = select(
            label:   'What do you need to manage?',
            options: [
                'users'         => '👤  Users & Subscriptions',
                'orders'        => '📦  Orders & Refunds',
                'jobs'          => '⚙️   Queue & Background Jobs',
                'tenants'       => '🏢  Tenants & Provisioning',
                'maintenance'   => '🔧  Maintenance & Cleanup',
                'reports'       => '📊  Reports & Exports',
            ]
        );

        return match($section) {
            'users'       => $this->call('admin:user'),
            'orders'      => $this->call('admin:order'),
            'jobs'        => $this->call('admin:jobs'),
            'tenants'     => $this->call('admin:tenant'),
            'maintenance' => $this->call('admin:maintenance'),
            'reports'     => $this->call('admin:reports'),
        };
    }

    private function displayBanner(): void
    {
        $this->line('');
        $this->line('  <fg=cyan>████████████████████████████████████</>');
        $this->line('  <fg=cyan>█</>  <fg=white;options=bold>ADMIN CONTROL PANEL</>           <fg=cyan>█</>');
        $this->line("  <fg=cyan>█</>  Environment: <fg=yellow>" . app()->environment() . "</>" . str_repeat(' ', 14 - strlen(app()->environment())) . "<fg=cyan>█</>");
        $this->line('  <fg=cyan>████████████████████████████████████</>');
        $this->line('');
    }
}

Now php artisan admin is the single entry point for all internal tooling.


Real-World Command: Bulk Subscription Migration

A real example: migrating all Starter plan users to a new plan structure during a pricing change.

// app/Console/Commands/MigrateSubscriptions.php
<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use function Laravel\Prompts\info;
use function Laravel\Prompts\warning;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\table;
use function Laravel\Prompts\progress;
use function Laravel\Prompts\select;

class MigrateSubscriptions extends Command
{
    protected $signature   = 'subscriptions:migrate {--dry-run}';
    protected $description = 'Migrate Starter plan users to the new Basic plan';

    public function handle(): int
    {
        $affected = User::whereHas('subscription', fn($q) =>
            $q->where('plan', 'starter')
        )->with('subscription')->get();

        // Show what will change
        table(
            headers: ['Name', 'Email', 'Current Plan', 'New Plan', 'Price Change'],
            rows: $affected->map(fn($u) => [
                $u->name,
                $u->email,
                'Starter ($29)',
                'Basic ($19)',
                '-$10/mo',
            ])->toArray()
        );

        info("Total affected: {$affected->count()} users");

        if ($this->option('dry-run')) {
            warning('Dry run — no changes made.');
            return self::SUCCESS;
        }

        if (!confirm("Migrate all {$affected->count()} users from Starter → Basic?", default: false)) {
            info('Migration cancelled.');
            return self::SUCCESS;
        }

        $migrated = progress(
            label:    'Migrating subscriptions',
            steps:    $affected,
            callback: function (User $user) {
                $user->subscription->update([
                    'plan'       => 'basic',
                    'amount'     => 1900,
                    'migrated_at' => now(),
                ]);
                return $user->id;
            },
        );

        info("✓ Migrated {$migrated->count()} subscriptions successfully.");
        return self::SUCCESS;
    }
}

Run it safely with --dry-run first:

# Preview — shows the table, makes no changes
php artisan subscriptions:migrate --dry-run

# Execute — shows table, requires confirmation, runs with progress bar
php artisan subscriptions:migrate

Input Validation: Real-Time Feedback

Laravel Prompts validates input inline — the user sees the error immediately without resubmitting:

$email = text(
    label:    'Email address',
    required: 'Email is required',
    validate: function (string $value): ?string {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            return 'Please enter a valid email address.';
        }

        if (User::where('email', $value)->exists()) {
            return 'A user with this email already exists.';
        }

        return null;  // validation passed
    },
    hint: 'Must be unique in the system',
);

$amount = text(
    label:    'Refund amount (in paise)',
    validate: fn($val) =>
        !is_numeric($val)         ? 'Must be a number' :
        (int)$val <= 0            ? 'Must be greater than 0' :
        (int)$val > 100000        ? 'Cannot exceed ₹1,000' :
        null,
    hint: 'Enter the amount in paise (100 = ₹1)',
);

PromptsForMissingInput: Automatic Prompt on Missing Arguments

For commands that can be run with arguments (for scripting) or interactively (for humans), implement PromptsForMissingInput:

// app/Console/Commands/GrantAccess.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Contracts\Console\PromptsForMissingInput;
use function Laravel\Prompts\search;

class GrantAccess extends Command implements PromptsForMissingInput
{
    protected $signature   = 'access:grant {user} {feature}';
    protected $description = 'Grant a feature flag to a user';

    // Called automatically when arguments are missing
    protected function promptForMissingArgumentsUsing(): array
    {
        return [
            'user' => fn() => search(
                label:   'Which user?',
                options: fn($q) => User::where('email', 'like', "%{$q}%")
                    ->limit(10)->pluck('email', 'id')->all(),
            ),
            'feature' => fn() => select(
                label:   'Which feature?',
                options: array_keys(config('features')),
            ),
        ];
    }

    public function handle(): int
    {
        $user    = User::findOrFail($this->argument('user'));
        $feature = $this->argument('feature');

        $user->grantFeature($feature);
        info("✓ Granted {$feature} to {$user->email}.");
        return self::SUCCESS;
    }
}
# Scripted — no prompts
php artisan access:grant 42 advanced-reporting

# Interactive — prompts for both arguments
php artisan access:grant

The same command works in both modes. Automation-friendly and human-friendly simultaneously.


Testing Prompts Commands

Laravel Prompts provides test helpers that allow you to fake user input:

// tests/Feature/ManageUserTest.php
use Laravel\Prompts\Key;
use Laravel\Prompts\Prompt;

it('can upgrade a user subscription via the terminal', function () {
    $user = User::factory()->create();

    // Fake the search result
    Prompt::fake([
        $user->id,      // search() returns this user
        'pro',          // select() chooses 'pro'
        Key::ENTER,     // confirm() defaults to yes
    ]);

    $this->artisan('admin:user')->assertSuccessful();

    expect($user->fresh()->subscription->plan)->toBe('pro');
});

it('cancels gracefully when user declines confirmation', function () {
    $user = User::factory()->create();

    Prompt::fake([
        $user->id,
        'pro',
        'no',  // confirm() answered with 'no'
    ]);

    $this->artisan('admin:user')->assertSuccessful();

    expect($user->fresh()->subscription)->toBeNull();
});

When to Add a Real Admin Panel

Terminal tooling is excellent for developer-operated tasks. Reach for Filament or Nova when:

✓ Non-technical team members need access (customer support, marketing)
✓ Operations happen more than a few times per week per person
✓ The workflow involves uploading files or editing rich content
✓ You need audit logging visible to non-developers
✓ Multiple people need to operate simultaneously
✓ The operation requires visual data (charts, image previews)

For everything else — every developer-operated task, every monthly data operation, every “just this once” fix — the 47-line Artisan command is the right tool.


Final Thoughts

Laravel Prompts transforms the terminal from a black box into a structured UI. Searchable dropdowns, multi-step forms, progress bars, confirmation dialogs, real-time validation — features web forms take for granted, now available in your Artisan commands.

The result is internal tooling that’s fast to build, safe to run (confirmation dialogs before destructive actions), and genuinely pleasant to use. The search() prompt alone — live database search as you type — makes the “find and manage a specific record” workflow faster than most admin panel UIs.

Build the Artisan command first. If you still need the admin panel after six months of using the command, build the panel then. Most of the time, you won’t need to.

Leave a Reply

Your email address will not be published. Required fields are marked *