square1 / laravel-mpp
Charge AI agents per request from any Laravel route using the Machine Payments Protocol (MPP), settling over Stripe Shared Payment Tokens or Tempo on-chain stablecoins. Tag routes via middleware or a #[RequiresPayment] attribute.
Requires
- php: ^8.4
- illuminate/support: ^12.0|^13.0
- stripe/stripe-php: >=16.0 <21.0
Requires (Dev)
- laravel/framework: ^12.0|^13.0
- laravel/pint: ^1.14
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.0
- phpunit/phpunit: ^12.5
This package is auto-updated.
Last update: 2026-08-07 17:20:18 UTC
README
Laravel MPP
Charge AI agents for access to Laravel routes with the Machine Payments Protocol (MPP).
square1/laravel-mpp returns a 402 Payment Required challenge for protected routes. A capable agent pays the challenge, retries the request, and receives the response with a payment receipt. You choose the price per route, or issue a metered session where one payment grants multiple accesses.
The package includes two payment rails:
- Stripe Shared Payment Tokens (SPTs), settled as PaymentIntents.
- Tempo pathUSD, paid by the stock
npx mppxclient.
Readiness
The Laravel middleware, signed challenges, receipts, metered sessions, and storage drivers are designed for production use.
The bundled Stripe rail depends on Stripe Shared Payment Tokens, which currently use preview APIs. Use it for test-mode development, demos, and Stripe-approved pilot or live flows. Expect API shape, Dashboard behavior, and buyer-wallet availability to change while Stripe's agentic-commerce APIs are in preview. Test mode will work globally, but live acceptance is currently gated to North American companies (August 26).
The bundled Tempo rail targets Tempo testnet pathUSD and the stock mppx client. Treat it as testnet integration support unless you have a separate mainnet deployment plan.
Route::get('/resource', MyPaidResource::class) ->middleware('mpp:0.50,USD'); #[RequiresPayment(amount: '5.00', currency: 'USD', grants: 10, scope: 'report.basic')] public function report() { // One $5 payment grants 10 accesses. }
For a real-world demo, see PayForGoals.com.
Contents
- Readiness
- Installation
- Quickstart
- Choose a Payment Rail
- Protecting Routes
- Metered Access
- Dynamic Pricing
- Preconditions
- Session Storage
- Configuration
- Testing
- Advanced Usage
- License
Release history, and anything to watch when upgrading, is in CHANGELOG.md.
Installation
Requires PHP 8.4 and Laravel 12 or 13.
composer require square1/laravel-mpp
Publish the config:
php artisan vendor:publish --tag=mpp-config
The database session store keeps metered credit balances in a table. Publish and run its migration only if you use that driver (see Session Storage):
php artisan vendor:publish --tag=mpp-migrations php artisan migrate
The package registers the mpp middleware alias automatically. No bootstrap/app.php changes are required.
By default, challenge signing uses a key derived from APP_KEY. Set MPP_CHALLENGE_SECRET in production if you want to rotate the MPP signing key independently. Rotating it invalidates only in-flight 402 challenges, not issued sessions.
MPP_SESSION_DRIVER=cache MPP_CHALLENGE_SECRET=
Quickstart
This example uses Stripe test mode, transacting directly with a Shared Payment Token. Test mode works wherever your Stripe account is based. As of August 2026, live acceptance is gated to North America-based accounts, including the Link buyer wallet, so the test-mode flow below is the broadly supported test path today.
Add your Stripe test secret key:
STRIPE_SECRET_KEY=sk_test_...
Protect a route:
use Illuminate\Support\Facades\Route; Route::get('/resource', fn () => response()->json(['result' => 'SOME_DATA'])) ->middleware('mpp:1.00,USD');
Hit the route without payment:
curl -si https://your-host/resource
The response is a signed 402 Payment Required challenge:
{
"type": "https://paymentauth.org/problems/payment-required",
"title": "Payment Required",
"status": 402,
"challengeId": "chal_...",
"accepts": [
{
"method": "stripe",
"amount": "1.00",
"currency": "USD",
"scope": "report.basic",
"expiresAt": "...",
"sig": "..."
}
]
}
That confirms the seller side is working. To complete the payment loop yourself in test mode, see Testing Stripe End to End.
Choose a Payment Rail
Stripe is the default primary method. Use Tempo per route with method=tempo, or globally with MPP_DEFAULT_METHOD=tempo.
Stripe
Stripe settlement uses Shared Payment Tokens. The verifier creates and confirms a PaymentIntent from the SPT presented by the buyer.
STRIPE_SECRET_KEY=sk_test_... STRIPE_NETWORK_ID=profile_... STRIPE_API_VERSION=2026-05-27.preview
STRIPE_SECRET_KEY is needed to settle a payment. The package still emits a 402 without it, but settlement will fail until it is set.
STRIPE_NETWORK_ID is the Stripe profile id advertised in the challenge. Link and agent wallets use it to scope an SPT to your business. It is not used by the server-side settlement call, but live Link-based buyer flows depend on Stripe availability for your buyer and seller accounts.
To get a profile id:
- Open Stripe profile in the Stripe Dashboard.
- Create a profile for your business.
- Use the resulting
profile_...value asSTRIPE_NETWORK_ID.
Stripe SPT support uses preview APIs. Build against test mode first, pin the Stripe API version, and review Stripe and package changelogs before upgrading. Test mode works wherever your account is based.
Testing Stripe End to End
In development, you can mint a test SPT yourself. This lets you drive the full 402 -> mint SPT -> retry -> 200 loop without Link.
A single test account works: the same sk_test_... key can mint the SPT and settle it. We guide a two-account setup instead, because separate accounts match production conditions more closely.
- Seller account: the Laravel app's
STRIPE_SECRET_KEY. This account creates and confirms the PaymentIntent. - Buyer account: a different
sk_test_...key used only to mint the test SPT. It stands in for the buyer wallet that issues the SPT in production.
First request the challenge and copy its challengeId and Stripe accept sig:
curl -s https://your-host/resource
{
"type": "https://paymentauth.org/problems/payment-required",
"title": "Payment Required",
"status": 402,
"detail": "Payment is required to access this resource.",
"challengeId": "chal_...", # We'll need this for later
"accepts": [
{
"method": "stripe",
"amount": "1.50",
"currency": "USD",
"network_id": "profile_test_...",
"payment_method_types": [
"card"
],
"grants": 1,
"scope": "report.basic",
"expiresAt": "2026-06-30T11:12:36Z",
"sig": "03ce60d5..." # We need this one also
}
]
}
Mint a test SPT for a $1.00 challenge:
curl -s -u "sk_test_buyer_...:" -H "Stripe-Version: 2026-05-27.preview" \ -X POST https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens \ -d payment_method=pm_card_visa \ -d "usage_limits[currency]=usd" \ -d "usage_limits[max_amount]=100" \ -d "usage_limits[expires_at]=$(($(date +%s)+300))"
{
"id": "spt_...", # Note this value also
"object": "shared_payment.granted_token",
...
"usage_limits": {
"currency": "usd",
"expires_at": 1782818057,
"max_amount": 1000
}
}
Replay the original request with the token:
curl -si https://your-host/resource \
-H 'Authorization: Payment method="stripe", challengeId="chal_...", sig="...", spt="spt_..."'
The response should be 200 OK and include a Payment-Receipt header:
Payment-Receipt: id="rcpt_...", challengeId="chal_...", method="stripe", amount="1.00", currency="USD", ref="pi_...", settledAt="..."
The ref value is the Stripe PaymentIntent id.
Cards have minimum charge amounts, often around $0.50 or EUR 0.50. Price card-backed routes above the minimum, or use a metered bundle where the single charge clears it.
Per-Payer Stripe Customers
By default, Stripe payments are guest charges. Set methods.stripe.customer_resolver to attach a seller-account Stripe Customer to the PaymentIntent when the paid retry already carries an identity you trust, such as an authenticated user or API key.
When implementing a customer resolver, attach it to the config:
// config/mpp.php 'methods' => [ 'stripe' => [ 'customer_resolver' => [\App\Mpp\StripeCustomerResolver::class, 'resolve'], ], ],
namespace App\Mpp; use Illuminate\Http\Request; class StripeCustomerResolver { public function resolve(Request $request): ?string { return $request->user()?->stripe_customer_id; } }
The resolver should return a cus_... id from the same Stripe account as STRIPE_SECRET_KEY. It runs on the paid retry, so any identity it uses must be present on that retry. For an API-key workflow, resolve the key to one of your own accounts and return that account's Stripe Customer id.
For open agent-payment endpoints, guest PaymentIntents plus metadata are often the right shape: the SPT proves payment authority, not a stable seller-side customer.
If the resolver returns null or throws, the package falls back to a guest charge.
Tempo
Tempo settlement accepts pathUSD from the stock npx mppx client. The agent signs a pathUSD transfer and pays gas. Your server broadcasts the signed transaction and confirms that it mined.
TEMPO_RECIPIENT=0x... TEMPO_RPC_URL=https://rpc.moderato.tempo.xyz TEMPO_CHAIN_ID=42431 TEMPO_TOKEN=0x20c0000000000000000000000000000000000000 TEMPO_DECIMALS=6
TEMPO_RECIPIENT is required. The RPC URL, chain id, token, and decimals default to Tempo testnet values.
Protect a route with Tempo:
Route::get('/paid', fn () => response()->json(['data' => 'paid'])) ->middleware('mpp:0.01,USD,method=tempo,scope=paid');
Pay it with mppx:
npx mppx https://your-host/paid --network testnet --account <your-account>
Testing Tempo End to End
Use this flow when you want to see a real Tempo testnet transfer land in a recipient address.
Create a temporary recipient address with Foundry. Install Foundry with foundryup, then create a local test wallet with cast wallet new:
foundryup cast wallet new
Copy the generated address value and use it as TEMPO_RECIPIENT in the Tempo configuration above. This address receives the testnet payment, so keep the generated private key only if you plan to reuse or move funds from it.
TEMPO_RECIPIENT=0x...
Add a low-value test route:
use Illuminate\Support\Facades\Route; Route::get('/tempo-test', fn () => response()->json([ 'paid' => true, 'at' => now()->toIso8601String(), ]))->middleware('mpp:0.01,USD,method=tempo,scope=tempo.test');
Pay the route with a funded mppx testnet account:
npx mppx https://your-host/tempo-test --network testnet --account <your-account>
The successful response includes a Payment-Receipt header. Its ref value is the transaction hash:
Payment-Receipt: id="rcpt_...", challengeId="chal_...", method="tempo", amount="0.01", currency="USD", ref="0x...", settledAt="..."
View the recipient address in the Tempo testnet explorer:
https://explore.testnet.tempo.xyz/address/0x...
Replace 0x... with the address you set as TEMPO_RECIPIENT. The explorer should show the incoming pathUSD transfer after the transaction is mined.
Tempo uses the mppx wire format, which a 402 cannot carry at the same time as Stripe's. To serve both rails from one URL, choose the rail per request - see Can One Route Offer Both Rails?.
Can One Route Offer Both Rails?
Not in a single 402 unfortunatey.
A 402 is a quote. It has to state a price in something the caller can actually pay, and the two rails state that in different places. Stripe uses the native format: the response body carries a list of signed offers. Tempo speaks mppx, where the terms are a signed blob in the WWW-Authenticate header and the body carries no offer list at all. One HTTP response has one body and one set of headers, so it can speak one format or the other. An agent that understands Stripe finds nothing actionable in a Tempo challenge, and an mppx agent finds nothing actionable in a Stripe one.
So the decision of which rail to quote happens before the challenge is minted, which means the server has to know something about the caller first.
That is content negotiation, and the web has solved it before. When WebP was new, servers could not tell which browsers could decode it, so browsers began sending Accept: image/webp and servers used that to choose a representation. Identical shape of problem: the server must commit to one format up front, and only the client knows what it can consume.
There is no standard header for payment-rail capability yet. Until there is, define one for your API and document it for the agents that call you:
GET /resource HTTP/1.1 X-Supports: tempo, stripe
Then choose the rail before handing off to the MPP middleware:
namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Square1\Mpp\Http\Middleware\RequirePayment; use Symfony\Component\HttpFoundation\Response; class ChooseRail { public function __construct(private readonly RequirePayment $mpp) {} public function handle(Request $request, Closure $next): Response { $supports = array_map('trim', explode(',', (string) $request->header('X-Supports'))); // Quote the first rail we both understand; fall back to the house default. $spec = in_array('tempo', $supports, true) ? '0.01,USD,method=tempo,scope=resource' : '0.50,USD,method=stripe,scope=resource'; return $this->mpp->handle($request, $next, ...explode(',', $spec)); } }
Route::get('/resource', MyPaidResource::class) ->middleware(\App\Http\Middleware\ChooseRail::class);
A caller that sends nothing gets your default rail. Each challenge is bound to the rail it was minted for, so switching rails always means a fresh quote, which the package enforces for you.
Protecting Routes
You can protect routes with middleware arguments, controller attributes, or automatic attribute enforcement.
Middleware
Route::get('/resource', MyPaidResource::class) ->middleware('mpp:0.50,USD'); Route::get('/report', ReportController::class) ->middleware('mpp:5.00,USD,grants=10,scope=report.basic');
You can also reference a price book entry by key:
Route::get('/report', ReportController::class) ->middleware('mpp:report.basic');
Attribute Plus Middleware
use Square1\Mpp\Attributes\RequiresPayment; class ReportController { #[RequiresPayment(amount: '5.00', currency: 'USD', grants: 10, scope: 'report.basic')] public function __invoke() { // ... } } Route::get('/report', ReportController::class)->middleware('mpp');
Automatic Attribute Enforcement
Enable the attribute enforcer:
MPP_ATTRIBUTES_ENABLED=true
Then attributed controller actions are enabled without adding mpp to each route:
#[RequiresPayment(amount: '0.50', currency: 'USD')] public function latest() { // ... }
Automatic enforcement is disabled by default. It runs on the configured route groups, web and api by default. Routes already carrying the mpp middleware are skipped, so they are not charged twice.
Payment Options
| Option | Middleware | Attribute |
|---|---|---|
| Price and currency | mpp:0.50,USD |
amount: '0.50', currency: 'USD' |
| One charge per request | grants=1 |
grants: 1 |
| One charge for N accesses | grants=10 |
grants: 10 |
| Scope | scope=report.basic |
scope: 'report.basic' |
| Settlement rail | method=tempo |
method: 'tempo' |
| Price per request | pricing=tiered |
pricing: ['tiered'] |
| Preconditions | preconditions=postexists |
preconditions: ['postexists'] |
scope is a label you choose for the priced resource. Metered sessions are locked to their scope. If you omit it, the package derives one from the route URI.
When you list several methods, the first one is the primary. It sets the dialect of the challenge and is the default method on a paid retry that omits one.
Defaults
Use defaults to avoid repeating price or rail settings:
MPP_DEFAULT_METHOD=tempo MPP_DEFAULT_AMOUNT=0.01 MPP_DEFAULT_CURRENCY=USD MPP_DEFAULT_GRANTS=1
Route::get('/report', ReportController::class) ->middleware('mpp:scope=report'); #[RequiresPayment(scope: 'resource')] public function latest() { // Amount, currency, grants, and method come from config. }
Leave MPP_DEFAULT_AMOUNT unset if every protected route should declare its own price.
Metered Access
Set grants above 1 when one payment should grant multiple accesses:
Route::get('/report', ReportController::class) ->middleware('mpp:5.00,USD,grants=10,scope=report.basic');
The paid request spends the first credit and returns a Payment-Session header:
HTTP/1.1 200 OK Payment-Receipt: id="rcpt_...", method="stripe", amount="5.00", currency="USD", ref="pi_..." Payment-Session: id="sess_...", remaining="9", scope="report.basic", expiresAt="..."
Reuse the session on later requests:
curl -si https://your-host/report \
-H 'Authorization: Payment method="stripe", session="sess_..."'
Each successful request decrements the balance and returns the updated Payment-Session header. When the session is exhausted or expired, the next request receives a fresh 402.
Session spends are scope-checked and atomic. Concurrent requests cannot spend more credits than the session was granted.
Metering works the same on both rails. A Tempo payment for a metered route also issues a session, reused with the same Authorization: Payment ..., session="sess_..." header shown above.
Dynamic Pricing
Everything above prices a route. Sometimes the price belongs to the request: a pro account pays $2.50 where a free account pays $5.00, a partner gets a bigger bundle for the same money, a caller in another region pays in another currency.
A price resolver decides that per request. Register it once, name it on the routes it applies to, and the resolved price is what gets minted into the 402:
// config/mpp.php 'pricing' => [ 'resolvers' => [ 'tiered' => [\App\Mpp\Pricing\TieredPrice::class, 'price'], ], // Apply to every gated route, before any route-specific resolvers. 'global' => [], ],
namespace App\Mpp\Pricing; use Illuminate\Http\Request; use Square1\Mpp\Payment\PaymentSpec; class TieredPrice { /** @return array<string, mixed>|null */ public function price(Request $request, PaymentSpec $spec): ?array { return match ($request->user()?->tier) { 'pro' => ['amount' => '2.00'], 'partner' => ['amount' => '2.00', 'grants' => 20, 'scope' => 'report.partner'], 'staff' => ['free' => true], default => null, // leave the route's own price alone }; } }
Attach it like any other option:
// $5 is the LIST PRICE - what a caller pays when `tiered` returns null. // Recognised tiers are discounts off it. Route::get('/report', ReportController::class) ->middleware('mpp:5.00,USD,scope=report,pricing=tiered'); #[RequiresPayment(amount: '5.00', scope: 'report', pricing: ['tiered'])] public function show() { /* ... */ }
// Or on a price_book entry, so every route using the entry inherits it. 'price_book' => [ 'report.basic' => ['amount' => '5.00', 'currency' => 'USD', 'pricing' => ['tiered']], ],
Who Owns the Price
One rule: something has to supply a price before the gate - the route or a resolver.
Which one is yours to choose, per route, by whether you write an amount:
// The route owns the default price; the custom resolver may overwrite it. ->middleware('mpp:5.00,USD,scope=report,pricing=tiered'); // The resolver fully owns responsibility for the price. ->middleware('mpp:scope=report,pricing=tiered');
Write the amount when a list price is a real thing your endpoint has. It is then the price for every caller the resolver doesn't recognise, and the price you fall back to if the resolver is later disabled. Leave it out when there is no list price to state, as with usage-based or per-item pricing, rather than inventing a placeholder that nothing reads.
On a resolver-owned route, if every resolver declines, the request cannot be priced and raises UnpriceableRequestException, naming the route and the resolvers that ran. It is a distinct exception from InvalidConfigurationException on purpose. The configuration is fine, and what went wrong depends on the request, so it can recur in production long after a deploy rather than surfacing once at boot.
If mpp.defaults.amount is set, every route has a house price and this case can't arise. In this case, a declining resolver falls back to this default.
What a Resolver May Change
A resolver is a plain class with one method. There is no base class to extend and no special casing: you return an array and the package reads it. One return can set any of these together - it is not limited to the amount.
| Key | Effect |
|---|---|
amount |
The price. Must be a positive number. |
currency |
The currency code, upper-cased for you. |
grants |
Accesses per payment. > 1 issues a metered session. |
scope |
The label the payment and any session are bound to. |
free |
true serves the route without charging. |
Anything else - including method - throws InvalidConfigurationException. Which rail a route uses is resolved once, from the route's own method= and the configured default, and is not a resolver's to change: a resolver sets the price, not the payment terms around it. To vary the rail per request, choose it before the middleware runs - see Can One Route Offer Both Rails?.
A zero, negative, or non-numeric amount also throws. Giving a resource away has to be confirmed very explicitly, so a resolver that miscalculates, or reads an empty config value, fails loudly instead of quietly making a paid endpoint free.
return ['free' => true]; // yes, serve this one for nothing return ['amount' => '0']; // throws
free => true and an amount together throw for the same reason: which one you meant should never be a guess. A free request skips the challenge, the session, and the receipt entirely - it is served like an unguarded route - but its preconditions still run, so a free caller cannot reach a resource a check would have refused them.
Every shape from one method, on a route declaring mpp:9.00,USD,grants=3,scope=everything.list:
public function price(Request $request, PaymentSpec $spec): ?array { return match ($request->user()?->tier) { // Just the price. Everything else on the route stands. 'pro' => ['amount' => '2.00'], // Price, currency, bundle size and credit pool, all at once. 'partner' => [ 'amount' => '18.00', 'currency' => 'EUR', 'grants' => 25, 'scope' => 'everything.partner', ], // No charge. Same method, same return type - `free` is just another key. // No 'amount' alongside it: the pair throws. 'staff' => ['free' => true], // No opinion. NOT free: the route's own price stands. default => null, }; }
| Caller | Result |
|---|---|
unrecognised (null) |
402 - 9.00 USD, grants 3, scope everything.list |
pro |
402 - 2.00 USD, grants 3, scope everything.list |
partner |
402 - 18.00 EUR, grants 25, scope everything.partner |
staff |
200 - served, no challenge |
The distinction to hold on to: null is "no opinion", not "no charge". Waiving is always ['free' => true]. On a route that states no price of its own, that difference decides between a served request and an exception.
Composition
Resolvers compose like preconditions. Globals run first, then the route's own, in declared order, de-duplicated. Each one receives the spec as the previous one left it, so a later resolver can build on an earlier one:
->middleware('mpp:5.00,USD,pricing=tiered|regional')
Here regional sees the tier-adjusted amount, not the route's default $5. An unknown name throws rather than falling back to the static price, so a typo can't quietly charge everyone list price.
Pricing and Metered Sessions
Metered sessions are bound to a scope, not to a payer. A session is a bearer credit balance: whoever holds the id can spend it on that scope.
So if a metered route's price varies, vary its scope too:
'partner' => ['amount' => '2.00', 'grants' => 20, 'scope' => 'report.partner'],
Without that, credits bought at $2 are spendable by any bearer on the same scope, including one who should have paid $5. The package logs a warning when a resolver reprices a metered route without changing its scope - once per scope per process, so once per request under PHP-FPM and once per worker under Octane. Once-off routes (grants = 1) never issue a session and are unaffected.
The Quote Is Binding
A resolver decides the price of a challenge, not of a settlement. The amount is HMAC-signed into the 402 and settlement verifies against that stored challenge - never against a freshly-resolved spec. So a resolver whose answer changes between the 402 and the paid retry cannot change what that buyer was quoted:
402 → amount="2.00" (caller was on the pro tier)
... their subscription lapses ...
retry → settles at 2.00, receipt says 2.00
The same holds in the other direction: a resolver that turns free after issuing a 402 cannot burn or settle that challenge, and a resolver that raises the price cannot charge an outstanding quote more than it promised. Only new challenges get the new price.
Resolvers run on every gated request, paid retries and session spends included, so keep them cheap and side-effect free - they are not the place to write an audit record. The resolved amount is ignored on those requests, but the resolved scope is not: a session is spent against the scope the resolver returns now. If a caller's tier changes while they hold credits, their session stops matching and they get a fresh 402. Keep a tier's scope stable for as long as its sessions can live (MPP_SESSION_TTL), or key the scope on something that outlives the tier.
Preconditions
The payment gate runs before your controller. On a paid retry it settles the payment and then calls the controller, so a 404 raised inside the controller comes after the buyer has already paid. And the first, unpaid request to a missing resource returns a 402, which tells an agent to pay for something that does not exist.
Preconditions close that gap. A precondition is a named check that runs before a 402 is minted or a payment settled. It returns a response to reject the request (a 404 for a missing resource, a 403 for a blocked user) or null to let the request proceed to the gate. Anything that decides whether a request can ever be fulfilled belongs here, not in the controller.
Define checks once in config, then attach them where they apply. Each check is a [Class::class, 'method'] pair, resolved through the container (so it stays config:cache-safe), called with the request and the resolved PaymentSpec:
// config/mpp.php 'preconditions' => [ 'checks' => [ 'postexists' => [\App\Mpp\Checks\PostExists::class, 'check'], 'usernotblocked' => [\App\Mpp\Checks\UserNotBlocked::class, 'check'], ], // Run on every gated route, before any route-specific checks. 'global' => ['usernotblocked'], ],
namespace App\Mpp\Checks; use App\Models\Post; use Illuminate\Http\Request; use Square1\Mpp\Payment\PaymentSpec; use Symfony\Component\HttpFoundation\Response; class PostExists { public function check(Request $request, PaymentSpec $spec): ?Response { return Post::find($request->route('post')) ? null : response()->json(['error' => 'No such post.'], 404); } }
Attach route-specific checks the same way as other arguments, pipe-separated and ordered, on the middleware or the attribute:
Route::get('/posts/{post}', ShowPost::class) ->middleware('mpp:1.00,USD,scope=post.view,preconditions=postexists'); #[RequiresPayment(amount: '1.00', scope: 'post.view', preconditions: ['postexists'])] public function show() { /* ... */ }
Checks are additive and composed in order: the global checks run first, then the route's own, de-duplicated. The first check that returns a response wins, and the rest do not run, so a global usernotblocked short-circuits before a route's postexists ever fires. A name that is not defined in checks throws InvalidConfigurationException, so a typo fails closed rather than silently skipping a check.
Checks run on every guarded route, however it was declared - middleware arguments, mpp plus an attribute, or an attribute enforced automatically. The PaymentSpec they receive has already been through any price resolvers, so $spec->amount is the price this request will actually be charged, not the route's static one. A check can use that: refuse a purchase above a caller's spending cap, for instance.
If a request can only be judged after settlement, you have to refund instead, which is worse for the buyer and rail-specific. Prefer a precondition wherever existence or eligibility can be determined up front.
Session Storage
A metered route (grants > 1) issues a session, which is a prepaid credit balance the server keeps between requests. The agent holds only the session id; the server holds the remaining count and decrements it on each request, so that balance has to be stored somewhere. Once-off routes (grants = 1) never create a session, so you only need a session store if you use metered access.
The default driver is cache:
MPP_SESSION_DRIVER=cache
The cache driver uses your app's default cache store unless MPP_SESSION_CACHE_STORE is set, so a Redis-backed application keeps sessions in Redis automatically. Point it at a persistent, shared store. A per-server or memory-only cache can evict a balance early or hide it from other workers, which would cut a buyer's paid-for access short.
Use the database driver when you want balances to survive cache eviction and restarts, or to share them across app servers without a shared cache:
MPP_SESSION_DRIVER=database MPP_SESSION_DB_CONNECTION=
The migration creates the mpp_sessions table that holds those balances. It is the only reason the migration exists, and you need it only with the database driver:
php artisan vendor:publish --tag=mpp-migrations php artisan migrate
Configuration
The main settings live in config/mpp.php.
| Key | Purpose |
|---|---|
secret |
Challenge signing key. Defaults to a key derived from APP_KEY when unset. |
challenge_ttl |
Challenge lifetime in seconds. Default: 300. |
session_ttl |
Metered session lifetime in seconds. Default: 3600. |
default_method |
Primary settlement method. Default: stripe. |
defaults.amount |
Global price fallback. Leave null to require each route to set a price. |
defaults.currency |
Global currency fallback. Default: USD. |
defaults.grants |
Global grants fallback. Default: 1. |
methods.stripe.* |
Stripe verifier settings. |
methods.tempo.* |
Tempo verifier settings. |
sessions.* |
Metered session storage settings. |
attributes.enabled |
Enables automatic #[RequiresPayment] enforcement. Default: false. |
attributes.middleware_groups |
Route groups used by automatic attribute enforcement. Default: ['web', 'api']. |
price_book |
Named pricing presets. |
pricing.resolvers |
Named [Class::class, 'method'] price resolvers, keyed by the name routes reference. |
pricing.global |
Resolvers applied to every gated route, before route-specific ones. |
preconditions.checks |
Named [Class::class, 'method'] checks, keyed by the name routes reference. |
preconditions.global |
Checks run on every gated route, before route-specific ones. |
Price Book
Price book entries let you name common prices:
'price_book' => [ 'report.basic' => ['amount' => '5.00', 'currency' => 'USD', 'grants' => 10], ],
Route::get('/report', ReportController::class) ->middleware('mpp:report.basic');
The key also becomes the default scope.
An entry can also carry its own preconditions and pricing lists, so every route using it inherits them:
'price_book' => [ 'report.basic' => [ 'amount' => '5.00', 'currency' => 'USD', 'grants' => 10, 'pricing' => ['tiered'], 'preconditions' => ['usernotblocked'], ], ],
Either list may be written as an array or pipe-separated ('tiered|regional'). A route that names its own pricing= or preconditions= replaces the entry's list rather than adding to it.
Configuration Validation
The gate checks built-in rail configuration before it mints a challenge.
| Rail | Missing config | Result |
|---|---|---|
Stripe secret_key |
Settlement cannot run. | Logs once, still emits 402. |
Stripe network_id |
Link or agent wallets cannot scope an SPT to you. | Logs once, still emits 402. |
Tempo recipient, token, or chain_id |
The challenge would be unpayable or unsafe. | Throws InvalidConfigurationException. |
Tempo rpc_url |
Settlement cannot broadcast the transaction. | Logs once, still emits 402. |
Custom verifiers are responsible for their own configuration validation.
Testing
The local test suite uses Pest:
composer test
composer lint
Live Stripe tests self-skip unless a test key is present:
STRIPE_SECRET_KEY=sk_test_... vendor/bin/pest --group=stripe
Cross-account Stripe tests need two different test accounts:
STRIPE_BUYER_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_... vendor/bin/pest --group=stripe-cross
Advanced Usage
Custom Native Verifiers
A native rail implements Square1\Mpp\Settlement\Verifier.
The paid retry presents a proof value. Your verifier must check that proof against the rail's own source of truth and return success only when the settled amount and currency match the signed challenge.
namespace App\Mpp; use Square1\Mpp\Protocol\Challenge; use Square1\Mpp\Protocol\Credential; use Square1\Mpp\Settlement\SettlementResult; use Square1\Mpp\Settlement\Verifier; use Square1\Mpp\Support\Money; final class AcmePayVerifier implements Verifier { public function __construct(private readonly AcmePayClient $acme) {} public function verify(Credential $credential, Challenge $challenge, array $context = []): SettlementResult { $chargeId = $credential->proof; if ($chargeId === null || $chargeId === '') { return SettlementResult::failure('No AcmePay charge id presented.'); } try { $charge = $this->acme->getCharge($chargeId); } catch (\Throwable $e) { return SettlementResult::failure('AcmePay lookup failed: '.$e->getMessage()); } $expectedMinor = Money::toMinorUnits($challenge->amount, $challenge->currency); if ($charge->status !== 'succeeded' || $charge->amountMinor !== $expectedMinor || strtoupper($charge->currency) !== strtoupper($challenge->currency)) { return SettlementResult::failure('AcmePay charge does not match the challenge.'); } return SettlementResult::settled( settlementRef: $charge->id, amountMinor: $expectedMinor, currency: $challenge->currency, ); } }
Register and offer it:
'methods' => [ 'acme' => [ 'verifier' => \App\Mpp\AcmePayVerifier::class, 'payment_method_types' => ['acme'], ], ],
Then use it on a route with method=acme, or make it the house rail with MPP_DEFAULT_METHOD=acme.
The gate already checks that the challenge exists, is unexpired, was offered for the method, and has a valid signature. It also burns successful challenges and serializes concurrent settlement attempts. If your rail supports idempotency keys, use the challenge id.
Wire Format
Most implementors do not need to build these headers by hand, but they are useful for debugging.
Native unpaid response:
HTTP/1.1 402 Payment Required WWW-Authenticate: Payment id="chal_...", method="stripe", amount="0.50", currency="USD", network_id="profile_...", grants="1", scope="resource", expires_at="...", sig="..." Content-Type: application/problem+json Cache-Control: no-store { "type": "https://paymentauth.org/problems/payment-required", "title": "Payment Required", "status": 402, "challengeId": "chal_...", "accepts": [ { "method": "stripe", "amount": "0.50", "currency": "USD", "network_id": "profile_...", "grants": 1, "scope": "resource", "expiresAt": "...", "sig": "..." } ] }
Native paid retry:
Authorization: Payment method="stripe", challengeId="chal_...", sig="...", spt="spt_..."
Custom native rails use proof instead of spt:
Authorization: Payment method="acme", challengeId="chal_...", sig="...", proof="charge_..."
Metered follow-up:
Authorization: Payment method="stripe", session="sess_..."
Tempo uses the separate mppx format emitted and consumed by the mppx client.
Security Notes
- Challenges are HMAC-signed over the payment terms and expiry.
- A paid retry must echo the signature for the selected method.
- A dynamically resolved price binds at mint time. Settlement verifies against the stored challenge, so re-resolving cannot change what a buyer was quoted.
- Waiving a charge must be explicit (
free => true); a zero or unparseable resolved amount throws rather than serving free. - Challenges are burned after successful settlement.
- Stripe settlement is trusted only after a succeeded PaymentIntent matching the challenge amount and currency.
- Tempo settlement is trusted only after the signed transfer pays the challenged token, amount, and recipient, and the transaction is confirmed.
- Metered sessions are scope-checked and decremented atomically.
- The challenge signing key and Stripe secret key stay server-side.
Octane and FrankenPHP
The package is safe under long-lived workers. Request-specific state is passed per call rather than stored on singletons.
Reload workers after changing MPP_CHALLENGE_SECRET, TTLs, Stripe keys, or Tempo config. Tempo settlement blocks while it polls for a receipt, up to poll_attempts * poll_delay_ms.
License
This package is released under the MIT License. See LICENSE.md.
MPP and Stripe SPT APIs may change while preview APIs are involved. Pin package versions and review the changelog when upgrading.