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
Suggests
- laravel/mcp: Native MCP client support (recommended over HttpMcpClient for production use)
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).
Configurable via config/ai-tasks.php:
'dashboard' => [ 'enabled' => env('AI_DASHBOARD_ENABLED', true), 'path' => env('AI_DASHBOARD_PATH', 'ai-tasks'), 'middleware' => ['web'], 'poll_interval' => env('AI_DASHBOARD_POLL', 3), // seconds; 0 = off 'theme' => env('AI_DASHBOARD_THEME', 'system'), // light|dark|system 'per_page' => env('AI_DASHBOARD_PER_PAGE', 50), ],
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' => 300, ], '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
Tools & MCP
Override tools() on any task to pass tools to the underlying AnonymousAgent. Tools are forwarded automatically on send(), stream(), and queue().
Three approaches are supported:
- Local tools — PHP classes implementing
Laravel\Ai\Contracts\Tool - Native MCP (recommended) — install
laravel/mcpand useClient::web()orClient::local()for HTTP and stdio servers;laravel/aiwraps the tool primitives automatically, no supergateway proxy needed - HttpMcpClient — zero-dependency fallback for Streamable HTTP servers when
laravel/mcpis not installed
→ Full documentation: docs/mcp.md
Job Timeout
Override jobTimeout() on any task to control how long the queue job is allowed to run before Horizon kills it:
class HeavyAnalysisTask extends AiTask { // default is 300 seconds; raise for long multi-step tool chains public function jobTimeout(): int { return 600; } }
The value is passed to ProcessAiPayload at dispatch time. Make sure the Horizon supervisor timeout is at least as large as your highest jobTimeout().
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->scoped(\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']; } }
Note:
serializeForQueue()must return only scalar values (strings, ints, arrays of scalars). The array is JSON-encoded and stored in Redis; on the worker side it is passed back into the constructor vianew static(...$args). Do not pass Eloquent models — they will be JSON-serialized into a plain array and the constructor will receive anarrayinstead of a model instance. Pass IDs instead and load the model insidetoPayload().
serializeForQueue()also drives idempotency: if it returns[](the default), no deduplication is applied forAI::queue(). Any task that accepts constructor parameters influencing the prompt must implementserializeForQueue()—AI::queue()will throw aLogicExceptionat dispatch time if it detects constructor parameters butserializeForQueue()returns[].
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).
Idempotency
Every run is protected against duplicates via a unique idempotency_key stored in ai_runs. The key is a hash of [tenantId, taskName, modality, serializeForQueue()].
Deduplication is active only when serializeForQueue() returns a non-empty array. If it returns [] (the default), idempotencyKey() returns null and no deduplication is applied — multiple runs with the same task can coexist. This means: for any task with variable inputs, implementing serializeForQueue() is required both for queue reconstruction and for correct idempotency behavior.
Collision behavior (when a non-null key already exists in ai_runs):
AI::queue()— returns the existingrun_id; no duplicate job is dispatched.AI::send()— always makes a fresh API call;idempotency_keyis not stored for sync runs.
Override idempotencyKey() when you need custom deduplication logic:
class ChatTask extends AiTask { public function __construct( private readonly string $question, private readonly string $messageId, // unique per message from the chat system private readonly array $history = [], ) {} public function serializeForQueue(): array { return [$this->question, $this->messageId, $this->history]; } // idempotencyKey() default is sufficient — messageId makes each turn unique }
For chat/assistant integrations where the same question can be asked multiple times: as long as the conversation history (or a messageId) is part of serializeForQueue(), each turn produces a different key and idempotency works correctly — it only blocks genuine technical duplicates (double-send, queue retry).
Laravel Octane
No configuration needed. The package handles Octane automatically:
TenantResolveris bound asscoped— new instance per request/jobAiManagerdriver cache is flushed on everyRequestReceivedandTaskReceivedOctane event
If you provide a custom TenantResolver that holds per-request state, the scoped binding ensures it is reset correctly between requests.
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 });
Modalities
Supports five modalities: text · image · embed · audio · transcription.
Set modality() and toPayload() accordingly. For image generation, embeddings, TTS, and transcription see:
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 key each provider expects, 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.
