Building AI-Powered CLI Tools with Laravel Zero

· 10 min read

Laravel Zero is a micro-framework for building standalone CLI applications. Combined with LLM APIs, you can create powerful AI-powered command-line tools — code reviewers, content generators, data analyzers — all distributable as a single binary.

Why Laravel Zero?

Feature Artisan Commands Laravel Zero
Requires full Laravel Yes No
Standalone binary No Yes (PHAR)
Installation composer install whole app Download one file
Dependencies Full framework Only what you need
Best for App-internal CLI Standalone tools

Installation

composer create-project laravel-zero/laravel-zero ai-cli
cd ai-cli

Add Required Packages

# HTTP client for API calls
php ai-cli app:install http

# Database support (for SQLite-based memory)
php ai-cli app:install database

# Dotenv for API keys
php ai-cli app:install dotenv

Building a Code Review Tool

The Main Command

// app/Commands/ReviewCommand.php

namespace App\Commands;

use Illuminate\Support\Facades\Http;
use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\textarea;
use function Laravel\Prompts\select;
use function Laravel\Prompts\spin;

class ReviewCommand extends Command
{
    protected $signature = 'review
        {file? : Path to the file to review}
        {--language= : Programming language}
        {--focus=general : Review focus (security, performance, general)}';

    protected $description = 'AI-powered code review for any file';

    public function handle(): int
    {
        $filePath = $this->argument('file');

        if (!$filePath) {
            $filePath = textarea(
                label: 'Paste the file path or code to review:',
                placeholder: '/path/to/your/file.php',
            );
        }

        // Read file or use pasted code
        if (file_exists($filePath)) {
            $code = file_get_contents($filePath);
            $language = $this->option('language') ?? $this->detectLanguage($filePath);
        } else {
            $code = $filePath; // Treat as pasted code
            $language = $this->option('language') ?? select(
                label: 'What language is this?',
                options: ['php', 'javascript', 'python', 'go', 'rust', 'other'],
            );
        }

        $focus = $this->option('focus');

        $this->info("Reviewing {$language} code with focus: {$focus}...\n");

        $review = spin(
            callback: fn () => $this->callAI($code, $language, $focus),
            message: 'AI is reviewing your code...',
        );

        $this->newLine();
        $this->line($review);

        return Command::SUCCESS;
    }

    private function callAI(string $code, string $language, string $focus): string
    {
        $systemPrompt = match ($focus) {
            'security' => 'You are a security-focused code reviewer. Look for vulnerabilities: SQL injection, XSS, CSRF, authentication issues, input validation, and OWASP Top 10 issues.',
            'performance' => 'You are a performance-focused code reviewer. Look for N+1 queries, unnecessary loops, missing indexes, memory leaks, and optimization opportunities.',
            default => 'You are an expert code reviewer. Analyze code quality, patterns, bugs, and suggest improvements. Be specific and actionable.',
        };

        $response = Http::withToken(env('OPENAI_API_KEY'))
            ->timeout(120)
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => 'gpt-4o',
                'messages' => [
                    ['role' => 'system', 'content' => $systemPrompt],
                    ['role' => 'user', 'content' => "Review this {$language} code:\n\n```{$language}\n{$code}\n```"],
                ],
                'max_tokens' => 2000,
            ]);

        return $response->json('choices.0.message.content', 'Failed to get review');
    }

    private function detectLanguage(string $path): string
    {
        return match (pathinfo($path, PATHINFO_EXTENSION)) {
            'php' => 'php',
            'js', 'jsx', 'ts', 'tsx' => 'javascript',
            'py' => 'python',
            'go' => 'go',
            'rs' => 'rust',
            'rb' => 'ruby',
            default => 'unknown',
        };
    }
}

Streaming AI Responses

Don't make users wait. Stream the response token by token:

// app/Commands/AskCommand.php

namespace App\Commands;

use LaravelZero\Framework\Commands\Command;

class AskCommand extends Command
{
    protected $signature = 'ask {question : Your question}';
    protected $description = 'Ask an AI question and get a streaming response';

    public function handle(): int
    {
        $question = $this->argument('question');

        $this->streamResponse($question);
        $this->newLine(2);

        return Command::SUCCESS;
    }

    private function streamResponse(string $question): void
    {
        $ch = curl_init();

        curl_setopt_array($ch, [
            CURLOPT_URL => 'https://api.openai.com/v1/chat/completions',
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . env('OPENAI_API_KEY'),
            ],
            CURLOPT_POSTFIELDS => json_encode([
                'model' => 'gpt-4o',
                'messages' => [
                    ['role' => 'system', 'content' => 'You are a helpful programming assistant. Be concise.'],
                    ['role' => 'user', 'content' => $question],
                ],
                'stream' => true,
            ]),
            CURLOPT_WRITEFUNCTION => function ($ch, $data) {
                $lines = explode("\n", $data);
                foreach ($lines as $line) {
                    $line = trim($line);
                    if (str_starts_with($line, 'data: ') && $line !== 'data: [DONE]') {
                        $json = json_decode(substr($line, 6), true);
                        $content = $json['choices'][0]['delta']['content'] ?? '';
                        if ($content) {
                            $this->output->write($content);
                        }
                    }
                }
                return strlen($data);
            },
        ]);

        curl_exec($ch);
        curl_close($ch);
    }
}

Interactive Chat Mode

// app/Commands/ChatCommand.php

namespace App\Commands;

use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\text;
use function Laravel\Prompts\confirm;

class ChatCommand extends Command
{
    protected $signature = 'chat {--system= : Custom system prompt}';
    protected $description = 'Interactive AI chat in your terminal';

    private array $messages = [];

    public function handle(): int
    {
        $systemPrompt = $this->option('system') ?? 'You are a helpful programming assistant.';
        $this->messages[] = ['role' => 'system', 'content' => $systemPrompt];

        $this->info('AI Chat (type "exit" to quit, "/clear" to reset)');
        $this->newLine();

        while (true) {
            $input = text(
                label: 'You',
                placeholder: 'Ask anything...',
            );

            if ($input === 'exit' || $input === 'quit') {
                $this->info('Goodbye!');
                break;
            }

            if ($input === '/clear') {
                $this->messages = [['role' => 'system', 'content' => $systemPrompt]];
                $this->info('Conversation cleared.');
                continue;
            }

            if ($input === '/save') {
                $this->saveConversation();
                continue;
            }

            $this->messages[] = ['role' => 'user', 'content' => $input];

            $this->newLine();
            $this->output->write('<fg=green>AI:</> ');
            $response = $this->streamChat();
            $this->newLine(2);

            $this->messages[] = ['role' => 'assistant', 'content' => $response];
        }

        return Command::SUCCESS;
    }

    private function streamChat(): string
    {
        $fullResponse = '';

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => 'https://api.openai.com/v1/chat/completions',
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . env('OPENAI_API_KEY'),
            ],
            CURLOPT_POSTFIELDS => json_encode([
                'model' => 'gpt-4o',
                'messages' => $this->messages,
                'stream' => true,
            ]),
            CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$fullResponse) {
                foreach (explode("\n", $data) as $line) {
                    $line = trim($line);
                    if (str_starts_with($line, 'data: ') && $line !== 'data: [DONE]') {
                        $json = json_decode(substr($line, 6), true);
                        $content = $json['choices'][0]['delta']['content'] ?? '';
                        if ($content) {
                            $this->output->write($content);
                            $fullResponse .= $content;
                        }
                    }
                }
                return strlen($data);
            },
        ]);

        curl_exec($ch);
        curl_close($ch);

        return $fullResponse;
    }

    private function saveConversation(): void
    {
        $filename = 'chat-' . date('Y-m-d-His') . '.md';
        $content = "# AI Chat - " . date('Y-m-d H:i:s') . "\n\n";

        foreach ($this->messages as $msg) {
            if ($msg['role'] === 'system') continue;
            $role = $msg['role'] === 'user' ? '**You**' : '**AI**';
            $content .= "{$role}: {$msg['content']}\n\n---\n\n";
        }

        file_put_contents($filename, $content);
        $this->info("Conversation saved to: {$filename}");
    }
}

Content Generator Tool

// app/Commands/GenerateCommand.php

class GenerateCommand extends Command
{
    protected $signature = 'generate
        {type : Type of content (blog, readme, commit, changelog)}
        {--context= : Additional context or file path}
        {--output= : Output file path}';

    protected $description = 'Generate various types of content with AI';

    public function handle(): int
    {
        $type = $this->argument('type');
        $context = $this->option('context');

        if ($context && file_exists($context)) {
            $context = file_get_contents($context);
        }

        $prompt = match ($type) {
            'blog' => $this->blogPrompt($context),
            'readme' => $this->readmePrompt($context),
            'commit' => $this->commitPrompt($context),
            'changelog' => $this->changelogPrompt($context),
            default => throw new \InvalidArgumentException("Unknown type: {$type}"),
        };

        $result = spin(
            callback: fn () => $this->generate($prompt),
            message: "Generating {$type}...",
        );

        if ($output = $this->option('output')) {
            file_put_contents($output, $result);
            $this->info("Written to: {$output}");
        } else {
            $this->line($result);
        }

        return Command::SUCCESS;
    }

    private function commitPrompt(?string $diff): string
    {
        return "Generate a conventional commit message for this diff. "
             . "Use format: type(scope): description\n\n"
             . "Diff:\n```\n{$diff}\n```";
    }

    private function readmePrompt(?string $codeContext): string
    {
        return "Generate a comprehensive README.md for a project with this code structure:\n"
             . "```\n{$codeContext}\n```\n"
             . "Include: Project description, Installation, Usage, Configuration, Contributing.";
    }

    private function blogPrompt(?string $topic): string
    {
        return "Write a technical blog post about: {$topic}\n"
             . "Format: Markdown with YAML frontmatter (title, date, description, tags).\n"
             . "Include: Code examples, explanations, practical use cases.";
    }

    private function changelogPrompt(?string $commits): string
    {
        return "Generate a CHANGELOG.md entry from these git commits:\n"
             . "```\n{$commits}\n```\n"
             . "Group by: Added, Changed, Fixed, Removed. Use Keep a Changelog format.";
    }

    private function generate(string $prompt): string
    {
        $response = Http::withToken(env('OPENAI_API_KEY'))
            ->timeout(120)
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => 'gpt-4o',
                'messages' => [
                    ['role' => 'system', 'content' => 'You are a technical content generator. Output clean, well-structured content.'],
                    ['role' => 'user', 'content' => $prompt],
                ],
            ]);

        return $response->json('choices.0.message.content', 'Generation failed');
    }
}

Building a Distributable PHAR

# Compile to a single binary
php ai-cli app:build ai-cli

# This creates: builds/ai-cli
# Users can run: ./ai-cli review myfile.php

Configuration for Build

// config/app.php
return [
    'production' => true,
    'version' => '1.0.0',
    'env' => 'production',
];
// composer.json — add box config
{
    "extra": {
        "laravel-zero": {
            "build": {
                "output": "builds/ai-cli"
            }
        }
    }
}

Distribution

# Users install via:
curl -L https://github.com/yourname/ai-cli/releases/latest/download/ai-cli -o ai-cli
chmod +x ai-cli
mv ai-cli /usr/local/bin/

# Usage
ai-cli review app/Http/Controllers/UserController.php --focus=security
ai-cli ask "How do I implement caching in Laravel?"
ai-cli chat --system="You are a Laravel expert"
ai-cli generate commit --context="$(git diff --staged)"

Error Handling & UX

public function handle(AiService $ai): int
{
    if (!env('OPENAI_API_KEY')) {
        $this->error('OPENAI_API_KEY is not configured.');
        $this->line('Create a .env file with OPENAI_API_KEY=sk-your-key');
        return Command::FAILURE;
    }

    try {
        // ... main logic
    } catch (\Illuminate\Http\Client\ConnectionException $e) {
        $this->error('Cannot connect to API. Check your internet connection.');
        return Command::FAILURE;
    } catch (\Illuminate\Http\Client\RequestException $e) {
        if ($e->response->status() === 429) {
            $this->error('Rate limited. Wait a few seconds and try again.');
        } elseif ($e->response->status() === 401) {
            $this->error('Invalid API key. Check OPENAI_API_KEY.');
        } else {
            $this->error("API error: {$e->getMessage()}");
        }
        return Command::FAILURE;
    }
}

Testing

class ReviewCommandTest extends TestCase
{
    public function test_review_command_requires_valid_file(): void
    {
        $this->artisan('review', ['file' => 'nonexistent.php'])
            ->assertExitCode(Command::FAILURE);
    }

    public function test_generate_commit_works(): void
    {
        Http::fake([
            'api.openai.com/*' => Http::response([
                'choices' => [['message' => ['content' => 'feat(auth): add login endpoint']]],
            ]),
        ]);

        $this->artisan('generate', [
            'type' => 'commit',
            '--context' => 'Added login controller',
        ])->assertExitCode(Command::SUCCESS);
    }
}

Conclusion

Laravel Zero + LLM APIs = powerful CLI tools that developers actually want to use. The key ingredients:

  1. Shared AI Service — foundation for all commands (chat + stream)
  2. Streaming — show responses as they generate, don't wait for full response
  3. Interactive prompts — use Laravel Prompts for beautiful, professional input
  4. Single binary — distribute as PHAR, users don't need Laravel/PHP dependencies
  5. Focused commands — one tool per job (review, chat, generate)
  6. Pipe-friendly — read stdin for pipelines: git diff | ai-cli generate commit

More tool ideas:

  • ai-cli explain app/Models/Order.php — explain code
  • ai-cli test app/Actions/CreateUser.php — generate tests
  • ai-cli translate content/posts/vi/... --to=en — translate content

Comments