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:
- Shared AI Service — foundation for all commands (chat + stream)
- Streaming — show responses as they generate, don't wait for full response
- Interactive prompts — use Laravel Prompts for beautiful, professional input
- Single binary — distribute as PHAR, users don't need Laravel/PHP dependencies
- Focused commands — one tool per job (review, chat, generate)
- Pipe-friendly — read stdin for pipelines:
git diff | ai-cli generate commit
More tool ideas:
ai-cli explain app/Models/Order.php— explain codeai-cli test app/Actions/CreateUser.php— generate testsai-cli translate content/posts/vi/... --to=en— translate content