Symfony bundle for protecting routes with the x402 HTTP 402 Payment Required protocol (v2).
This bundle acts as a Resource Server: it does not implement wallets or direct blockchain logic. Payment verification and settlement are delegated to a facilitator behind the FacilitatorClientInterface — either an external HTTP service (default) or your own PHP implementation (see Facilitator).
- PHP 8.4+
- Symfony 7.4 or 8.x
composer require berenger/x402-bundleRegister the bundle in config/bundles.php:
return [
// ...
X402\Bundle\X402Bundle::class => ['all' => true],
];# config/packages/x402.yaml
x402:
facilitator_url: 'https://facilitator.example.com' # required with HttpFacilitatorClient
pay_to: '0xYourRecipientAddress'
network: 'eip155:84532' # CAIP-2 format (Base Sepolia)
asset: '0xUSDC' # required for protected routes
default_amount: '1000' # required for protected routes
settle: after # after (recommended) | before | manual, default: after
timeout: 10.0 # seconds, default
replay_cache_ttl: 300 # seconds, default
max_timeout_seconds: 60 # payment validity window, default
asset_name: 'USDC' # EIP-712 token name (exact scheme extra)
asset_version: '2' # EIP-712 token version (exact scheme extra)
replay:
cache_pool: cache.app # Symfony cache pool for replay protection
lock_store: null # optional lock store service id (defaults to FlockStore)facilitator_url is optional in configuration but required at compile time when the default HttpFacilitatorClient is used. If you provide a custom FacilitatorClientInterface, you can omit it. The bundle requires Symfony's FrameworkBundle because it uses cache.app, controller argument resolver tags and framework services.
Protect a controller action with the #[RequiresPayment] attribute:
use X402\Bundle\Attribute\CurrentPayment;
use X402\Bundle\Attribute\RequiresPayment;
use X402\Bundle\Payment\PaymentContext;
use X402\Bundle\Payment\PaymentVerificationResult;
use X402\Bundle\Payment\PaymentSettlementService;
final class ApiController
{
#[RequiresPayment(amount: '1000', description: 'Premium API access')]
public function premium(#[CurrentPayment] PaymentVerificationResult $payment): JsonResponse
{
return new JsonResponse(['data' => 'premium content', 'payer' => $payment->payer]);
}
}For settle: manual, inject PaymentSettlementService and use the
_x402_context request attribute. Call settle() exactly once after the
application work succeeds, or release() when the payment will not be
settled:
$context = $request->attributes->get('_x402_context');
if (!$context instanceof PaymentContext) {
throw new \LogicException('No verified payment context.');
}
$settlement = $paymentSettlementService->settle($context);Attribute fields are nullable and fall back to bundle configuration when omitted. Method-level attributes override class-level attributes field by field. The settle field accepts a SettleMode enum, e.g. #[RequiresPayment(settle: SettleMode::Manual)]. The mimeType field sets the resource MIME type advertised to the client in the payment requirements (e.g. #[RequiresPayment(mimeType: 'application/json')]).
You can expose several accepted payment options on the same route:
#[RequiresPayment(amount: '1000', network: 'eip155:84532')]
#[RequiresPayment(amount: '2000', network: 'eip155:1')]
public function multiNetwork(): JsonResponse
{
// ...
}The client chooses one option in PAYMENT-SIGNATURE; the bundle matches it against the server's accepts list before calling the facilitator.
The bundle dispatches Symfony events you can listen to:
| Event | When |
|---|---|
PaymentVerifiedEvent |
After successful facilitator verification |
PaymentSettledEvent |
After successful settlement |
PaymentFailedEvent |
When a payment is rejected (invalid payload, replay, verify/settle failure, facilitator error) |
- Client requests a protected route without
PAYMENT-SIGNATUREheader → HTTP 402 withPAYMENT-REQUIREDheader (base64-encoded payment requirements) and JSON body. - Client retries with
PAYMENT-SIGNATUREheader (base64-encodedPaymentPayloadJSON). - Bundle validates the accepted option, claims the replay id, then calls the facilitator
verifyendpoint. - On success, payment info is stored in
Requestattribute_x402_paymentand the controller runs. If the payment payload containsresource.url, it must exactly match the requested URL. - Settlement happens according to the
settlemode (see below), and when it succeeds aPAYMENT-RESPONSEheader is added to the response.
The settle option controls when the facilitator settle endpoint is called:
| Mode | Behaviour | When to use |
|---|---|---|
after (default) |
Settle in kernel.response, only if the controller returned a successful (2xx) response. The resource is produced first, then the payment is settled. If settlement fails, the response is replaced with a 402 and the replay nonce is released. |
Recommended. Avoids charging a payer for a resource that was never delivered. |
before |
Settle in kernel.controller, before the controller runs. The payer is charged regardless of whether the controller then succeeds. |
When you want to gate purely on payment and the controller cannot meaningfully fail. |
manual |
Only verify is performed. The verified PaymentContext is stored in _x402_context; the application must call PaymentSettlementService::settle() or release(). |
Advanced flows (deferred/queued settlement). |
In
aftermode,_x402_paymentcarriessettlement === nullinside the controller, because settlement has not happened yet — the settlement result is delivered to the client via thePAYMENT-RESPONSEheader. Inbeforemode the controller already sees the populatedsettlement.Note: even in
aftermode a tiny irreducible window remains — if settlement succeeds on-chain but delivery to the client then fails, the payer is charged without receiving the body. This is inherent to combining HTTP with on-chain settlement;afterminimises it,beforewidens it.
| Header | Direction | Description |
|---|---|---|
PAYMENT-REQUIRED |
Server → Client | Base64-encoded PaymentRequired object |
PAYMENT-SIGNATURE |
Client → Server | Base64-encoded PaymentPayload object |
PAYMENT-RESPONSE |
Server → Client | Base64-encoded SettlementResponse object |
| Situation | Status |
|---|---|
| Payment required | 402 |
| Invalid payload / replay / accepted mismatch | 400 |
| Verification or settlement rejected by facilitator | 402 |
| Facilitator unreachable or HTTP error | 502 |
| Missing bundle configuration (asset/amount) | 500 |
The facilitator is the component that actually verifies signatures and settles payments
(the cryptographic/on-chain part). This bundle never talks to a blockchain directly: it only
depends on the FacilitatorClientInterface, which has two methods, verify() and settle().
There are two ways to provide a facilitator. Both are valid.
This is what the bundle ships with: HttpFacilitatorClient sends the payment to a remote
facilitator service over HTTP. This is where facilitator_url is used. It's the base URL of
that service:
verify()→POST {facilitator_url}/verifysettle()→POST {facilitator_url}/settle
Each call POSTs a JSON body of the form { x402Version, paymentPayload, paymentRequirements }
and expects the facilitator's standard verify/settle response. Use this option when you rely on
a hosted or self-hosted facilitator (e.g. a TypeScript/Go implementation). In this case
facilitator_url is required and is the only facilitator-related setting you need.
facilitator_url is not used when you replace the default client. If you implement
FacilitatorClientInterface yourself, you can put any logic you want inside verify()/settle()
— including connecting directly to a blockchain RPC node, recovering the EIP-712 signer
(EIP-3009 transferWithAuthorization), checking balances, or broadcasting the settlement
transaction from PHP.
namespace App\Facilitator;
use X402\Bundle\Facilitator\FacilitatorClientInterface;
use X402\Bundle\Payment\PaymentPayload;
use X402\Bundle\Payment\PaymentRequirements;
use X402\Bundle\Payment\SettleResult;
use X402\Bundle\Payment\VerifyResult;
final class OnChainFacilitatorClient implements FacilitatorClientInterface
{
public function verify(PaymentPayload $payload, PaymentRequirements $requirements): VerifyResult
{
return new VerifyResult(isValid: true, payer: '0x...');
}
public function settle(PaymentPayload $payload, PaymentRequirements $requirements): SettleResult
{
return new SettleResult(success: true, transaction: '0x...', network: $requirements->network);
}
}Wire it in your application so it replaces the default client:
# config/services.yaml
services:
App\Facilitator\OnChainFacilitatorClient: ~
X402\Bundle\Facilitator\FacilitatorClientInterface: '@App\Facilitator\OnChainFacilitatorClient'Note: doing real EIP-712 / EIP-3009 verification and on-chain settlement in PHP is possible but non-trivial — the PHP crypto ecosystem is less mature than
viem/ethers.js. Many integrations therefore prefer Option A and delegate to a dedicated facilitator service.
Replay protection is always enforced. The bundle derives a replay identifier from the
payment payload, preferring authorization.nonce (EIP-3009), then nonce, then signature. If none of
these is present, it falls back to a SHA-256 hash of the raw PAYMENT-SIGNATURE header so an exact
duplicate submission is still rejected.
The identifier is reserved atomically before the facilitator is called (claim/release), which
closes the double-spend window during verification/settlement. By default a FlockStore
(filesystem lock) is used, which is suitable for a single server. For a multi-node
deployment, configure a distributed lock store:
x402:
replay:
lock_store: app.redis_lock_storeThe replay cache pool must also be shared by all application nodes. Lock contention is treated as a failed claim rather than blocking the request indefinitely.
composer install
composer test
composer phpstan
composer cs- No wallet implementation, and no built-in crypto/blockchain logic: verification and settlement
are delegated to a
FacilitatorClientInterface. The bundle ships only the HTTP implementation (facilitator_url); a direct on-chain implementation in PHP is possible but is up to you (see Facilitator). - The default replay lock store (
FlockStore) is single-server; use a distributed store for multi-node setups. - Experimental bundle, API may change.
MIT