Xây Dựng AI-Powered CLI Tools với Laravel Zero

· 13 min read

Laravel Zero là micro-framework cho CLI apps standalone. Kết hợp với LLM APIs, bạn tạo được công cụ mạnh — code reviewers, content generators, data analyzers — tất cả phân phối dưới dạng một file binary. Không cần deploy web server, không cần browser.

Bài viết này build 3 tools thực tế: code reviewer, interactive chat, và content generator — từ setup đến PHAR binary.

Tại Sao Laravel Zero?

Feature Laravel Zero Vanilla PHP Symfony Console
Setup 1 command Manual Moderate
DI Container Có (Laravel) Không
HTTP Client Http:: facade cURL/Guzzle Guzzle
Interactive prompts Laravel Prompts readline() QuestionHelper
PHAR binary Built-in Manual Manual
Ecosystem Laravel packages DIY Symfony packages

Laravel Zero = Laravel power cho CLI. Bạn có service container, config, logging, HTTP client — tất cả trong CLI app.

Cài Đặt

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

# Cài đặt components cần thiết
php ai-cli app:install http      # HTTP client (gọi APIs)
php ai-cli app:install dotenv    # .env support (API keys)
php ai-cli app:install log       # Logging

Environment

# .env
OPENAI_API_KEY=sk-your-api-key-here
OPENAI_MODEL=gpt-4o
DEFAULT_LANGUAGE=vi

Config

// config/ai.php
return [
    'api_key' => env('OPENAI_API_KEY'),
    'model' => env('OPENAI_MODEL', 'gpt-4o'),
    'max_tokens' => env('AI_MAX_TOKENS', 4096),
    'temperature' => env('AI_TEMPERATURE', 0.7),
];

AI Service — Shared Foundation

// app/Services/AiService.php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class AiService
{
    public function chat(array $messages, float $temperature = null): string
    {
        $response = Http::withToken(config('ai.api_key'))
            ->timeout(120)
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => config('ai.model'),
                'messages' => $messages,
                'max_tokens' => config('ai.max_tokens'),
                'temperature' => $temperature ?? config('ai.temperature'),
            ]);

        if ($response->failed()) {
            throw new \RuntimeException(
                "AI API call failed: {$response->status()} - {$response->body()}"
            );
        }

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

    /**
     * Stream response — yield từng chunk text
     */
    public function stream(array $messages): \Generator
    {
        $response = Http::withToken(config('ai.api_key'))
            ->withOptions(['stream' => true])
            ->timeout(120)
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => config('ai.model'),
                'messages' => $messages,
                'max_tokens' => config('ai.max_tokens'),
                'stream' => true,
            ]);

        $body = $response->getBody();
        $buffer = '';

        while (!$body->eof()) {
            $buffer .= $body->read(1024);

            while (($pos = strpos($buffer, "\n")) !== false) {
                $line = substr($buffer, 0, $pos);
                $buffer = substr($buffer, $pos + 1);

                if (!str_starts_with($line, 'data: ')) continue;
                $data = substr($line, 6);
                if ($data === '[DONE]') return;

                $chunk = json_decode($data, true);
                $content = $chunk['choices'][0]['delta']['content'] ?? '';
                if ($content !== '') yield $content;
            }
        }
    }
}

Tool 1: Code Review

// app/Commands/ReviewCommand.php

namespace App\Commands;

use App\Services\AiService;
use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\{select, spin, info, error, note};

class ReviewCommand extends Command
{
    protected $signature = 'review {file} {--focus=general : Loại review: security, performance, general}';
    protected $description = 'AI-powered code review cho PHP/Laravel files';

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

        if (!file_exists($filePath)) {
            error("File không tồn tại: {$filePath}");
            return Command::FAILURE;
        }

        $code = file_get_contents($filePath);
        $language = $this->detectLanguage($filePath);
        $focus = $this->option('focus');
        $fileSize = strlen($code);

        if ($fileSize > 50_000) {
            error("File quá lớn ({$fileSize} bytes). Tối đa 50KB.");
            return Command::FAILURE;
        }

        note("Reviewing: {$filePath} ({$language}, {$fileSize} bytes, focus: {$focus})");

        $systemPrompt = $this->buildSystemPrompt($focus, $language);

        $review = spin(
            callback: fn () => $ai->chat([
                ['role' => 'system', 'content' => $systemPrompt],
                ['role' => 'user', 'content' => "Review code sau:\n\n```{$language}\n{$code}\n```"],
            ], temperature: 0.3), // Low temperature cho analysis chính xác
            message: 'AI đang review code...',
        );

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

        // Lưu review vào file nếu muốn
        if ($this->confirm('Lưu review vào file?', false)) {
            $reviewFile = $filePath . '.review.md';
            file_put_contents($reviewFile, "# Code Review: {$filePath}\n\n{$review}");
            info("Review đã lưu: {$reviewFile}");
        }

        return Command::SUCCESS;
    }

    private function buildSystemPrompt(string $focus, string $language): string
    {
        $base = "Bạn là expert {$language} code reviewer. Phân tích kỹ lưỡng và đưa ra gợi ý cụ thể, actionable.";

        return match ($focus) {
            'security' => "{$base}\n\nTập trung vào:\n" .
                "- OWASP Top 10 vulnerabilities\n" .
                "- SQL injection, XSS, CSRF\n" .
                "- Authentication/authorization issues\n" .
                "- Sensitive data exposure\n" .
                "- Input validation\n" .
                "Đánh giá severity: Critical, High, Medium, Low",

            'performance' => "{$base}\n\nTập trung vào:\n" .
                "- N+1 queries\n" .
                "- Unnecessary loops/iterations\n" .
                "- Missing database indexes\n" .
                "- Memory usage\n" .
                "- Cache opportunities\n" .
                "Ước lượng impact: High, Medium, Low",

            default => "{$base}\n\nPhân tích:\n" .
                "1. Code quality và readability\n" .
                "2. Design patterns và architecture\n" .
                "3. Potential bugs\n" .
                "4. Testing gaps\n" .
                "5. Laravel best practices\n" .
                "6. Quick wins để cải thiện",
        };
    }

    private function detectLanguage(string $filePath): string
    {
        return match (pathinfo($filePath, PATHINFO_EXTENSION)) {
            'php' => 'php',
            'js', 'mjs' => 'javascript',
            'ts', 'tsx' => 'typescript',
            'vue' => 'vue',
            'py' => 'python',
            'blade.php' => 'blade',
            default => 'text',
        };
    }
}

Sử dụng:

ai-cli review app/Http/Controllers/UserController.php
ai-cli review app/Models/Order.php --focus=security
ai-cli review app/Services/PaymentService.php --focus=performance

Tool 2: Interactive Chat Mode

// app/Commands/ChatCommand.php

namespace App\Commands;

use App\Services\AiService;
use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\{text, info, note};

class ChatCommand extends Command
{
    protected $signature = 'chat {--system= : Custom system prompt} {--context= : File path làm context}';
    protected $description = 'Interactive AI chat với streaming responses';

    private array $messages = [];

    public function handle(AiService $ai): int
    {
        $systemPrompt = $this->option('system') ?? 'Bạn là expert Laravel/PHP developer. Trả lời ngắn gọn, chính xác.';

        $this->messages[] = ['role' => 'system', 'content' => $systemPrompt];

        // Load file context nếu có
        if ($contextFile = $this->option('context')) {
            if (file_exists($contextFile)) {
                $content = file_get_contents($contextFile);
                $this->messages[] = [
                    'role' => 'system',
                    'content' => "Context file ({$contextFile}):\n```\n{$content}\n```",
                ];
                note("Loaded context: {$contextFile}");
            }
        }

        info('AI Chat (gõ "exit" để thoát, "/clear" để reset, "/save" để lưu)');
        $this->newLine();

        while (true) {
            $input = text(
                label: 'Bạn',
                placeholder: 'Hỏi gì đó... (exit để thoát)',
                required: true,
            );

            $input = trim($input);

            if ($input === 'exit' || $input === 'quit') {
                info('Tạm biệt!');
                break;
            }

            if ($input === '/clear') {
                $this->messages = [$this->messages[0]]; // Giữ system prompt
                info('Đã xóa hội thoại.');
                continue;
            }

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

            if (str_starts_with($input, '/file ')) {
                $filePath = trim(substr($input, 6));
                if (file_exists($filePath)) {
                    $content = file_get_contents($filePath);
                    $this->messages[] = [
                        'role' => 'user',
                        'content' => "Đây là file {$filePath}:\n```\n{$content}\n```",
                    ];
                    note("File loaded: {$filePath}");
                    continue;
                }
                error("File không tồn tại: {$filePath}");
                continue;
            }

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

            // Stream response
            $this->output->write('<fg=green>AI:</> ');
            $fullResponse = '';

            foreach ($ai->stream($this->messages) as $chunk) {
                $this->output->write($chunk);
                $fullResponse .= $chunk;
            }

            $this->newLine(2);
            $this->messages[] = ['role' => 'assistant', 'content' => $fullResponse];

            // Trim conversation nếu quá dài
            if (count($this->messages) > 42) {
                $system = $this->messages[0];
                $recent = array_slice($this->messages, -40);
                $this->messages = [$system, ...$recent];
            }
        }

        return Command::SUCCESS;
    }

    private function saveConversation(): void
    {
        $filename = 'chat-' . now()->format('Y-m-d-His') . '.md';

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

        file_put_contents($filename, $content);
        info("Conversation saved: {$filename}");
    }
}

Commands trong chat:

exit       — Thoát
/clear     — Reset conversation
/save      — Lưu conversation ra file markdown
/file path — Load file vào context

Sử dụng:

ai-cli chat
ai-cli chat --system="Bạn là DevOps expert"
ai-cli chat --context=app/Models/User.php

Tool 3: Content Generator

// app/Commands/GenerateCommand.php

class GenerateCommand extends Command
{
    protected $signature = 'generate {type : blog, readme, commit, changelog} {--context=} {--output=}';
    protected $description = 'AI content generator cho developers';

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

        $prompt = $this->buildPrompt($type, $context);

        $result = spin(
            callback: fn () => $ai->chat([
                ['role' => 'system', 'content' => $this->systemPromptForType($type)],
                ['role' => 'user', 'content' => $prompt],
            ]),
            message: "Đang tạo {$type}...",
        );

        if ($output) {
            file_put_contents($output, $result);
            info("Output saved: {$output}");
        } else {
            $this->newLine();
            $this->line($result);
        }

        return Command::SUCCESS;
    }

    private function loadContext(): string
    {
        $context = $this->option('context') ?? '';

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

        // Nếu stdin có data (pipe), đọc từ stdin
        if (!posix_isatty(STDIN)) {
            return stream_get_contents(STDIN);
        }

        return $context;
    }

    private function buildPrompt(string $type, string $context): string
    {
        return match ($type) {
            'commit' => "Tạo conventional commit message cho diff sau:\n```\n{$context}\n```\n\n" .
                "Format: type(scope): description\n\nTypes: feat, fix, docs, style, refactor, test, chore",

            'readme' => "Tạo README.md chuyên nghiệp cho project. Dựa trên cấu trúc/code:\n```\n{$context}\n```\n\n" .
                "Bao gồm: badges, installation, usage, configuration, contributing, license.",

            'blog' => "Viết blog post kỹ thuật chi tiết (tiếng Việt) về: {$context}\n\n" .
                "Format: YAML frontmatter + markdown. Bao gồm code examples, giải thích chi tiết, gotchas.",

            'changelog' => "Tạo CHANGELOG entry từ commits sau:\n```\n{$context}\n```\n\n" .
                "Format: Keep a Changelog style. Nhóm theo: Added, Changed, Fixed, Removed.",

            default => throw new \InvalidArgumentException("Unknown type: {$type}"),
        };
    }

    private function systemPromptForType(string $type): string
    {
        return match ($type) {
            'commit' => 'Bạn tạo conventional commit messages rõ ràng, ngắn gọn. Chỉ output commit message, không giải thích.',
            'readme' => 'Bạn tạo README.md chuyên nghiệp. Output chỉ markdown, không wrap trong code block.',
            'blog' => 'Bạn là technical writer cho blog Laravel/PHP tiếng Việt. Viết chi tiết, có code examples.',
            'changelog' => 'Bạn tạo CHANGELOG entries. Output chỉ markdown, theo Keep a Changelog format.',
            default => 'Bạn là helpful developer assistant.',
        };
    }
}

Sử dụng:

# Commit message từ git diff
git diff --staged | ai-cli generate commit

# README từ project structure
find . -name "*.php" -path "*/app/*" | head -50 | ai-cli generate readme --output=README.md

# Blog post
ai-cli generate blog --context="Laravel Middleware deep dive"

# Changelog từ git log
git log --oneline v1.0..HEAD | ai-cli generate changelog

Build & Phân Phối Binary

# Build PHAR binary
php ai-cli app:build ai-cli

# Output: builds/ai-cli (single executable file)
ls -la builds/ai-cli
# -rwxr-xr-x 1 user 8.5M builds/ai-cli

Cài Đặt Cho Users

# Download
chmod +x ai-cli && mv ai-cli /usr/local/bin/

# Tạo .env cho API key
echo "OPENAI_API_KEY=sk-your-key" > ~/.ai-cli.env

# Sử dụng
ai-cli review app/Http/Controllers/UserController.php --focus=security
ai-cli chat --system="Bạn là Laravel expert"
ai-cli generate commit --context="$(git diff --staged)"

Self-Update (Optional)

// Cài đặt auto-update component
php ai-cli app:install self-update

// Users update bằng:
ai-cli self-update

Error Handling & UX

// Trong command
public function handle(AiService $ai): int
{
    if (!config('ai.api_key')) {
        error('OPENAI_API_KEY chưa được cấu hình.');
        note('Tạo file .env với OPENAI_API_KEY=sk-your-key');
        return Command::FAILURE;
    }

    try {
        // ... main logic
    } catch (\Illuminate\Http\Client\ConnectionException $e) {
        error('Không thể kết nối API. Kiểm tra internet.');
        return Command::FAILURE;
    } catch (\Illuminate\Http\Client\RequestException $e) {
        if ($e->response->status() === 429) {
            error('Rate limited. Chờ vài giây rồi thử lại.');
        } elseif ($e->response->status() === 401) {
            error('API key không hợp lệ. Kiểm tra OPENAI_API_KEY.');
        } else {
            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_review_detects_language_correctly(): void
    {
        $command = new ReviewCommand();
        $this->assertEquals('php', $command->detectLanguage('test.php'));
        $this->assertEquals('javascript', $command->detectLanguage('app.js'));
        $this->assertEquals('typescript', $command->detectLanguage('types.ts'));
    }

    public function test_generate_commit_parses_diff(): void
    {
        // Mock AI service
        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);
    }
}

Kết Luận

Laravel Zero + LLM APIs = CLI tools mạnh mẽ, phân phối dễ dàng. Thành phần chính:

  1. AiService — shared foundation cho tất cả commands (chat + stream)
  2. Streaming — hiển thị response realtime, không chờ full response
  3. Interactive prompts — dùng Laravel Prompts cho input đẹp, professional
  4. Single binary — phân phối PHAR, users không cần cài Laravel/PHP dependencies
  5. Focused commands — mỗi tool một việc (review, chat, generate)
  6. Pipe-friendly — đọc stdin cho pipeline git diff | ai-cli generate commit

Ý tưởng tools khác:

  • ai-cli explain app/Models/Order.php — giải thích code
  • ai-cli test app/Actions/CreateUser.php — generate tests
  • ai-cli migrate — generate migration từ description
  • ai-cli translate content/posts/vi/... --to=en — translate blog posts

Bình luận