p2flux / laravel
Official Laravel integration for the P2Flux PHP SDK: USDC payments and subscriptions on Base, settled straight to your own wallet.
Requires
- php: ^8.2
- illuminate/contracts: ^12.61.1 || ^13.12
- illuminate/support: ^12.61.1 || ^13.12
- p2flux/sdk-php: ^0.15.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5.50 || ^12.5.8
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
composer require p2flux/laravel
The official Laravel integration for P2Flux: USDC payments and subscriptions on Base that settle straight to your own wallet. No custody, no payout step, no account balance.
This package is deliberately thin. It binds the official
PHP SDK's P2Flux\P2FluxClient into the container from your
config, adds a facade, a publishable config file, an artisan about section and an optional
p2flux.paywall middleware that charges AI agents for a route. No tables, no models, no routes, no
scheduler, no second SDK to keep in sync.
p2flux/laravel -> p2flux/sdk-php -> the P2Flux API
Requirements
| PHP | 8.2+ (8.3+ on Laravel 13) |
| Laravel | 12.61.1+, 13.12+ |
| Dependencies | p2flux/sdk-php, illuminate/support, illuminate/contracts |
Those floors are not arbitrary. Every Laravel 11 release, and the earlier 12 and 13 patches, carry
an unresolved security advisory, so Composer refuses to install them by default. Requiring versions
below the floor would mean shipping you a framework your own composer audit flags.
Nothing to register
Laravel's package discovery registers the service provider. There is no
bootstrap/providers.php edit, and the defaults work immediately — publishing the config file is
optional:
php artisan vendor:publish --tag=p2flux-config
Configure through the environment:
P2FLUX_API_URL=https://api-test.p2flux.com # default: https://api.p2flux.com P2FLUX_TIMEOUT=60
There is no API key. P2Flux v1 has no API authentication: a payment is bound to its recipient and amount by the customer's own signature, and the contract refuses a second charge in a period. Anything asking for a P2Flux key is describing a product that does not exist.
Five-minute payment
Inject the SDK client anywhere Laravel resolves classes:
use P2Flux\P2FluxClient; final class CreatePaymentController extends Controller { public function __construct(private readonly P2FluxClient $p2flux) { } public function __invoke(Request $request): JsonResponse { $order = Order::create(['user_id' => $request->user()->id, 'amount' => '12.50', 'status' => 'pending']); // The recipient and the amount come from your config and your records - never the request. $payment = $this->p2flux->createPayment([ 'recipient' => config('services.p2flux.recipient'), 'amount' => $order->amount, ]); $order->update(['p2flux_intent' => $payment['intent']]); return response()->json([ 'order' => $order->id, 'checkout' => 'https://pay.p2flux.com/#/pay/' . rawurlencode($payment['intent']), ]); } }
Hosted checkout
Send the buyer to the checkout URL. Their wallet pays, and the checkout reports back to the page that opened it:
const win = window.open(checkoutUrl, 'p2flux', 'width=460,height=680') addEventListener('message', (event) => { if (event.origin !== new URL(CHECKOUT).origin) return if (event.data?.type === 'p2flux.ready') { win.postMessage({ type: 'p2flux.hello' }, new URL(CHECKOUT).origin) } if (event.data?.type === 'p2flux.payment.completed') { fetch('/payments/verify', { // hand it to Laravel; decide nothing here method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: ORDER_ID, tx_hash: event.data.tx_hash }), }) } })
Verify before fulfilling
Webhooks are optional and never decide. The browser message (or a webhook) says what happened; your Laravel backend's verdict is what decides.
$verdict = $this->p2flux->verifyPayment($order->p2flux_intent, $txHash, $settlementReceipt); if (($verdict['valid'] ?? false) !== true) { return ($verdict['code'] ?? '') === 'PAYMENT_CONFIRMING' ? response()->json(['status' => 'confirming'], 202) // poll the same hash : response()->json(['status' => 'unsettled', 'code' => $verdict['code'] ?? null]); } // Fulfil exactly once: re-check the status inside the lock. DB::transaction(function () use ($order, $verdict): void { $fresh = Order::whereKey($order->id)->lockForUpdate()->first(); if ($fresh->status === 'paid') { return; } $fresh->update(['status' => 'paid', 'tx_hash' => $verdict['tx_hash']]); });
Lost the hash entirely? recoverPayment($intent) finds the settlement from the intent alone.
Full walk-through: payments.
Optional webhooks
With p2flux/sdk-php 0.12+, add notify_url when creating a payment (or a payment link) and P2Flux
POSTs to it once the payment is verified on chain - no queue worker or scheduler of your own needed:
$payment = P2Flux::createPayment([ 'recipient' => config('p2flux.recipient'), 'amount' => $order->total, 'notify_url' => route('p2flux.notify', $order), // a public https URL, excluded from CSRF ]); // routes/web.php: Route::post('/p2flux/notify/{order}', ...)->name('p2flux.notify'); $result = P2Flux::handleNotification($request->getContent(), ['reference' => $order->p2flux_reference]); if ($result['confirmed']) { $order->markPaidOnce($result['dedupe_key']); // idempotent, under lockForUpdate() } return response()->noContent(); // a P2FluxException (not final yet) becomes a 5xx: it is sent again
handleNotification asks the API again and checks the answer is for this order; the notification
itself carries no secret. If the create response has no notify_url, the payment is not watched -
keep verifying as above. Details: https://p2flux.com/docs/webhooks.html
Signed webhooks
With p2flux/sdk-php 0.15+, every delivery carries p2flux-signature: t=<unix>,kid=<id>,v1=<Ed25519>
over {$t}.{$rawBody}, signed with P2Flux's public key (from capabilities(); no shared secret, needs
ext-sodium, bundled with PHP); the API signs from release 2026-11, so a delivery from an older API has
no header. Check it on the raw body before anything else - a small middleware that skips a request
without the header and refuses (4xx/5xx, never 2xx) one whose signature is present but bad:
// app/Http/Middleware/P2FluxSignature.php use Closure; use Illuminate\Http\Request; use P2Flux\Laravel\Facades\P2Flux; use P2Flux\P2FluxException; final class P2FluxSignature { public function handle(Request $request, Closure $next) { $signature = $request->header('p2flux-signature'); if ($signature === null) { // No header: an API before the signing release. Let it through - handleNotification's API // check decides on money either way. Only a present-but-bad signature is refused. return $next($request); } try { P2Flux::verifyWebhook($request->getContent(), (string) $signature); } catch (P2FluxException $e) { // WEBHOOK_TOO_OLD usually means this server's clock is off by more than 5 minutes; every retry // would fail the same way, so let it through - handleNotification's API check decides. if ($e->status === 'WEBHOOK_TOO_OLD') { return $next($request); } // WEBHOOK_SIGNATURE_INVALID is a refusal; anything else (the keys could not be fetched) is // temporary, so answer 5xx and P2Flux sends it again. abort($e->action === 'INVALID_REQUEST' ? 401 : 503); } return $next($request); } } // routes/web.php Route::post('/p2flux/notify/{order}', NotifyController::class) ->middleware(P2FluxSignature::class)->name('p2flux.notify');
Or in one step in the controller: P2Flux::handleNotification($request->getContent(), ['reference' => $order->p2flux_reference, 'signature' => $request->header('p2flux-signature')]) (null when absent) answers
confirmed => false with reason WEBHOOK_SIGNATURE_INVALID / WEBHOOK_TOO_OLD for a bad one.
A signature proves P2Flux sent the message, not that money moved: keep handleNotification, which
still asks the API. P2Flux::webhookDeliveries(['intent' => ...]) shows each attempt (7 days) and
P2Flux::redeliverWebhook($token, $eventId) sends a pending one now.
merchant_ref (your order number: ASCII letters, digits and . _ : / # -, 1-64 characters) passes
through createPayment, createPaymentLink, createSubscription and P2Flux::charge($ref, $merchantRef); an API that does not support it yet refuses the field. Full reference:
sdk-php README.
Pay the network fee in USDC
A buyer holding USDC and no ETH can still pay. They sign a token authorization, the P2Flux relayer submits the transaction and pays the Base network fee in ETH, and the buyer reimburses that exact cost in USDC in the same transaction. Nothing is waived: the fee is quoted before they sign, and they pay it in USDC rather than in ETH.
$usdc = collect($this->p2flux->capabilities()['tokens'])->firstWhere('symbol', 'USDC'); $sponsored = $usdc && in_array('payment_token', $usdc['gas_payment_modes'], true); $payment = $this->p2flux->createPayment([ 'recipient' => config('services.p2flux.recipient'), 'amount' => '12.50', 'gas_payment_mode' => $sponsored ? 'payment_token' : 'native', ]);
Ask capabilities() first: support is a fact about a deployment, not about a token.
Details: paying the network fee in USDC.
Subscriptions
P2Flux schedules nothing, and this package starts no billing. Your application decides a period
is due and calls charge() from your own command or job:
$result = $p2flux->charge($subscription->capability); if ($result->ok) { return; // CHARGED or ALREADY_CHARGED - the period is paid } match ($result->action) { 'WAIT' => null, // confirming; the money moved 'RETRY_LATER' => $this->retryLater(), 'CUSTOMER_ACTION_REQUIRED' => $subscription->needsCustomer($result->status), 'STOP_SUBSCRIPTION' => $subscription->stop($result->status), };
charge() never throws on a payment outcome. Schedule it yourself:
Schedule::command('p2flux:charge-due')->hourly()->withoutOverlapping();
See subscriptions and
examples/Console/ChargeDueSubscriptions.php.
Charge AI agents (x402 paywall)
AI agents pay for a route in USDC, per request, with the open x402 standard. Set your wallet and put a middleware on the route:
P2FLUX_RECIPIENT=0xYourWallet
Route::get('/report', ReportController::class)->middleware('p2flux.paywall'); // config price (0.05) Route::get('/data', DataController::class)->middleware('p2flux.paywall:0.20'); // this route's price Route::get('/article/{id}', ArticleController::class)->middleware('p2flux.paywall:0.05,agents');
- A request without payment gets
402 Payment Requiredwith the price; the agent pays and repeats it. The payment is settled before the route runs, and serves one response. ,agentscharges AI agents and programs only — browsers and search engines pass free. Without it every caller pays, which is what an API wants.- Agents pay per request (P2Flux keeps 1%, at least 0.003 USDC) or from a prepaid balance with no
transaction per request (3%).
p2flux.paywall.prepaid => falseoffers pay-per-request only. - The payment requirement is kept in your default cache for an hour.
Money goes to your wallet; the fee is taken on chain. No account, no API key. Every setting, the test-to-live checklist: paywall.
Facade
Optional, and imported explicitly — this package registers no global alias:
use P2Flux\Laravel\Facades\P2Flux; $caps = P2Flux::capabilities();
It resolves the same container singleton. Constructor injection stays the recommended interface, because it is what makes your own classes testable.
Every P2FluxClient method works through it, including P2Flux::rates() and
P2Flux::createMerchantToken(); the accounting statement is P2Flux\Statement::statement() (static),
and a refund sent from a Safe verifies with P2Flux::verifyRefund() like any other.
Testing
Your application never needs to spend crypto to be tested. The SDK takes a transport callable, so
a fake replaces HTTP entirely and the container carries it everywhere:
$this->app->instance(P2FluxClient::class, new P2FluxClient([ 'apiUrl' => 'https://api.example', 'transport' => fn (string $url, array $payload, int $timeout): array => [200, ['valid' => true, 'tx_hash' => '0xabc']], ]));
Recipes per outcome: testing.
Documentation
| Getting started | Install, configuration, injection, the facade |
| Payments | Create, hosted checkout, verify, recover |
| Paying the network fee in USDC | Buyers with no ETH |
| Subscriptions | Signup, your renewal job, recovery |
| Charge AI agents | The p2flux.paywall middleware, its settings, test to live |
| Testing | Fake transports, canned answers |
| Production checklist | Before real money |
| Examples | Controllers, commands and a test, as application code |
| Pre-publication review | The audit this package passed before release |
Protocol detail lives in the PHP SDK's own documentation: sdk-php · p2flux.com/docs
The other official packages
| PHP SDK | composer require p2flux/sdk-php — Packagist · GitHub |
| JavaScript / TypeScript SDK | npm install @p2flux/sdk — npm · GitHub |
License
MIT.