Laravel · PHP · AI agents · API key management

Laravel AI agent API key management: vault keys via middleware and service container

Laravel applications bind vendor API clients in service providers using the IoC container — app()->singleton(StripeClient::class, ...). In PHP-FPM or Laravel Octane, that singleton is shared across all incoming requests from the same worker. For AI agent tool controllers, this means every concurrent agent run hits Stripe with the same key: no per-agent daily spend cap, no independent revocation, and no audit attribution linking a charge to a specific run. Vault keys issued via Laravel middleware give each request its own scoped credential — without changing how the service container is structured.

TL;DR

Register a VaultKeyService as a scoped binding in a service provider. Create a VaultKeyMiddleware that issues a vault key, stores it in the request object via $request->attributes->set('vault_key', $vk), and revokes it in a deferred callback. Inject VaultKeyService into agent tool controllers and use $request->attributes->get('vault_key') to retrieve the token for Keybrake proxy calls. For Horizon queue jobs, issue the vault key at the top of handle() and revoke in a finally block.

The Laravel AI agent tool backend pattern

The standard Laravel approach binds Stripe once at application boot and shares it across all requests:

// app/Providers/StripeServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;

class StripeServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Singleton — one instance shared across ALL requests in this worker
        $this->app->singleton(StripeClient::class, function () {
            return new StripeClient(config('services.stripe.secret'));
        });
    }
}

In PHP-FPM, each request spawns in a fresh PHP process so the singleton is effectively per-request. But in Laravel Octane (Swoole or RoadRunner), workers are long-lived — the singleton persists across thousands of requests. Even in PHP-FPM, the shared Stripe secret is used for attribution: if two concurrent agent runs both fail, Stripe's logs show the same key for both, making per-run investigation impossible.

Vault keys via Laravel middleware

Laravel middleware is the natural place to issue and revoke vault keys — it runs before and after the controller, exactly like a database transaction wrapper:

// app/Http/Middleware/VaultKeyMiddleware.php
namespace App\Http\Middleware;

use App\Services\VaultKeyService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VaultKeyMiddleware
{
    public function __construct(private VaultKeyService $vault) {}

    public function handle(Request $request, Closure $next): Response
    {
        $userId  = $request->header('X-User-ID', 'unknown');
        $runId   = $request->header('X-Agent-Run-ID', uniqid('run_'));
        $label   = "laravel-{$userId}-{$runId}";

        $vaultKey = $this->vault->issue(
            label:            $label,
            vendor:           'stripe',
            dailyCapUSD:      500,
            allowedEndpoints: ['/v1/payment_intents', '/v1/payment_intents/*'],
            expiresIn:        '5m'
        );

        if ($vaultKey !== null) {
            $request->attributes->set('vault_key', $vaultKey);
            $request->attributes->set('vault_key_id', $vaultKey['id']);
        }

        $response = $next($request);

        // Revoke after the response is built — cleanup regardless of success or error
        if ($keyId = $request->attributes->get('vault_key_id')) {
            $this->vault->revoke($keyId);
        }

        return $response;
    }
}

Register the middleware on your agent tool routes only — not globally:

// routes/api.php
use App\Http\Middleware\VaultKeyMiddleware;

Route::middleware(['auth:sanctum', VaultKeyMiddleware::class])
    ->prefix('agent/tools')
    ->group(function () {
        Route::post('/charge',  [AgentToolsController::class, 'charge']);
        Route::post('/refund',  [AgentToolsController::class, 'refund']);
        Route::post('/invoice', [AgentToolsController::class, 'invoice']);
    });

VaultKeyService implementation

// app/Services/VaultKeyService.php
namespace App\Services;

use Illuminate\Http\Client\Factory as Http;

class VaultKeyService
{
    private string $token;
    private string $baseUrl = 'https://api.keybrake.com/v1';

    public function __construct(Http $http)
    {
        $this->token = config('services.keybrake.token');
        $this->http  = $http;
    }

    public function issue(
        string $label,
        string $vendor,
        int $dailyCapUSD,
        array $allowedEndpoints,
        string $expiresIn
    ): ?array {
        $response = $this->http
            ->withToken($this->token)
            ->timeout(2)
            ->post("{$this->baseUrl}/keys", [
                'label'             => $label,
                'vendor'            => $vendor,
                'daily_usd_cap'     => $dailyCapUSD,
                'allowed_endpoints' => $allowedEndpoints,
                'expires_in'        => $expiresIn,
            ]);

        if ($response->failed()) {
            logger()->warning('VaultKeyService: issuance failed', [
                'status' => $response->status(),
                'label'  => $label,
            ]);
            return null; // Fail open: agent proceeds without vault key
        }

        return $response->json();
    }

    public function revoke(string $keyId): void
    {
        $this->http
            ->withToken($this->token)
            ->timeout(2)
            ->delete("{$this->baseUrl}/keys/{$keyId}");
        // Non-critical — TTL is the safety net; ignore errors
    }
}

Controller reading the vault key

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

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class AgentToolsController extends Controller
{
    public function charge(Request $request): \Illuminate\Http\JsonResponse
    {
        $vaultKey = $request->attributes->get('vault_key');

        if (!$vaultKey) {
            return response()->json(['error' => 'vault_key_unavailable'], 503);
        }

        $response = Http::withToken($vaultKey['token'])
            ->post('https://proxy.keybrake.com/stripe/v1/payment_intents', [
                'amount'   => $request->integer('amount'),
                'currency' => 'usd',
            ]);

        if ($response->status() === 429 && $response->json('code') === 'cap_exhausted') {
            return response()->json(['error' => 'spend_cap_exceeded'], 402);
        }

        return response()->json($response->json(), $response->status());
    }
}

Laravel Horizon queue job pattern

For agents that run as Horizon jobs rather than HTTP requests, issue the vault key at the start of handle() and revoke in a finally block — there's no middleware lifecycle in queue workers:

// app/Jobs/RunAgentJob.php
namespace App\Jobs;

use App\Services\VaultKeyService;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;

class RunAgentJob implements ShouldQueue
{
    use Queueable, InteractsWithQueue;

    public function __construct(
        private string $userId,
        private string $runId,
        private int $maxSpendUSD
    ) {}

    public function handle(VaultKeyService $vault): void
    {
        $label    = "horizon-{$this->userId}-{$this->runId}";
        $vaultKey = $vault->issue($label, 'stripe', $this->maxSpendUSD,
                        ['/v1/payment_intents'], '30m');

        try {
            // Agent work here — uses $vaultKey['token'] for proxy calls
            $this->executeAgentWorkflow($vaultKey);
        } finally {
            if ($vaultKey) {
                $vault->revoke($vaultKey['id']);
            }
        }
    }
}
Laravel contextVault key issuance pointRevocation point
HTTP request (PHP-FPM) Middleware handle() before $next($request) Middleware after $next($request) returns
HTTP request (Octane) Middleware — same pattern, but Octane resets singleton bindings between requests if configured Middleware — same; verify flush config for your singletons
Horizon queue job Top of handle() method finally block in handle()
Artisan command Top of handle() method finally block or end of command

Get early access

Related questions

Does this work with Laravel Cashier for Stripe billing?

Laravel Cashier uses its own Stripe key configuration (typically config('cashier.key')) and calls Stripe directly — it doesn't go through an HTTP middleware pipeline that vault keys would intercept. Vault keys are designed for agent tool calls, not for billing operations that humans initiate. The pattern is: use Cashier for subscription management and customer billing flows (initiated by your app, not an autonomous agent), and use vault keys for the Stripe calls an AI agent makes autonomously during a run. There's no conflict — Cashier's key and the agent vault token are used for entirely different Stripe operations.

Does this work with Laravel Octane (Swoole/RoadRunner)?

Yes, but you need to verify your singleton lifecycle. Octane reuses PHP workers across requests, so singletons bound without a reset mechanism persist between requests. For VaultKeyService, this is fine — it's stateless (it makes HTTP calls but holds no per-request state). The vault key itself is stored in $request->attributes, which is per-request by Octane's design and gets garbage collected at request end. The main Octane concern is making sure your agent tool controllers don't accidentally cache a vault key in a singleton — store it only in the request object, never in a class property on a singleton service.

What happens if vault key issuance fails — does the agent request fail too?

The recommended default is fail-open: if VaultKeyService::issue() returns null (due to a timeout or Keybrake API error), the middleware passes the request through without a vault key, and the controller falls back to the shared Stripe key. This prioritizes availability over isolation. Once you've validated that vault key issuance latency is acceptable in production (typically 30–80ms on the same region), switch to fail-closed: return a 503 if $vaultKey === null. Use a feature flag (e.g. config('services.keybrake.fail_closed', false)) to control the transition without a code deploy.

Further reading