fomvasss / laravel-ai-tasks
AI task orchestrator for Laravel: routing, queue, audit, budget, webhooks
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/pipeline: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- laravel/ai: ^0.8
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0
README
Support
If this package is useful to you, consider supporting its development:
USDT TRC20:
THLgp6DxiAtbNHvgnKV56vk1L38UuUagKf
AI task orchestrator for Laravel. Handles routing, queuing, audit logging, budget tracking, and webhook processing on top of laravel/ai as the transport layer.
Dashboard
Built-in web UI at /ai-tasks — runs list with stats, filters, and per-run detail (request, response, tokens, cost).
The dashboard auto-refreshes every 8 seconds — stats and the current table page update without a full reload, respecting any active filters and pagination.
Configurable via config/ai-tasks.php:
'dashboard' => [ 'enabled' => env('AI_DASHBOARD_ENABLED', true), 'path' => env('AI_DASHBOARD_PATH', 'ai-tasks'), 'middleware' => ['web'], ],
Requirements
- PHP ^8.3
- Laravel ^12 | ^13
- laravel/ai ^0.8
Installation
composer require fomvasss/laravel-ai-tasks
Publish configs and run migrations:
# laravel/ai provider config (credentials go here) php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider" --tag=ai-config # this package config (routing, budgets, queues) php artisan vendor:publish --tag=ai-tasks-config php artisan vendor:publish --tag=ai-migrations php artisan migrate
Add API keys to .env — credentials are read by laravel/ai:
AI_DEFAULT=openai OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... GEMINI_API_KEY=... DEEPSEEK_API_KEY=sk-... GROQ_API_KEY=gsk_...
Two config files
| File | Purpose |
|---|---|
config/ai.php |
laravel/ai — API keys, provider URLs |
config/ai-tasks.php |
this package — models, prices, routing, budgets |
Horizon / Queue
Two queues are used by default:
AI_QUEUE=ai AI_QUEUE_POST=ai-post
Example Horizon config:
'supervisor-ai' => [ 'connection' => 'redis', 'queue' => ['ai'], 'balance' => 'auto', 'minProcesses' => 2, 'maxProcesses' => 20, 'tries' => 3, 'timeout' => 120, ], 'supervisor-ai-post' => [ 'connection' => 'redis', 'queue' => ['ai-post'], 'balance' => 'simple', 'minProcesses' => 1, 'maxProcesses' => 8, 'tries' => 3, 'timeout' => 60, ],
Creating a Task
php artisan ai:make-task SummarizeTask php artisan ai:make-task Orders/AnalyzeTask --queued
<?php declare(strict_types=1); namespace App\Ai\Tasks; use Laravel\Ai\Messages\UserMessage; use Fomvasss\AiTasks\DTO\AiPayload; use Fomvasss\AiTasks\DTO\AiResponse; use Fomvasss\AiTasks\Tasks\AiTask; class SummarizeTask extends AiTask { public function __construct( private readonly string $text, ) {} public function modality(): string { return 'text'; } public function toPayload(): AiPayload { return new AiPayload( modality: $this->modality(), messages: [new UserMessage("Summarize: {$this->text}")], systemPrompt: 'You are a concise summarizer. Reply in 3 sentences max.', options: ['temperature' => 0.3], ); } public function postprocess(AiResponse $response): AiResponse|array { // save to DB, dispatch further jobs, etc. return $response; } }
Running Tasks
use Fomvasss\AiTasks\Facades\AI; // Sync $response = AI::send(new SummarizeTask($text)); echo $response->content; // Async (queue) $runId = AI::queue(new SummarizeTask($text)); // Streaming $response = AI::stream(new SummarizeTask($text), function (string $chunk) { echo $chunk; }); // $response->content — full accumulated text // $response->usage — tokens + cost (same as AI::send) // Override driver at runtime $response = AI::send(new SummarizeTask($text), drivers: 'anthropic');
Streaming
AI::stream() delivers response text chunk by chunk via a callback, useful for real-time UI (SSE, WebSockets).
$response = AI::stream( new SummarizeTask($text), function (string $chunk) { echo $chunk; // or: event('stream', $chunk) }, drivers: ['openai'], // optional driver override ); // After the stream ends: $response->content; // full accumulated text $response->usage; // tokens + cost
Provider support
All providers supported by laravel/ai work with streaming automatically — OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama, and any OpenAI-compatible endpoint.
Long responses
AI::send() has a default 60-second timeout per request. For tasks that generate large outputs (long articles, stories, detailed reports), use AI::stream() — it has no timeout by default:
$response = AI::stream(new WriteStoryTask(), function (string $chunk) { // process chunks, or ignore them }, drivers: ['deepseek']); $response->content; // full accumulated text
Driver Routing
Tasks are routed to drivers via config/ai-tasks.php:
'routing' => [ 'summarize' => ['openai', 'anthropic'], // fallback chain 'orders_analyze' => ['gemini'], ],
Or on the task instance:
AI::send((new SummarizeTask($text))->viaDrivers('gemini'));
Multi-tenant Budget Tracking
// config/ai-tasks.php 'budgets' => [ 'tenant-abc' => ['monthly_usd' => 50.0], 'default' => ['monthly_usd' => 100.0], ],
The TenantResolver picks up tenant ID from X-Tenant-Id header, authenticated user, or config default. Override it by binding your own resolver in a service provider:
$this->app->singleton(\Fomvasss\AiTasks\Support\TenantResolver::class, fn() => new MyTenantResolver());
Cost Tracking
Set pricing per driver in config/ai-tasks.php (per 1M tokens):
'anthropic' => [ 'model' => 'claude-sonnet-4-6', 'price' => [ 'in' => 3.00, 'out' => 15.00, 'cache_write' => 3.75, // prompt caching write 'cache_read' => 0.30, // prompt caching read ], ],
Cost is calculated after each response and stored in ai_runs.cost. If price is not set, cost is null but token counts are always saved.
Query spend per tenant:
AiRun::where('tenant_id', $tenantId) ->where('status', 'ok') ->sum('cost'); // fast SQL, indexed column
Prompt Caching (Anthropic)
return new AiPayload( modality: 'text', messages: [new UserMessage($prompt)], systemPrompt: $longSystemPrompt, options: ['cache' => true], // caches systemPrompt on Anthropic );
Queued Tasks
Implement ShouldQueueAi and define serializeForQueue():
use Fomvasss\AiTasks\Contracts\ShouldQueueAi; class AnalyzeTask extends AiTask implements ShouldQueueAi { public function __construct(private readonly int $productId) {} public function serializeForQueue(): array { return [$this->productId]; } public function viaQueues(): array { return ['request' => 'ai', 'post' => 'ai-post']; } }
Delayed dispatch
Pass a delay to AI::queue() to defer execution:
AI::queue(new SummarizeTask($text), delay: 300); // 5 minutes (seconds) AI::queue(new SummarizeTask($text), delay: now()->addHours(2)); // Carbon AI::queue(new SummarizeTask($text), delay: new \DateInterval('PT10M'));
Pre-execution guard — shouldRun()
Override shouldRun() on any task to perform a last-moment check inside the queue job, before the API call is made. If it returns false, the run is marked skipped and no tokens are consumed:
class AnalyzeProductTask extends AiTask { public function __construct(private readonly int $productId) {} public function shouldRun(): bool { // re-check at job execution time — the model state may have changed return Product::find($this->productId)?->needs_analysis ?? false; } }
Useful when a queued task may become irrelevant by the time a worker picks it up (e.g. record deleted, status changed, result already computed).
Testing
Use AI::fake() in tests to avoid real API calls. The fake records all calls and provides assertion helpers.
use Fomvasss\AiTasks\Facades\AI; // Default: all tasks return "fake ai response" $fake = AI::fake(); // Fixed response for all tasks $fake = AI::fake('Short summary.'); // Per-task responses (matched by task name) $fake = AI::fake([ 'summarize' => 'This is a summary.', 'translate' => 'Це переклад.', '*' => 'Default fallback.', // catch-all ]);
Assertions
$fake->assertSent(SummarizeTask::class); $fake->assertSent(SummarizeTask::class, function (AiTask $task, string $method) { return $task->name() === 'summarize' && $method === 'send'; }); $fake->assertNotSent(TranslateTask::class); $fake->assertQueued(SummarizeTask::class); $fake->assertSentCount(3); // total calls (send + stream + queue) $fake->assertNothingSent();
AI::stream() with fake still calls the $onChunk callback once with the full response, so streaming logic can be tested too.
Events
| Event | When |
|---|---|
AiTaskQueued |
Task dispatched to queue |
AiTaskStarted |
API call begins |
AiTaskCompleted |
Postprocess done, response ready |
AiTaskFailed |
All drivers failed |
AiRunFinished |
Low-level: single driver call succeeded |
AiRunFailed |
Low-level: single driver call failed |
Event::listen(AiTaskCompleted::class, function (AiTaskCompleted $event) { // $event->task, $event->response, $event->run });
Image Generation
Set modality: 'image' in the payload. Supported via OpenAI (gpt-image-1, dall-e-3) and Gemini.
class GenerateImageTask extends AiTask { public function modality(): string { return 'image'; } public function toPayload(): AiPayload { return new AiPayload( modality: 'image', messages: [new UserMessage('A minimalist blue logo for a tech startup')], options: [ 'model' => 'gpt-image-1', 'size' => '1024x1024', // or '3:2' landscape / '2:3' portrait 'quality' => 'standard', 'timeout' => 120, ], ); } public function postprocess(AiResponse $resp): array|AiResponse { // $resp->content — base64 string (image/png) // Save to file: if ($resp->ok) { $path = storage_path('app/images/generated_' . time() . '.png'); file_put_contents($path, base64_decode($resp->content)); } return $resp; } } $r = AI::send(new GenerateImageTask(), drivers: ['openai']); // $r->content — base64 PNG image (can be decoded and saved)
Embeddings
Convert text to vector embeddings for semantic search, clustering, etc.
class EmbedDocumentTask extends AiTask { public function __construct(private readonly string $text) {} public function modality(): string { return 'embed'; } public function toPayload(): AiPayload { return new AiPayload( modality: 'embed', messages: [$this->text], // string or array or UserMessage ); } public function postprocess(AiResponse $resp): array|AiResponse { // $resp->content — JSON array of floats (embedding vector) $vector = json_decode($resp->content, true); return [ 'ok' => $resp->ok, 'dims' => count($vector), 'vector' => $vector, 'tokens' => $resp->usage['tokens_in'] ?? null, ]; } } $r = AI::send(new EmbedDocumentTask('Your text here'), drivers: ['openai']); // Returns: { "ok": true, "dims": 1536, "vector": [0.023, -0.012, ...] }
Supported embedding models:
- OpenAI:
text-embedding-3-small,text-embedding-3-large - Gemini:
gemini-embedding-001
Audio & Text-to-Speech
Generate speech from text via OpenAI or ElevenLabs.
class GenerateSpeechTask extends AiTask { public function __construct(private readonly string $text) {} public function modality(): string { return 'audio'; } public function toPayload(): AiPayload { return new AiPayload( modality: 'audio', messages: [$this->text], options: [ 'model' => 'tts-1', // or 'tts-1-hd' 'voice' => 'alloy', // alloy, echo, fable, onyx, nova, shimmer 'female' => false, // or true for ElevenLabs 'instructions' => 'Speak clearly and slowly', // optional ], ); } public function postprocess(AiResponse $resp): array|AiResponse { // $resp->content — base64 audio (MP3 or WAV) if ($resp->ok) { $path = storage_path('app/audio/speech_' . time() . '.mp3'); file_put_contents($path, base64_decode($resp->content)); } return ['ok' => $resp->ok, 'size' => strlen($resp->content)]; } } AI::send(new GenerateSpeechTask('Hello world'), drivers: ['openai']);
Transcription & Speech-to-Text
Convert audio files to text via OpenAI, ElevenLabs, Mistral, or Gemini.
class TranscribeAudioTask extends AiTask { public function __construct(private readonly string $audioPath) {} public function modality(): string { return 'transcription'; } public function toPayload(): AiPayload { return new AiPayload( modality: 'transcription', options: [ 'path' => $this->audioPath, // full file path // or use: // 'storage' => 'file_path', // from storage disk // 'disk' => 'local', // storage disk name 'diarize' => true, // speaker identification (OpenAI only) ], ); } public function postprocess(AiResponse $resp): array|AiResponse { return [ 'ok' => $resp->ok, 'text' => $resp->content, 'duration_seconds' => round(strlen($resp->content) / 100), // rough estimate ]; } } $r = AI::send(new TranscribeAudioTask('/path/to/audio.mp3'), drivers: ['openai']); // Returns: { "ok": true, "text": "transcribed text...", "duration_seconds": 42 }
Supported formats: MP3, MP4, MPEG, MPGA, M4A, OGG, WAV, WEBM
Artisan Commands
| Command | Description |
|---|---|
ai:make-task Name |
Generate a task class |
ai:models [driver] |
List available models from provider API |
ai:request "prompt" |
Ad-hoc sync or queued request |
ai:runs |
List recent ai_runs |
ai:budget {tenant} |
Show monthly spend vs limit |
ai:retry |
List failed runs for retry |
ai:models
# all configured drivers php artisan ai:models # specific driver php artisan ai:models gemini # filter by substring php artisan ai:models openai --filter=gpt-4 # show token limits, release date, capabilities php artisan ai:models anthropic --detail
Currently configured model is highlighted with ✓. Providers with a URL in ai.providers.*.url (Groq, Mistral, DeepSeek, xAI, Ollama, OpenRouter…) are queried via the OpenAI-compatible /v1/models endpoint automatically.
Supported Providers
Any provider supported by laravel/ai works automatically — just add a section to config/ai.php (credentials) and config/ai-tasks.php (model, price). No code changes needed.
The following providers are pre-configured in config/ai-tasks.php (just add the .env key):
| Provider | Driver key | Pre-configured |
|---|---|---|
| OpenAI | openai |
✅ |
| Anthropic | anthropic |
✅ |
| Google Gemini | gemini |
✅ |
| DeepSeek | deepseek |
✅ |
| Groq | groq |
✅ |
| Mistral | mistral |
✅ |
| xAI (Grok) | xai |
✅ |
| Ollama (local) | ollama |
✅ |
| VoyageAI | voyageai |
add manually |
| AWS Bedrock | bedrock |
add manually |
| OpenRouter | openrouter |
add manually |
| Perplexity | perplexity |
add manually |
| ElevenLabs | eleven |
✅ (audio/tts) |
| any laravel/ai provider | — | add manually |
How credentials work
laravel/ai reads API keys from config/ai.php (published via vendor:publish --provider="Laravel\Ai\AiServiceProvider"). The api_key is not stored in config/ai-tasks.php — that file only contains model names, pricing, and routing config.
To check what .env variables each provider needs, see:
vendor/laravel/ai/config/ai.php
Adding a new provider (e.g. Mistral):
# 1. Add to config/ai.php (laravel/ai config) 'mistral' => [ 'key' => env('MISTRAL_API_KEY'), 'url' => 'https://api.mistral.ai/v1', ], # 2. Add to config/ai-tasks.php (this package) 'mistral' => [ 'model' => 'mistral-large-latest', 'price' => ['in' => 2.00, 'out' => 6.00], ],
Changelog
See CHANGELOG.
License
MIT — see LICENSE.
