Platform: https://proofage.xyz | Packagist: https://packagist.org/packages/proofage/laravel-client
A Laravel package for integrating with the ProofAge API, featuring automatic HMAC authentication and a fluent interface.
Full API reference: https://docs.proofage.xyz/api-reference.html#/
ProofAge is an online age verification platform enabling websites to confirm users meet minimum age requirements through a hosted, privacy-focused KYC process — without server-side document handling. It supports alcohol/tobacco/cannabis commerce, adult content platforms, gambling sites, and age-restricted subscriptions.
This package provides a first-class Laravel integration: a service provider with auto-discovery, a facade, HMAC-signed webhook middleware, and a setup verification command.
It is built on proofage/php-sdk, the framework-neutral ProofAge client, which does the request signing, the retries and the resource calls. This package adds the Laravel wiring and sends every request through the Http facade, so Http::fake() intercepts the client in your tests. Upgrading from 0.6? See UPGRADE.md.
Install the package via Composer:
composer require proofage/laravel-clientPublish the configuration file:
php artisan vendor:publish --provider="ProofAge\Laravel\ProofAgeServiceProvider" --tag="config"Configure your environment variables:
PROOFAGE_API_KEY=your-api-key
PROOFAGE_SECRET_KEY=your-secret-key
PROOFAGE_BASE_URL=https://api.proofage.xyz
PROOFAGE_VERSION=v1After configuration, verify your setup using the built-in command:
php artisan proofage:verify-setupWhen everything is configured correctly, you should see:
✅ Configuration is valid
✅ Workspace connection successful
✅ Webhook URL is configured https://yoursite.com/webhooks/proof-age
✅ Webhook route found: POST webhooks/proof-age -> App\Http\Controllers\WebhookController@handleProofAgeWebhook
✅ Webhook route is protected with VerifyWebhookSignature middleware
✅ ProofAge setup verified successfully!
The verification command ensures:
- Configuration - API keys and base URL are properly set
- Workspace Connection - Can successfully connect to ProofAge API
- Webhook URL - Webhook endpoint is configured in your workspace
- Route Existence - Laravel route exists for the webhook path
- HTTP Method - Route accepts POST requests
- Security Middleware - Route is protected with HMAC signature verification
If you see errors about missing middleware, add it to your webhook route:
Route::post('/webhooks/proof-age', [WebhookController::class, 'handleProofAgeWebhook'])
->middleware('proofage.verify_webhook');use ProofAge\Laravel\Facades\ProofAge;
use ProofAge\Laravel\Resources\VerificationResource;
// Get workspace information
$workspace = ProofAge::workspace()->get();
// Create a verification
$verification = ProofAge::verifications()->create([
'callback_url' => 'https://your-app.com/webhook',
'metadata' => ['user_id' => 123]
]);
// Get verification details
$verification = ProofAge::verifications()->find('verification-id');
// Get age estimation details
$estimation = ProofAge::verifications('verification-id')->estimation();
// [
// 'verification_id' => '...',
// 'attempt_id' => '...',
// 'age_threshold' => [
// 'minimum' => 18,
// 'passed' => true,
// 'confidence' => 0.98,
// ],
// 'gender' => [
// 'value' => VerificationResource::GENDER_FEMALE, // 0 = female, 1 = male
// 'confidence' => 0.93,
// ],
// ]
// Accept consent for verification
ProofAge::verifications('verification-id')->acceptConsent([
'consent_version_id' => 1,
'text_sha256' => 'hash-value'
]);
// Upload media
ProofAge::verifications('verification-id')->uploadMedia([
'type' => 'selfie',
'file' => $uploadedFile
]);
// Submit verification
ProofAge::verifications('verification-id')->submit();use ProofAge\Laravel\ProofAgeClient;
$client = app(ProofAgeClient::class); // app(\ProofAge\Sdk\Client::class) resolves the same singleton
$workspace = $client->workspace()->get();
// Lower level: makeRequest() returns a ProofAge\Sdk\Http\Response
$response = $client->makeRequest('GET', 'workspace');
$response->status();
$response->json();ProofAgeClient is the SDK client, so its middleware and events are available: a middleware runs once per HTTP attempt, before signing; events observe the signed request and the response, with the API key and signature masked.
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use ProofAge\Laravel\ProofAgeClient;
use ProofAge\Sdk\Events\ResponseEvent;
use ProofAge\Sdk\Http\Request;
use ProofAge\Sdk\Http\Response;
$client = app(ProofAgeClient::class);
$client->pushMiddleware(fn (Request $request, callable $next): Response => $next(
$request->withHeader('X-Request-Id', (string) Str::uuid())
));
$client->onResponse(fn (ResponseEvent $e) => Log::info('proofage.response', [
'status' => $e->status(),
'attempt' => $e->attempt(),
'ms' => $e->durationMs(),
]));See the SDK's README for the full middleware and event API and for what raw() on an event exposes.
dd(), dump(), print_r() and var_dump() of the client, of a request or of a caught exception
show the SDK's redacted view: the secret key as [redacted], the API key and the HMAC signature
masked, a request body as its size and sha256 rather than its bytes. The SDK covers print_r() and
var_dump() itself through __debugInfo(); this package registers casters with Symfony's
VarDumper — what Laravel's dd() and dump() use, and which otherwise reads the real properties by
reflection — for the same classes when Composer's autoloader loads. var_export() and reflection
are not covered.
The package includes middleware to verify HMAC signatures on incoming webhook requests from ProofAge.
Apply the middleware to your webhook routes:
// In your routes/web.php or routes/api.php
Route::post('/proofage/webhook', [WebhookController::class, 'handle'])
->middleware('proofage.verify_webhook');Or apply it to a route group:
Route::middleware(['proofage.verify_webhook'])->group(function () {
Route::post('/proofage/decision-webhook', [WebhookController::class, 'handleDecision']);
Route::post('/proofage/track-webhook', [WebhookController::class, 'handleStatusChanged']);
});The middleware:
- Checks that
PROOFAGE_SECRET_KEYis configured - Verifies the
X-HMAC-Signatureheader is present - Generates the expected signature using the same algorithm as ProofAge
- Compares signatures using
hash_equals()for timing-safe comparison - Returns appropriate error responses for invalid requests
Some applications need separate verification flows for different user roles. For example, a marketplace where buyers go through a basic age check while sellers require full identity verification -- each with its own ProofAge workspace, credentials, and webhook endpoint.
The package supports this out of the box. All shared settings (base_url, version, timeout, etc.) are inherited from the default proofage config, so additional workspaces only need their own api_key and secret_key.
The default workspace (buyers) is configured via config/proofage.php as usual. For sellers, add a second set of credentials anywhere in your application config -- config/services.php is a common choice:
// config/services.php
'proofage_seller' => [
'api_key' => env('PROOFAGE_SELLER_API_KEY'),
'secret_key' => env('PROOFAGE_SELLER_SECRET_KEY'),
],# .env
# Buyer workspace (default)
PROOFAGE_API_KEY=pk_live_...
PROOFAGE_SECRET_KEY=sk_live_...
# Seller workspace
PROOFAGE_SELLER_API_KEY=pk_live_...
PROOFAGE_SELLER_SECRET_KEY=sk_live_...The ProofAge facade and app(ProofAgeClient::class) singleton always use the default (buyer) workspace. For the seller workspace, use ProofAgeClientFactory:
use ProofAge\Laravel\Facades\ProofAge;
use ProofAge\Laravel\ProofAgeClientFactory;
// Buyer verification -- uses default proofage.* config
$buyerVerification = ProofAge::verifications()->create([
'callback_url' => 'https://marketplace.com/webhooks/proofage',
]);
// Seller verification -- uses services.proofage_seller config
$sellerClient = app(ProofAgeClientFactory::class)->make('services.proofage_seller');
$sellerVerification = $sellerClient->verifications()->create([
'callback_url' => 'https://marketplace.com/webhooks/proofage-seller',
]);Each workspace sends webhooks signed with its own secret key. Use the middleware's config prefix parameter to verify signatures with the correct credentials:
// routes/api.php
// Buyer webhooks -- verified with default proofage.* keys
Route::post('/webhooks/proofage', [BuyerWebhookController::class, 'handle'])
->middleware('proofage.verify_webhook');
// Seller webhooks -- verified with services.proofage_seller keys
Route::post('/webhooks/proofage-seller', [SellerWebhookController::class, 'handle'])
->middleware('proofage.verify_webhook:services.proofage_seller');# Check the buyer (default) workspace
php artisan proofage:verify-setup
# Check the seller workspace
php artisan proofage:verify-setup --config=services.proofage_sellerThe command checks configuration, API connectivity, webhook route existence, and that the middleware uses the matching config prefix -- so you'll be warned if the keys would mismatch.
When a custom config prefix is used, the following resolution rules apply:
| Key | Resolution |
|---|---|
api_key |
Read from the specified prefix (required) |
secret_key |
Read from the specified prefix (required) |
base_url |
Specified prefix, falls back to proofage.base_url |
version |
Specified prefix, falls back to proofage.version |
timeout |
Specified prefix, falls back to proofage.timeout |
retry_attempts |
Specified prefix, falls back to proofage.retry_attempts |
retry_delay |
Specified prefix, falls back to proofage.retry_delay |
webhook_tolerance |
Specified prefix, falls back to proofage.webhook_tolerance (default: 300s) |
Additional workspaces only need api_key and secret_key. If a workspace connects to a different ProofAge environment (e.g. staging), add base_url under the same prefix and it will take priority over the default.
workspace()->get()- Get workspace informationworkspace()->getConsent()- Get consent information
verifications()->create(array $data)- Create a new verificationverifications()->find(string $id)- Get verification by IDverifications(string $id)->acceptConsent(array $data)- Accept consentverifications(string $id)->uploadMedia(array $data)- Upload media filesverifications(string $id)->submit()- Submit verification for processingverifications(string $id)->document()- Get sanitized document fields and source mediaverifications(string $id)->estimation()- Get age-threshold and gender estimationverifications(string $id)->blockFace(?array $data)- Block the verification face for AML
Every method's exact request and response shape is documented in the SDK: its AGENTS.md
(vendor/proofage/php-sdk/AGENTS.md), the @param/@return PHPDoc on ProofAge\Sdk\Resources\*,
and the bundled vendor/proofage/php-sdk/resources/openapi.json.
ProofAge\Sdk\Enums\VerificationStatus, ProofAge\Sdk\Enums\WebhookReason and
ProofAge\Sdk\Enums\BlockFaceReasonCode model the status, AML reason and reason_code values.
(ProofAge\Laravel\Enums\* was removed in 0.7.0.)
Inside a Laravel application, catch the Laravel name for a specific status and the SDK base class for everything:
use ProofAge\Laravel\Exceptions\AuthenticationException; // 401
use ProofAge\Laravel\Exceptions\ValidationException; // 422, getErrors()
use ProofAge\Sdk\Exceptions\TransportException; // connection refused, DNS, TLS, timeout
use ProofAge\Sdk\Exceptions\ProofAgeException; // every other non-2xx, and the base class of all of the above
try {
$verification = ProofAge::verifications()->create($data);
} catch (AuthenticationException $e) {
// 401: $e->getErrorCode()
} catch (ValidationException $e) {
// 422: $e->getErrors()
} catch (TransportException $e) {
// The API could not be reached; $e->getResponse() is null
} catch (ProofAgeException $e) {
// Everything else: $e->getCode() is the HTTP status, $e->getResponse() the ProofAge\Sdk\Http\Response
}The client throws ProofAge\Laravel\Exceptions\AuthenticationException for a 401,
ValidationException for a 422 and ProofAgeException for every other non-2xx; the webhook
middleware throws WebhookVerificationException. All four descend from
ProofAge\Laravel\Exceptions\ProofAgeException, which descends from
ProofAge\Sdk\Exceptions\ProofAgeException — the catch-all, and the only one of the two bases that
also catches TransportException.
They do not descend from the SDK's own ProofAge\Sdk\Exceptions\AuthenticationException,
ValidationException or WebhookVerificationException: PHP allows one parent, and keeping the
pre-0.7 catch (ProofAge\Laravel\Exceptions\ProofAgeException) working won. A catch on one of
those three SDK names therefore never matches inside a Laravel application — a 422 would fall
through to whatever comes next. The Laravel names are deprecated in 0.7 and removed in 1.0, when
the SDK names become what is thrown; see UPGRADE.md.
The webhook middleware throws ProofAge\Laravel\Exceptions\WebhookVerificationException on invalid requests. It descends from ProofAge\Laravel\Exceptions\ProofAgeException (not from the SDK's WebhookVerificationException; see Error Handling above) and carries errorCode, statusCode and toArray(). By default, the exception renders a JSON error response:
{
"error": {
"code": "INVALID_SIGNATURE",
"message": "HMAC signature is invalid"
}
}To customize this response, register a renderable in your application's exception handler:
Laravel 11+ (bootstrap/app.php):
use ProofAge\Laravel\Exceptions\WebhookVerificationException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->renderable(function (WebhookVerificationException $e) {
return response()->json([
'error' => [
'code' => $e->errorCode,
'message' => $e->getMessage(),
],
], $e->statusCode);
});
})Laravel 10 (app/Exceptions/Handler.php):
use ProofAge\Laravel\Exceptions\WebhookVerificationException;
public function register(): void
{
$this->renderable(function (WebhookVerificationException $e) {
return response()->json([
'error' => [
'code' => $e->errorCode,
'message' => $e->getMessage(),
],
], $e->statusCode);
});
}composer testIn your own application's tests, Http::fake() intercepts every request the client makes, including
multipart uploads ($request->hasFile('file')) and the signed headers ($request->header('X-HMAC-Signature')):
Http::fake(['api.proofage.xyz/v1/workspace' => Http::response(['id' => 'ws_1', 'name' => 'Acme'])]);
ProofAge::workspace()->get();
Http::assertSent(fn ($request) => $request->hasHeader('X-API-Key'));If you would rather not go through the facade, ProofAge\Sdk\Testing\FakeHttpClient is a transport
double the SDK ships: new ProofAgeClient($config, $fake).
- Platform: https://proofage.xyz
- Live Demo: https://demo.proofage.xyz
- Node SDK:
@proofage/nodeon npm
| Platform | Repository | Use-case |
|---|---|---|
| Node.js | ProofAge/node-client | Node.js age verification client — HMAC-signed API calls, webhook verification for Express, Hono, Next.js and other Node.js frameworks |
| WordPress | ProofAge/wordpress-plugin | Age gate plugin for WordPress — WooCommerce age verification, age-restricted pages, adult content gating |
| Laravel | this repo | Laravel age verification client — HMAC-signed API calls, webhook handling, middleware for age-restricted routes |
| Next.js | ProofAge/demo | Full-stack age verification demo with JS SDK, server routes, and webhook receiver |
The MIT License (MIT). Please see License File for more information.