Blog / Tooling
Tooling · 7 min read · June 28, 2026

Stop Your Laravel App Leaking PII Into AI Prompts

TL;DR. Your prompts are assembled at runtime from real data: a support ticket, a user row, a document. That data can carry an email address, a payment card, or even one of your own API keys straight to a third party you do not control, with their retention and their subprocessors. The leak is in the data, not the code, so static review and your own eyes cannot see it. This post shows how to scan every outbound AI request in a Laravel app, redact what should not leave, and block the unsafe ones, all at the HTTP layer, regardless of which AI SDK you use. The package is free and open source.

The leak is in the data, not the code

Here is a controller that passes every review:

$summary = $prism->withPrompt(
    "Summarise this support ticket for the team:\n\n" . $ticket->body
)->asText();

Clean code. The problem is $ticket->body. A customer pasted a card number to "verify their identity," or quoted an email thread, or attached a stack trace with a bearer token in a header. None of that is visible in the source. It only exists at the moment the request goes out, and at that moment it is on its way to OpenAI or Anthropic.

This matters for two separate reasons that often get conflated:

  • Security. A secret in a prompt is a secret disclosed to a third party. If it is one of your own keys, you have just leaked a live credential into someone else's logs. This is OWASP LLM02, sensitive information disclosure.
  • Compliance. Personal data in a prompt is a transfer to a processor. When you send data to OpenAI you also send it to their subprocessors, Microsoft and others on their published list, often outside your region and retained for a window unless you have a zero-retention agreement (OpenAI Data Processing Addendum). Under GDPR that is a processing activity you are accountable for.

You cannot fix at review time a leak that only exists at runtime. You need something on the wire.

Why the usual answers do not fit a Laravel app

The redaction conversation today is dominated by three approaches, and each has a cost for a Laravel team:

  • Microsoft Presidio is the reference open-source detector, but it is Python. Standing up a Python service next to your PHP app, just to inspect prompts, is real operational weight.
  • Redaction proxies work by making you change your provider base URL so traffic flows through them. That is a new hop to operate, trust, and pay for, and it only covers calls you remember to point at it.
  • Manual redaction in each call site is the approach that rots first. The one place someone forgets is the one that leaks.

What a Laravel app wants is a guard that lives inside the app, sees every outbound call by default, and does not care which SDK made it.

Scanning and redacting at the wire

laravel-ai-egress-guard registers a global HTTP-client middleware. Every outbound request to a known AI provider host is inspected before it leaves: it builds a scan context from the request body, runs the rule set, stores the request and its findings with high-confidence secrets redacted, and, when you turn guard mode on, can stop the request.

composer require laravelsecurityaudit/laravel-ai-egress-guard
php artisan migrate

Because the hook is at Laravel's HTTP-client layer, coverage is broad without per-call wiring. Prism is built on the Laravel HTTP client, so Prism calls are covered automatically, as are the laravel/ai SDK and anything using the Http facade. A client that ships its own Guzzle stack, like openai-php, is the one case you attach by hand:

use LaravelSecurityAudit\EgressGuard\Support\EgressGuard;

$client = new \GuzzleHttp\Client(['handler' => EgressGuard::stack()]);

Requests to any non-provider host take a fast path and are ignored, so this is not a tax on the rest of your traffic.

It reads prompts, not just blobs

For OpenAI, Anthropic, and Gemini request shapes, the guard extracts the model and a readable prompt (messages[].content, Anthropic system, Gemini contents[].parts[].text) and stores that alongside the raw body. By default it scans the full raw body, which is the most thorough option. If the raw JSON is too noisy for your rules, set scan.target to prompt and it scans only the extracted message text.

What it detects

The rules combine the shared detection engine with egress-specific patterns:

Rule idSeverityConfidence
secrets.private_keycriticalhigh
secrets.stripe_keycriticalhigh
secrets.api_keycriticalhigh
secrets.aws_access_keycriticalhigh
secrets.bearer_tokenwarningmedium
pii.credit_cardcriticalhigh
pii.emailwarningmedium
pii.phonewarninglow

You toggle rules, override severities, and add your own by implementing the engine's Rule contract.

Three places to use it, from soft to hard

The guard is designed to be adopted gradually, so it never breaks a real AI call on its first day.

1. Observe and review

Out of the box it captures and redacts but does not block. Open /egress-guard in a non-production environment and you see every captured request with its provider, model, a risk badge, and the findings, with the body redacted. The inbox can hold sensitive prompt data, so it is closed by default unless you set a gate or are in a listed local environment. This is the "show me what we are actually sending" view, and it is usually a surprising first run.

2. Fail the build

Run your suite so calls are captured, then gate CI:

php artisan test
php artisan egress-guard:scan --min-severity=critical --format=sarif --output=egress-guard.sarif

The command exits non-zero when a finding meets the threshold, and SARIF points at the calling route action when known. You can also assert directly in a test:

use LaravelSecurityAudit\EgressGuard\Support\EgressGuard;

public function test_the_summary_prompt_leaks_nothing(): void
{
    $this->generateSummaryFor($user); // makes the AI call

    EgressGuard::assertNoCriticalFindings();
}

3. Block in production

When you trust the thresholds, turn on guard mode:

EGRESS_GUARD_BLOCK=true

Defaults are conservative: it blocks only critical findings at high confidence, and fail_open is true, so a scanner error never silently breaks a real call. A blocked request throws EgressGuardBlocked, fires an EgressBlocked event, and logs the rule ids, never the secret. A known-safe call can be allowlisted by route action or bypassed with a header.

A bonus you get for free: data residency

Because the guard already sits on every outbound call and knows the provider, it can enforce where data is allowed to go:

// config/egress-guard.php
'residency' => [
    'enabled' => true,
    'allowed_regions' => ['EU'],
    'regions' => ['openai' => 'EU', 'anthropic' => 'US'],
],

A call to a provider whose region is not allowed is blocked exactly like a secret leak and fires a ResidencyViolation event. With enforcement off, the event still fires so you can measure exposure before you start blocking.

What redaction does to the stored copy

The guard does not alter the request you send unless guard mode is on and it blocks. What it redacts is the copy it stores for the inbox and findings, so your own audit trail never becomes a second place the secret lives. A critical, high-confidence match like a card number or a private key is masked in the stored body, and the rest stays readable so a reviewer keeps the context. The redaction is conservative by design: it masks what it is sure about rather than mangling the whole payload.

Frequently asked questions

Does this add latency to every AI call?

Only calls to known provider hosts are inspected; everything else takes a fast path. The inspection is an in-process scan of the request body, measured in milliseconds, with no extra network hop.

Is it a proxy?

No. There is no base URL to change and no separate service to run. It is middleware inside your app, on the HTTP client you already use.

What about streaming responses?

The guard inspects the outbound request, the prompt you send, which is where the leak you control lives. It does not need to buffer the streamed response to do that.

Does it cover openai-php?

Yes, by attaching its handler to that client's Guzzle stack, shown above. SDKs built on Laravel's HTTP client, including Prism, are covered automatically.

laravel-ai-egress-guard is MIT licensed. It pairs with laravel-ai-lint, which catches exposed keys in source before they ship, the circuit breaker that stops runaway spend, and a processing ledger that turns these same captured calls into a GDPR record of processing. Static analysis catches the wiring; the egress guard catches what only appears at runtime.

This is defensive tooling, not legal advice, and not affiliated with or endorsed by Laravel, Laravel LLC, or any AI provider.

Audit what your prompts are actually sending.

A senior engineer reviews every route that touches AI, every prompt builder, every mailable. Fixed price, every finding with a fix.