ReqKey.docs
SDKs

PHP SDK

The official PHP SDK for API key validation, credit metering, consumer rate limits, and correlated traffic analytics. One framework-neutral client and middleware engine, plus small adapters for Laravel, Symfony, Slim, any PSR-15 dispatcher, and plain PHP — with typed enums, typed exceptions, and named-argument configuration throughout. PHP 8.1 or later is required.

Prefer it filled in for you?
The dashboard's Setup Guide generates this exact integration from a few questions — with your real API id, key location, and a copy-ready brief for AI coding agents. Explore every option interactively in the SDK playground.

Install

A single Composer package ships the shared client, the middleware processor, and every framework adapter. Nothing framework-specific is pulled in unless your application already depends on it:

terminal
# One package covers every framework
composer require reqkey/reqkey

# Slim 4 also needs a PSR-7 implementation
composer require reqkey/reqkey slim/slim slim/psr7

# Laravel: publish the config file (the provider is auto-discovered)
php artisan vendor:publish --tag=reqkey-config
Published from Git tags
Releases are cut from version tags and published to Packagist. The request contract and middleware behavior track the ReqKey Python SDK, so switching stacks never means relearning ReqKey.

The SDK reads your project credential from the REQKEY_PROJECT_KEY environment variable (falling back to the legacy REQKEY_ROOT_KEY). Copy the real key from the dashboard, and keep it out of source control:

.env
REQKEY_PROJECT_KEY=reqkey_your_project_key
REQKEY_API_ID=api_payments

Quickstart

Every adapter drives the same MiddlewareProcessor, built from a Client and a MiddlewareConfig. Laravel and Symfony build it for you from a config file; Slim, PSR-15, and plain PHP wire it explicitly. Every route it covers is guarded automatically — your handlers stay untouched, and the application never calls /key/validate or /ingest directly.

// config/reqkey.php
return [
    'project_key' => env('REQKEY_PROJECT_KEY'),
    'api_id' => env('REQKEY_API_ID'),
    'middleware' => [
        'key_name' => 'X-Startup-Key',
        'exclude_paths' => ['/health'],
    ],
];

// routes/web.php — apply the auto-discovered "reqkey" alias
Route::middleware('reqkey')->post('/payments', function (Request $request) {
    $decision = $request->attributes->get('reqkey');

    return response()->json([
        'created' => true,
        'credits_remaining' => $decision->creditsRemaining,
    ], 201);
});

Laravel and Symfony configure capture, failure, key-location, and retry options declaratively; for dynamic credit costs or resolver closures, keep those in the published PHP config (Laravel) or a small service (Symfony). Slim reuses the PSR-15 adapter directly. For scripts and workers, the direct Client validates and ingests without any middleware.

Reading the decision

After successful validation, the full VerificationResult — the consumer, credits remaining, and a traceable request id — is exposed where each framework keeps request-scoped values, as reqkey (with the correlation id as reqkey.request_id):

Laravel · Symfony
$request->attributes->get('reqkey')
PSR-15 · Slim
$request->getAttribute('reqkey') on the PSR request
Plain PHP
the MiddlewareResult from $guard->begin() $result->decision

The decision reads naturally in authorization code:

  • $decision->allowed() — true when the key is valid; $decision->reason categorizes a denial.
  • $decision->creditsRemaining and $decision->creditsLimit — the consumer's balance (?int).
  • $decision->requestId — a traceable id for support and log correlation.

Successful responses also carry X-ReqKey-Request-ID, X-ReqKey-Credits-Limit, X-ReqKey-Credits-Remaining, and X-ReqKey-Validation-Time-Ms headers — add them to your CORS expose_headers if browser JavaScript must read them.

Modes

One constructor argument decides what the middleware does per request:

mode
mode: Mode::Both      // validate keys AND record analytics (default)
mode: Mode::Validate  // key checks and credit deduction only — nothing recorded
mode: Mode::Ingest    // analytics only — no key checks at all

In Mode::Both, denied requests — missing keys, exhausted credits, forbidden access, rate-limit hits — are recorded too, so abuse is visible in your dashboards. Set ingestDeniedRequests: false to record successful requests only. In ingest-only mode, another component's validation can be correlated via a requestIdResolver closure. Ingestion runs inside the request lifecycle — a failed analytics send is logged, never surfaced to your consumer.

Where the key comes from

Match however your consumers already send credentials:

key extraction
use ReqKey\Enum\KeyLocation;
use ReqKey\Enum\KeyScheme;

// Custom header (recommended)
new MiddlewareConfig(apiId: 'api_payments', keyLocation: KeyLocation::Header, keyName: 'X-Startup-Key');

// Authorization: Bearer <key>
new MiddlewareConfig(apiId: 'api_payments', keyName: 'Authorization', keyScheme: KeyScheme::Bearer);

// ?api_key=<key> — easy to test, but URLs end up in logs
new MiddlewareConfig(apiId: 'api_payments', keyLocation: KeyLocation::Query, keyName: 'api_key');

// Or take full control with a resolver closure
new MiddlewareConfig(
    apiId: 'api_payments',
    keyResolver: static fn (RequestData $request): ?string => $request->header('X-Custom-Key'),
);
Headers over query parameters
Query parameters are available for compatibility, but headers are safer — URLs are retained in browser history and access logs. A credential read from a query parameter is always removed from captured query data and the analytics path, while still sent in the dedicated apiKey identity field. Query and cookie credentials must use the raw scheme.

Paths & credit costs

Excluded traffic is never validated, charged, or recorded — for anyone. The credits argument is one field: a non-negative int, or a closure for per-request pricing:

routing & pricing
new MiddlewareConfig(
    apiId: 'api_payments',
    // Exact paths or trailing-* prefixes: never validated, charged, or recorded
    excludePaths: ['/health', '/docs/*'],
    // Or decide per request with a predicate
    shouldProtect: static fn (RequestData $request): bool => str_starts_with($request->path, '/api/'),
    // Charge endpoints differently — a closure instead of an int
    credits: static fn (RequestData $request): int => $request->path === '/images' ? 5 : 1,
);

Every resolver receives a framework-neutral ReqKey\Http\RequestData — method, path, headers, query parameters, cookies, peer IP, and the framework's own request as $request->nativeRequest. The SDK deliberately never auto-retries /key/validate, because validation deducts credits — a safe retry would require server-side idempotency.

Analytics capture

By default each event records the method, normalized endpoint, status, latency, and User-Agent — correlated to the validated consumer. Everything else is opt-in with a plain bool:

capture
new MiddlewareConfig(
    apiId: 'api_payments',
    // All off by default — turn on what your dashboards need
    captureQueryParams: true,
    captureRequestHeaders: true,
    captureResponseHeaders: true,
    captureRequestBody: true,   // textual, first 1,000 chars
    captureResponseBody: true,
    captureClientIp: true,
    // User-Agent is captured by default — opt out explicitly
    captureUserAgent: false,
    // Strip additional headers beyond the built-in redaction list
    excludedHeaders: ['X-Internal-Trace'],
);
Redaction is always on
Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-API-Key, and your configured key name are always stripped before anything leaves your server — even with header capture on. Bodies are captured only for textual content types and truncated to 1,000 characters; non-seekable PSR streams are never consumed for analytics.

Failure behavior

Validation fails closed by default: if ReqKey times out or is unreachable, the middleware returns 503 without running your handler. Prefer availability? Set failureMode: FailureMode::Open — transport failures let requests through, while explicitly invalid, exhausted, forbidden, and rate-limited keys are still always denied. Either way, an onError callback tells you it happened:

failure
use ReqKey\Enum\FailureMode;
use ReqKey\Model\MiddlewareErrorEvent;

new MiddlewareConfig(
    apiId: 'api_payments',
    failureMode: FailureMode::Closed, // or FailureMode::Open: let requests through
    onError: static function (MiddlewareErrorEvent $event): void {
        // Provider-neutral: post it to Slack, Discord, PagerDuty, ...
        error_log(sprintf('ReqKey %s failed on %s %s: %s',
            $event->operation, $event->method, $event->path, $event->message()));
    },
    errorMessages: [
        'insufficient_credits' => 'Out of credits — top up in your dashboard.',
    ],
);

The MiddlewareErrorEvent intentionally excludes credentials, headers, queries, and bodies; exceptions thrown inside the callback are logged and never change the HTTP response. On the direct client, access decisions are normalized (200/402/403/429 are data, not errors) and real failures surface as typed exceptions under ReqKey\ExceptionConfigurationException, TimeoutException, TransportException, AuthenticationException, and ApiException:

errors.php
use ReqKey\Exception\ApiException;
use ReqKey\Exception\TimeoutException;
use ReqKey\Exception\TransportException;

try {
    $decision = $client->verify(key: $key, apiId: 'api_payments');
    // Access denial is data, not an exception — inspect the decision.
    if (!$decision->allowed()) {
        // $decision->reason, statusCode, message, retryAfter are available.
    }
} catch (TimeoutException $e) {
    // Validation deducts credits, so it is never retried by default.
} catch (TransportException | ApiException $e) {
    error_log('ReqKey unavailable: '.$e->getMessage());
}

Configuration reference

Every option below is a named argument to the MiddlewareConfig constructor. Laravel and Symfony expose the same options as snake_case keys in their config files (closures excepted). Booleans are plain — the two opt-out captures, captureUserAgent and ingestDeniedRequests, default on.

new MiddlewareConfig(…)
projectKeystringrequired

Your project credential, sent to ReqKey as the Bearer token. Read it from the REQKEY_PROJECT_KEY environment variable — never hard-code it.

apiIdstringrequired

The ReqKey API this application serves — validation and analytics are scoped to it. The first argument to MiddlewareConfig.

modeMode

What the middleware does per request: guard keys and record analytics (the default), key checks only, or analytics only.

enabledbool

One switch to bypass ReqKey entirely — handy as a local-development toggle that leaves your middleware layout untouched.

rootKeystring

Backward-compatible alias for projectKey on the Client. Never set both.

keyLocationKeyLocation

Where the key travels. Headers are recommended — query strings end up in logs and browser history.

keyNamestring

The customer-facing header, query parameter, or cookie name. Make it yours: x-startup-key.

keySchemeKeyScheme

For headers: the raw key value, or a standard Authorization: Bearer token.

keyResolverresolver

Full control — a closure that extracts the key from anywhere in the request.

creditsint | Closure

The cost per successful request. Pass a closure instead of a number to charge heavy endpoints more than light ones.

excludePathslist<string>

Paths that stay public — never validated, charged, or recorded. Exact matches or trailing-* prefixes.

shouldProtectresolver

A per-request predicate for when patterns are not enough — protect exactly the routes you choose.

skipMethodslist<string>

HTTP methods that bypass ReqKey. Defaults to ["OPTIONS"] so CORS preflights stay free.

captureQueryParamsbool

Record query parameters and the query string with each analytics event. Off by default.

captureRequestHeadersbool

Record request headers — auth headers, cookies, and consumer keys are always stripped first.

captureResponseHeadersbool

Record response headers, with the same always-on redaction.

captureRequestBodybool

Record textual request bodies, capped at the first 1,000 characters. Off by default.

captureResponseBodybool

Record textual response bodies, capped at the first 1,000 characters.

captureClientIpbool

Record the caller’s IP address as resolved by the framework. Off by default.

captureUserAgentbool

Record the User-Agent header. On by default — set false to opt out.

excludedHeaderslist<string>

Strip additional headers from captured analytics, beyond the built-in redaction list.

consumerNameResolverresolver

Label events with a human-readable customer name from a header or another trusted source.

clientIpResolverresolver

Behind a trusted proxy? Tell the SDK exactly which header holds the real client IP.

endpointResolverresolver

Normalize route names before they are recorded — collapse ids so endpoints group cleanly.

requestIdResolverresolver

In ingest-only mode: correlate events to a validation performed by another middleware.

ingestDeniedRequestsbool

Record denied traffic too — invalid keys, exhausted credits, rate-limit hits — so abuse is visible. On by default.

failureModeFailureMode

Fail closed (block with a 503, the default) or fail open (let requests through unvalidated). Invalid and exhausted keys are always denied either way.

onErrorcallback

Called on ReqKey service failures with a provider-neutral event — wire it to Slack, Discord, or PagerDuty. No request secrets included.

errorMessagesarray<string, string>

Rewrite the customer-facing denial messages in your own voice, per error type.

timeoutfloat

Seconds allowed for each ReqKey operation before the failure mode kicks in. Defaults to 2.0. Set on the Client.

Client settings

The project credential and timeout live on the Client, not the middleware config. ClientConfig::fromEnvironment() reads REQKEY_PROJECT_KEY, REQKEY_BASE_URL (default https://api.reqkey.com), REQKEY_TIMEOUT (default 2 seconds), and the retry variables; or build a ClientConfig directly with projectKey, baseUrl, timeout, and maxRetries. Validation retries are off by default because validation deducts credits; ingestion retries are safe once maxRetries is greater than zero. Never configure both a project and a root key.