Turn Laravel Events into AI Actions Without Making Controllers Depend on an LLM

You're refactoring a standard Laravel controller. The feature request seems innocent enough: “When a user updates their billing tier, have an LLM analyze their usage history, generate a personalized onboarding email, and update their customer profile tags.”

Five minutes later, your controller looks like this:

public function update(UpdateBillingRequest $request, User $user, AnthropicClient $ai)
{
    $user->update($request->validated());

    $usageData = $user->metrics()->monthlySummary()->get();

    $response = $ai->messages()->create([
        'model' => 'claude-3-5-sonnet',
        'max_tokens' => 1024,
        'messages' => [
            [
                'role' => 'user',
                'content' => "Analyze this usage data: {$usageData} and write an email."
            ]
        ]
    ]);

    $emailContent = $response->content[0]->text;
    
    Mail::to($user)->send(new PersonalizedWelcomeMail($emailContent));
    
    $user->update(['ai_tier_tag' => 'analyzed']);

    return redirect()->back();
}

Congratulations. Your HTTP controller—whose sole responsibility should be handling incoming HTTP requests, validating payload data, and returning a response—is now tightly coupled to a third-party Large Language Model API, synchronous HTTP network calls, external token pricing structures, and unstructured string parsing.

When Anthropic or OpenAI experiences a minor latency spike, your HTTP worker thread hangs. When the LLM returns slightly malformed JSON, your controller throws a fatal error, and the user's billing update fails entirely—even though their credit card was successfully charged via Stripe.

In this deep-dive guide, we are going to explore how to turn Laravel events into robust AI actions. By shifting LLM interactions out of HTTP controllers and into asynchronous event listeners, you decouple your business domain from volatile AI services, protect your application against timeouts, and establish a maintainable architectural pattern that scales gracefully.


📋 Table of Contents


The Architectural Antipattern: Why Controllers and LLMs Don't Mix

Before writing a single line of clean code, let's examine why embedding AI logic directly inside a Laravel controller destroys application maintainability.

HTTP controllers are the boundary layer of your web application. They translate HTTP requests into domain actions and domain states back into HTTP responses. LLMs, on the other hand, are inherently non-deterministic, network-bound, slow, and expensive utilities.

When you inject an AI client into a controller method, you invite several systemic risks into your codebase:

  1. Blocking HTTP Threads: Standard web requests have strict timeouts (often 30 to 60 seconds). LLM generation calls routinely take 3 to 10 seconds. If your upstream provider slows down, your web server's worker pool quickly fills up, resulting in a cascading outage for all users.
  2. Untestable Controllers: Testing a controller that makes live HTTP calls to an AI provider requires mocking complex API responses or burning real API credits during test suites.
  3. Volatile Failure Modes: If a customer changes their password or updates a billing address, a failure in the auxiliary AI summarization step should never prevent the core database transaction from completing.

By utilizing Laravel's native event and listener architecture, we can push AI processing out of the request-response lifecycle entirely. The controller fires a clean, domain-specific event and immediately returns a snappy HTTP response to the user. What happens next is handled asynchronously in the background.


Pattern 1: Decoupling Domain Events from AI Infrastructure

Scenario: Your e-commerce application allows vendors to upload raw product descriptions. You want an AI model to clean up the copy, extract SEO keywords, and categorize the product into an internal taxonomy whenever a product is created or updated.

Why it matters: If you put this logic in a ProductController, your store managers will experience frustrating 5-second page loads every time they save a draft. More importantly, your controller code becomes cluttered with prompt engineering, response parsing, and error-handling boilerplate.

Solution: Define a dedicated domain event (ProductWasSaved) that carries only the minimum necessary identifier or state. Let the event dispatcher manage who listens to it, keeping your controllers completely agnostic of artificial intelligence.

Code:

// app/Events/ProductWasSaved.php
namespace App\Events;

use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductWasSaved
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public readonly Product $product,
        public readonly bool $isNew
    ) {}
}

Now, fire this event cleanly from inside your controller:

// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;

use App\Events\ProductWasSaved;
use App\Http\Requests\SaveProductRequest;
use App\Models\Product;

class ProductController extends Controller
{
    public function store(SaveProductRequest $request)
    {
        $product = Product::create($request->validated());

        // Fire the event. The controller doesn't know or care about AI.
        ProductWasSaved::dispatch($product, isNew: true);

        return redirect()->route('products.edit', $product)
            ->with('success', 'Product saved successfully.');
    }
}

Why this works: Your controller is reduced to three lines of clean, readable database operations. It does not import an AI client, construct system prompts, or handle parsing exceptions. If you later decide to swap your AI provider from Anthropic to OpenAI, or drop AI enrichment entirely, your controller remains completely untouched.

💡 Practical note: Always pass models or IDs through events using the SerializesModels trait. This ensures that when the event is pushed to a queue worker, Laravel re-hydrates a fresh instance of the model from the database rather than serializing a stale state into the job payload.


Pattern 2: The Queue-Driven AI Listener Pipeline

Scenario: Your ProductWasSaved event is firing successfully, but when you attach a synchronous event listener that calls an LLM, your queue worker or HTTP request starts timing out because processing takes 6 seconds per item.

Why it matters: LLM APIs are notoriously variable in latency. Network partitions, rate limiting (429 Too Many Requests), and high server loads mean your code must expect delays and handle them gracefully in the background.

Solution: Implement a queued event listener by letting your listener class implement Laravel's ShouldQueue interface. Configure dedicated queue connections, timeout thresholds, and backoff strategies specifically for your AI workloads.

Code:

// app/Listeners/EnrichProductWithAi.php
namespace App\Listeners;

use App\Events\ProductWasSaved;
use App\Services\Ai\ProductContentAnalyzer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;

class EnrichProductWithAi implements ShouldQueue
{
    use InteractsWithQueue;

    // Configure queue behavior for slow, rate-limited AI tasks
    public int $tries = 3;
    public int $timeout = 60;
    public string $queue = 'ai-processing';

    public function __construct(
        protected ProductContentAnalyzer $analyzer
    ) {}

    public function handle(ProductWasSaved $event): void
    {
        $product = $event->product;

        // Skip enrichment if the content hasn't changed meaningfully
        if (!$event->isNew && !$product->wasChanged('raw_description')) {
            return;
        }

        try {
            $enrichmentData = $analyzer->analyze($product->raw_description);

            $product->update([
                'seo_title' => $enrichmentData->seoTitle,
                'seo_description' => $enrichmentData->seoDescription,
                'ai_tags' => $enrichmentData->tags,
                'ai_enriched_at' => now(),
            ]);
        } catch (\Exception $e) {
            Log::error("Failed to enrich product #{$product->id} via AI: {$e->getMessage()}");
            
            // Re-throw so Laravel's queue worker handles retries/failures
            throw $e;
        }
    }

    public function backoff(): array
    {
        // Exponential backoff: wait 10s, then 30s, then 60s
        return [10, 30, 60];
    }
}

Why this works: By implementing ShouldQueue, Laravel automatically serializes your listener and pushes it to the ai-processing queue worker. Your web server responds to the user instantly. If Anthropic's API returns a temporary 503 Service Unavailable error, Laravel catches the exception, pauses, and retries the job up to three times using exponential backoff.


Pattern 3: Structured Output Mapping via Laravel Data Transfer Objects

Scenario: You've successfully moved your LLM calls to a background queue listener. However, your code is currently parsing raw text strings returned by the model using json_decode() and praying that the AI didn't include conversational filler like “Here is the JSON you requested:” before the payload.

Why it matters: LLMs are text generators, not rigid database drivers. Without strict structural enforcement, a minor prompt tweak by another developer can break your JSON parsing, throwing TypeError exceptions across your application.

Solution: Leverage modern provider features for Structured Outputs (such as JSON mode or native schema enforcement) combined with strict Data Transfer Objects (DTOs) in PHP to guarantee type safety between the LLM response and your database layer.

Code:

// app/Services/Ai/Data/ProductEnrichmentDto.php
namespace App\Services\Ai\Data;

class ProductEnrichmentDto
{
    public function __construct(
        public readonly string $seoTitle,
        public readonly string $seoDescription,
        /** @var array<string> */
        public readonly array $tags
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            seoTitle: $data['seo_title'] ?? '',
            seoDescription: $data['seo_description'] ?? '',
            tags: $data['tags'] ?? []
        );
    }
}

Now, encapsulate your AI provider integration inside a dedicated service class that enforces strict schemas:

// app/Services/Ai/ProductContentAnalyzer.php
namespace App\Services\Ai;

use App\Services\Ai\Data\ProductEnrichmentDto;
use OpenAI\Client as OpenAiClient;

class ProductContentAnalyzer
{
    public function __construct(
        protected OpenAiClient $client
    ) {}

    public function analyze(string $rawDescription): ProductEnrichmentDto
    {
        $response = $client->chat()->create([
            'model' => 'gpt-4o-mini',
            'response_format' => [
                'type' => 'json_schema',
                'json_schema' => [
                    'name' => 'product_enrichment',
                    'strict' => true,
                    'schema' => [
                        'type' => 'object',
                        'properties' => [
                            'seo_title' => ['type' => 'string', 'maxLength' => 60],
                            'seo_description' => ['type' => 'string', 'maxLength' => 160],
                            'tags' => [
                                'type' => 'array',
                                'items' => ['type' => 'string']
                            ]
                        ],
                        'required' => ['seo_title', 'seo_description', 'tags'],
                        'additionalProperties' => false
                    ]
                ]
            ],
            'messages' => [
                ['role' => 'system', 'content' => 'Extract SEO metadata and tags from the provided product description.'],
                ['role' => 'user', 'content' => $rawDescription],
            ],
        ]);

        $rawJson = $response->choices[0]->message->content;
        $decoded = json_decode($rawJson, true);

        return ProductEnrichmentDto::fromArray($decoded);
    }
}

Why this works: By enforcing a strict JSON schema at the API provider level and passing the resulting array through a typed DTO constructor, you eliminate entire classes of runtime bugs. If the model fails to return the required keys, the API provider rejects the response or throws an explicit validation error, preventing corrupt data from ever reaching your database.


Pattern 4: Graceful Fallbacks and Circuit Breakers for Unstable AI APIs

Scenario: Your primary LLM provider suffers a major outage. Because your queued event listeners keep failing and retrying, your Redis queue fills up with thousands of stuck jobs, blocking secondary background tasks like transactional email delivery.

Why it matters: Third-party AI providers have uptime guarantees, but outages happen. A single flaky provider should not bring your entire queue infrastructure to a grinding halt.

Solution: Implement a fallback strategy and lightweight circuit breaker in your AI service layer. If your primary model or provider fails repeatedly, gracefully degrade functionality or failover to a cheaper, faster fallback model (or skip the AI enhancement entirely while logging a warning).

Code:

// app/Services/Ai/ResilientProductAnalyzer.php
namespace App\Services\Ai;

use App\Services\Ai\Data\ProductEnrichmentDto;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;

class ResilientProductAnalyzer
{
    protected const CIRCUIT_BREAKER_KEY = 'ai_circuit_breaker_open';
    protected const FAILURE_THRESHOLD = 5;

    public function __construct(
        protected ProductContentAnalyzer $primaryAnalyzer,
        protected FallbackProductAnalyzer $fallbackAnalyzer
    ) {}

    public function analyze(string $rawDescription): ProductEnrichmentDto
    {
        // Check if the circuit breaker is active
        if (Cache::has(self::CIRCUIT_BREAKER_KEY)) {
            Log::warning('AI Circuit breaker is open. Bypassing primary LLM.');
            return $this->fallbackAnalyzer->analyze($rawDescription);
        }

        try {
            return $this->primaryAnalyzer->analyze($rawDescription);
        } catch (Throwable $e) {
            $this->recordFailure();
            
            Log::error("Primary AI provider failed: {$e->getMessage()}. Attempting fallback.");

            try {
                return $this->fallbackAnalyzer->analyze($rawDescription);
            } catch (Throwable $fallbackException) {
                Log::critical("Both primary and fallback AI analyzers failed!");
                
                // Return a safe, hardcoded default DTO so the app doesn't crash
                return new ProductEnrichmentDto(
                    seoTitle: 'Pending Review',
                    seoDescription: 'Product description pending AI optimization.',
                    tags: ['uncategorized']
                );
            }
        }
    }

    protected function recordFailure(): void
    {
        $key = 'ai_failure_count';
        $failures = Cache::increment($key);

        if ($failures === 1) {
            Cache::expire($key, 300); // Reset count after 5 minutes
        }

        if ($failures >= self::FAILURE_THRESHOLD) {
            // Trip the circuit breaker for 15 minutes
            Cache::put(self::CIRCUIT_BREAKER_KEY, true, 900);
            Log::emergency('AI circuit breaker tripped due to consecutive API failures.');
        }
    }
}

Why this works: Production resilience requires assuming that external APIs will fail. By wrapping your AI service calls in a circuit breaker pattern backed by Laravel's cache driver, you prevent your queue workers from wasting compute cycles hammering a dead endpoint.


Pattern 5: Idempotency and Deduplication in LLM Event Listeners

Scenario: A network glitch causes a message broker timeout, or an administrator manually dispatches the ProductWasSaved event twice in rapid succession. Your queue worker processes both events concurrently, resulting in duplicate expensive LLM API calls and race conditions on your database model.

Why it matters: LLM API calls cost real money and take precious seconds to execute. Triggering duplicate AI tasks wastes your API budget and can overwrite newer database changes with stale LLM generations.

Solution: Make your event listeners strictly idempotent by implementing database-backed job locking or cache locks using Laravel's Cache::lock() mechanism before executing the AI payload.

Code:

// app/Listeners/EnrichProductWithAi.php (Enhanced with Idempotency)
namespace App\Listeners;

use App\Events\ProductWasSaved;
use App\Services\Ai\ResilientProductAnalyzer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;

class EnrichProductWithAi implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 60;
    public string $queue = 'ai-processing';

    public function __construct(
        protected ResilientProductAnalyzer $analyzer
    ) {}

    public function handle(ProductWasSaved $event): void
    {
        $product = $event->product;
        $lockKey = "ai_enrichment_lock_product_{$product->id}";

        // Acquire an atomic lock for 60 seconds
        $lock = Cache::lock($lockKey, 60);

        if (!$lock->get()) {
            // Another worker is already processing this product; exit gracefully
            return;
        }

        try {
            $enrichmentData = $analyzer->analyze($product->raw_description);

            // Double check that the product hasn't been updated since the event fired
            $product->refresh();
            
            $product->update([
                'seo_title' => $enrichmentData->seoTitle,
                'seo_description' => $enrichmentData->seoDescription,
                'ai_tags' => $enrichmentData->tags,
                'ai_enriched_at' => now(),
            ]);
        } finally {
            // Always release the lock
            $lock->release();
        }
    }
}

Why this works: Atomic cache locks ensure that even if a dozen identical events flood your queue simultaneously, only a single worker executes the LLM pipeline for that specific model instance. All other workers encounter a locked state and exit cleanly without burning API tokens.

🚨 Production warning: Always ensure your cache lock duration (60 seconds in the example above) exceeds your maximum expected LLM execution timeout, but remains short enough to release if a worker unexpectedly crashes mid-execution.


Pattern 6: Event-Driven Tool Use and Autonomous Function Calling

Scenario: You want your AI action to not just analyze text, but actively interact with your application's domain services—such as checking inventory levels across third-party warehouses or querying customer support ticket histories before generating a response.

Why it matters: Hardcoding static prompts limits your AI actions to isolated text transformations. Real enterprise workflows require LLMs to inspect live system states and execute controlled, domain-specific tools safely.

Solution: Expose safe, read-only domain actions as "Tools" or "Functions" that the LLM can invoke during an event-driven session, mapped securely through your event listener pipeline.

Code:

// app/Services/Ai/Tools/CheckInventoryTool.php
namespace App\Services\Ai\Tools;

use App\Services\WarehouseApiClient;

class CheckInventoryTool
{
    public static function definition(): array
    {
        return [
            'name' => 'check_inventory',
            'description' => 'Check real-time stock levels for a given SKU across warehouses.',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'sku' => [
                        'type' => 'string',
                        'description' => 'The product stock keeping unit.'
                    ]
                ],
                'required' => ['sku'],
            ],
        ];
    }

    public static function execute(array $arguments): string
    {
        $warehouse = new WarehouseApiClient();
        $stockLevel = $warehouse->getStockBySku($arguments['sku']);

        return json_encode(['available_stock' => $stockLevel]);
    }
}

Now, hook this tool execution loop inside your event listener service:

// app/Services/Ai/AutonomousSupportResolver.php
namespace App\Services\Ai;

use App\Services\Ai\Tools\CheckInventoryTool;
use OpenAI\Client as OpenAiClient;

class AutonomousSupportResolver
{
    public function __construct(protected OpenAiClient $client) {}

    public function resolveInquiry(string $customerMessage, string $sku): string
    {
        $response = $client->chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                ['role' => 'user', 'content' => $customerMessage]
            ],
            'tools' => [
                ['type' => 'function', 'function' => CheckInventoryTool::definition()]
            ],
            'tool_choice' => 'auto',
        ]);

        $responseMessage = $response->choices[0]->message;

        // Check if the LLM decided to call our tool
        if (!empty($responseMessage->toolCalls)) {
            $toolCall = $responseMessage->toolCalls[0];
            $functionName = $toolCall->function->name;
            $arguments = json_decode($toolCall->function->arguments, true);

            if ($functionName === 'check_inventory') {
                $toolResult = CheckInventoryTool::execute($arguments);

                // Send the tool output back to the LLM for final response synthesis
                // ... (continuation of chat loop)
            }
        }

        return $responseMessage->content ?? '';
    }
}

Why this works: By decoupling tools into discrete static classes (CheckInventoryTool), your AI service remains cleanly structured. The LLM acts as an orchestrator that decides when to inspect database or API states, while your event listener handles the secure execution boundary.


Decision Guide: When to Trigger AI Synchronously vs. Asynchronously

Choosing the right execution path for your AI integration prevents architectural headaches down the road. Use the following decision matrix to evaluate where your AI actions belong.

Dimension Synchronous (In-Controller) Asynchronous (Event-Driven Queue)
Max Acceptable Latency < 800ms (Real-time chat interfaces) > 1 second to minutes (Background processing)
Failure Impact Fails the entire HTTP request Retried automatically via queue worker
API Cost & Rate Limits High risk of hitting provider rate limits Throttled smoothly via queue batching & backoff
Database Transactions Tied to active DB transaction lifecycle Executed post-transaction or independently
Best Use Cases Interactive autocomplete, real-time UI validation Document analysis, email generation, embeddings, batch tagging
Recommended Verdict ❌ Avoid for general business logic ✅ The gold standard for robust Laravel applications

Final Architectural Observation

When you stop treating Large Language Models as specialized database drivers or extensions of your HTTP controllers, your entire application architecture changes for the better.

By wrapping your domain events around clean, queued listeners, strict DTO mappings, and robust circuit breakers, you achieve true architectural inversion. Your Laravel application remains pristine, performant, and fully testable—completely insulated from the volatility, network latency, and shifting APIs of the artificial intelligence ecosystem.

Post a Comment

Previous Post Next Post