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.
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:
# 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-configThe 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:
REQKEY_PROJECT_KEY=reqkey_your_project_key
REQKEY_API_ID=api_paymentsQuickstart
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):
$request->attributes->get('reqkey')$request->getAttribute('reqkey') on the PSR requestMiddlewareResult from $guard->begin() — $result->decisionThe decision reads naturally in authorization code:
$decision->allowed()— true when the key is valid;$decision->reasoncategorizes a denial.$decision->creditsRemainingand$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::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 allIn 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:
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'),
);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:
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:
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'],
);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:
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\Exception — ConfigurationException, TimeoutException, TransportException, AuthenticationException, and ApiException:
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.
projectKeystringrequiredYour project credential, sent to ReqKey as the Bearer token. Read it from the REQKEY_PROJECT_KEY environment variable — never hard-code it.
apiIdstringrequiredThe ReqKey API this application serves — validation and analytics are scoped to it. The first argument to MiddlewareConfig.
modeModeWhat the middleware does per request: guard keys and record analytics (the default), key checks only, or analytics only.
enabledboolOne switch to bypass ReqKey entirely — handy as a local-development toggle that leaves your middleware layout untouched.
rootKeystringBackward-compatible alias for projectKey on the Client. Never set both.
keyLocationKeyLocationWhere the key travels. Headers are recommended — query strings end up in logs and browser history.
keyNamestringThe customer-facing header, query parameter, or cookie name. Make it yours: x-startup-key.
keySchemeKeySchemeFor headers: the raw key value, or a standard Authorization: Bearer token.
keyResolverresolverFull control — a closure that extracts the key from anywhere in the request.
creditsint | ClosureThe 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.
shouldProtectresolverA 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.
captureQueryParamsboolRecord query parameters and the query string with each analytics event. Off by default.
captureRequestHeadersboolRecord request headers — auth headers, cookies, and consumer keys are always stripped first.
captureResponseHeadersboolRecord response headers, with the same always-on redaction.
captureRequestBodyboolRecord textual request bodies, capped at the first 1,000 characters. Off by default.
captureResponseBodyboolRecord textual response bodies, capped at the first 1,000 characters.
captureClientIpboolRecord the caller’s IP address as resolved by the framework. Off by default.
captureUserAgentboolRecord 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.
consumerNameResolverresolverLabel events with a human-readable customer name from a header or another trusted source.
clientIpResolverresolverBehind a trusted proxy? Tell the SDK exactly which header holds the real client IP.
endpointResolverresolverNormalize route names before they are recorded — collapse ids so endpoints group cleanly.
requestIdResolverresolverIn ingest-only mode: correlate events to a validation performed by another middleware.
ingestDeniedRequestsboolRecord denied traffic too — invalid keys, exhausted credits, rate-limit hits — so abuse is visible. On by default.
failureModeFailureModeFail closed (block with a 503, the default) or fail open (let requests through unvalidated). Invalid and exhausted keys are always denied either way.
onErrorcallbackCalled 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.
timeoutfloatSeconds 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.