Artisan, Tinker and Prompts

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) and Laravel Prompts 0.3 on PHP 8.3 to 8.5, checked on 2026-10-05; every command and output was run in the Bookshelf application. Artisan is built on Symfony Console, so the concepts on the PHP Reference’s Command-Line PHP page (arguments, options, exit codes, standard streams, signals) apply directly.

The built-in commands

php artisan list shows every command, grouped by namespace; php artisan help <command> (or --help) shows its arguments and options. The groups used most often:

Group Examples

make:*

Generators: make:model -mfsc (model with migration, factory, seeder, controller), make:controller --resource, make:request, make:policy, make:job, make:event, make:listener, make:middleware, make:command, make:test --pest, make:component, make:livewire (with Livewire)

migrate*, db:*

migrate, migrate:rollback, migrate:fresh --seed, db:seed, db:show, db:table, db:monitor

route:*, config:*, view:*, event:*, optimize

Inspect and cache: route:list, config:cache, view:cache, optimize (all caches), optimize:clear

queue:*, schedule:*

queue:work, queue:listen, queue:failed, queue:retry, schedule:list, schedule:run, schedule:work

install:*

install:api (API routes and Sanctum or Passport), install:broadcasting (Reverb)

Others

about, tinker, serve, dev, test, down/up, key:generate, storage:link, vendor:publish, lang:publish, stub:publish, pail

Generators fill files from stubs. php artisan stub:publish copies the stubs to stubs/ in the project, where they can be edited (for example to add declare(strict_types=1); or a licence header to every generated class).

Writing a command

php artisan make:command ImportBooks creates a class in app/Console/Commands, discovered automatically. The signature declares the name, arguments and options in one string; handle() does the work and returns an exit code. Bookshelf’s CSV importer:

<?php
// app/Console/Commands/ImportBooks.php
namespace App\Console\Commands;

use App\Models\Author;
use App\Models\Book;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
use Illuminate\Contracts\Console\PromptsForMissingInput;

use function Laravel\Prompts\confirm;
use function Laravel\Prompts\table;

class ImportBooks extends Command implements Isolatable, PromptsForMissingInput
{
    protected $signature = 'books:import
        {file : Path to a CSV file with title,author,year rows}
        {--dry-run : Show what would be imported without saving}
        {--limit=0 : Import at most this many rows (0 = all)}';

    protected $description = 'Import books from a CSV file';

    public function handle(): int
    {
        $path = $this->argument('file');
        if (! is_readable($path)) {
            $this->error("Cannot read $path");
            return self::FAILURE;                               // exit code 1
        }

        $rows = array_map('str_getcsv', file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
        if ($limit = (int) $this->option('limit')) {
            $rows = array_slice($rows, 0, $limit);
        }

        table(headers: ['Title', 'Author', 'Year'], rows: $rows);

        if ($this->option('dry-run') || ! confirm('Import '.count($rows).' books?', default: true)) {
            $this->info('Nothing imported.');
            return self::SUCCESS;
        }

        $this->withProgressBar($rows, function (array $row) {
            [$title, $author, $year] = $row + [2 => null];
            Book::create([
                'author_id' => Author::firstOrCreate(['name' => $author])->id,
                'title' => $title,
                'isbn' => 'import-'.md5($title.$author),
                'published_year' => $year ?: null,
            ]);
        });
        $this->newLine();
        $this->info('Imported '.count($rows).' books.');

        return self::SUCCESS;
    }

    /** Questions to ask when a required argument is missing (PromptsForMissingInput). */
    protected function promptForMissingArgumentsUsing(): array
    {
        return ['file' => ['Which CSV file should be imported?', 'storage/app/import/books.csv']];
    }
}

The signature syntax: {file} is a required argument, {file?} optional, {file=default} with a default, {files*} an array; {--dry-run} is a boolean option, {--limit=} an option with a value, {--limit=0} with a default, {--L|limit=} with a shortcut, {--tag=*} an array option; text after ` : ` is the help description. Laravel generates the help page from it:

$ php artisan books:import --help
Description:
  Import books from a CSV file

Usage:
  books:import [options] [--] <file>

Arguments:
  file                       Path to a CSV file with title,author,year rows

Options:
      --dry-run              Show what would be imported without saving
      --limit[=LIMIT]        Import at most this many rows (0 = all) [default: "0"]
      --isolated[=ISOLATED]  Do not run the command if another instance of the command is already running [default: false]
  ...
$ php artisan books:import storage/app/import/books.csv --dry-run

 ┌─────────────────────┬───────────────────┬──────┐
 │ Title               │ Author            │ Year │
 ├─────────────────────┼───────────────────┼──────┤
 │ Wild Seed           │ Octavia E. Butler │ 1980 │
 │ The Lathe of Heaven │ Ursula K. Le Guin │ 1971 │
 │ The Obelisk Gate    │ N. K. Jemisin     │ 2016 │
 └─────────────────────┴───────────────────┴──────┘

Nothing imported.

$ php artisan books:import storage/app/import/books.csv --limit=2 --no-interaction
 ...
 2/2 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
Imported 2 books.

$ php artisan books:import nope.csv -n; echo "exit $?"
Cannot read nope.csv
exit 1

What the run shows:

  • Exit codes matter for scripts, cron and CI: self::SUCCESS (0), self::FAILURE (1), self::INVALID (2). An uncaught exception also exits with 1.

  • --no-interaction (-n) answers every prompt with its default (confirm(default: true) here), so the same command works in a deployment script.

  • Isolatable adds the --isolated option, which takes a cache lock so that only one instance runs at a time; a second instance exits without running.

  • PromptsForMissingInput: running php artisan books:import without the file asks for it instead of failing with "Not enough arguments".

Output helpers: info(), comment(), warn(), error(), line(), newLine(), table() and withProgressBar(), or the Prompts functions below. Long-running commands can react to signals with $this->trap(SIGTERM, fn () => $this->shouldKeepRunning = false) to finish the current item before exiting.

Closure commands and calling commands from code

Small commands can be closures in routes/console.php, which is also where the schedule lives:

<?php
// routes/console.php
use App\Models\Book;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('books:count', function () {
    $this->info(Book::count().' books in the catalogue');
})->purpose('Count the books');

Schedule::command('books:import storage/app/import/books.csv --no-interaction')->dailyAt('03:00');

Artisan::call('books:import', ['file' => …, '--dry-run' => true]) runs a command from code and returns its exit code (int(0) for the dry run in the test application), and Artisan::output() returns what it printed; Artisan::queue() runs it on a queue worker instead. From inside another command, $this->call() and $this->callSilently() do the same.

Tinker

php artisan tinker is a REPL (based on PsySH) with the application booted: models, facades and helpers are all available, which makes it the quickest way to try a query or inspect data. --execute runs one snippet and exits, which is how many examples in this section were produced:

$ php artisan tinker                       # interactive: type PHP, see the result, exit to quit

$ php artisan tinker --execute='echo App\Models\Book::whereRelation("author", "name", "Octavia E. Butler")->orderBy("published_year")->pluck("title")->toJson();'
["Kindred","Wild Seed","Parable of the Sower"]
$ php artisan books:count                 # the closure command above
25 books in the catalogue
$ php artisan schedule:list
  0 3 * * *  php artisan books:import storage/app/import/books.csv --no-interaction  Next Due: 7 hours from now

Tinker runs with the full permissions of the application, against whatever database .env points to. Use it in production only with great care (prefer read-only queries), and never paste untrusted code into it.

Laravel Prompts

Laravel Prompts draws interactive forms in the terminal: text, textarea, number, password, confirm, select, multiselect, suggest, search, multisearch, pause and autocomplete, plus output helpers (info, warning, error, alert, note, intro, outro, table, callout), spin, progress and task. Each input prompt supports a placeholder, a default, a hint, required, and a validate closure that returns an error message or null. form() chains several prompts and returns all answers. It works in any PHP command-line application, not only in Artisan.

The form below ran with simulated keystrokes (Prompt::fake(), as in tests): the title typed as Dawn, the rating moved down two places, and the confirmation accepted:

<?php
require __DIR__ . '/vendor/autoload.php';

use Laravel\Prompts\Key;
use Laravel\Prompts\Prompt;

use function Laravel\Prompts\form;

// Simulated keystrokes, as in a test: type "Dawn", Enter; arrow down twice, Enter; Enter (accept default)
Prompt::fake(['D', 'a', 'w', 'n', Key::ENTER, Key::DOWN, Key::DOWN, Key::ENTER, Key::ENTER]);

$review = form()
    ->text('Book title', required: true, validate: fn (string $v) => mb_strlen($v) < 2 ? 'Too short.' : null, name: 'title')
    ->select('Rating', options: [5 => '★★★★★', 4 => '★★★★', 3 => '★★★'], name: 'rating')
    ->confirm('Publish now?', default: true, name: 'publish')
    ->submit();

var_dump($review);
// array(3) { ["title"]=> string(4) "Dawn" ["rating"]=> int(3) ["publish"]=> bool(true) }

Prompts supports macOS, Linux and Windows under WSL. In unsupported environments (native Windows PHP), Laravel automatically falls back to Symfony Console’s question helper for each prompt. When input is not interactive (cron, CI, --no-interaction), prompts return their defaults, and a required prompt without a default fails. spin() and task() animate only when the pcntl extension is available.

Testing commands

Tests run a command with $this->artisan() and describe the conversation: the questions, the answers and the output. The same assertions work for Symfony-style questions and for Prompts:

<?php
// tests/Feature/ImportBooksTest.php
use App\Models\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('it asks for the missing file and confirms before importing', function () {
    $this->artisan('books:import')
        ->expectsQuestion('Which CSV file should be imported?', 'storage/app/import/books.csv')
        ->expectsPromptsTable(headers: ['Title', 'Author', 'Year'], rows: [
            ['Wild Seed', 'Octavia E. Butler', '1980'],
            ['The Lathe of Heaven', 'Ursula K. Le Guin', '1971'],
            ['The Obelisk Gate', 'N. K. Jemisin', '2016'],
        ])
        ->expectsConfirmation('Import 3 books?', 'yes')
        ->expectsOutputToContain('Imported 3 books.')
        ->assertSuccessful();

    expect(Book::count())->toBe(3);
});

test('answering no imports nothing', function () {
    $this->artisan('books:import', ['file' => 'storage/app/import/books.csv'])
        ->expectsConfirmation('Import 3 books?', 'no')
        ->expectsOutput('Nothing imported.')
        ->assertExitCode(0);

    expect(Book::count())->toBe(0);
});
$ php artisan test --filter=ImportBooksTest
   PASS  Tests\Feature\ImportBooksTest
  ✓ it asks for the missing file and confirms before importing           0.22s
  ✓ answering no imports nothing                                         0.01s
  Tests:    2 passed (10 assertions)

A detail found while writing the test: expectsTable() matches tables printed with the command’s own table() method (Symfony’s renderer), while a table drawn with the Prompts table() function needs expectsPromptsTable(); using the wrong one fails with Output "+---------------------+…" was not printed.

Where older books and tutorials are outdated

  • Registering commands in app/Console/Kernel.php (Stauffer ch. 8) is unnecessary: commands in app/Console/Commands are discovered, and closure commands and the schedule live in routes/console.php (Laravel 11).

  • $this->ask(), $this->choice() and $this->secret() still work, but Laravel Prompts (Laravel 10) gives validation, search, multi-select and forms with a much better terminal experience.

  • exit() in commands: return self::SUCCESS or self::FAILURE from handle() instead, so that Artisan::call() and tests receive the code.

  • Running two copies of a long job from cron needed hand-written lock files; Isolatable (Laravel 9.x) and the scheduler’s withoutOverlapping() handle it.