Bekzod Erkinov
Listen to Article
Loading...Table of contents · 10 sections
Laravel 12 E-Commerce, Part 7: Test Pyramid, Security Hardening & PCI DSS
Laravel's CSRF middleware switches itself off under php artisan test, so a checkout can pass every test and still return 419 to every real Stripe webhook. And whether you file PCI DSS SAQ A or SAQ A-EP depends on who produces the page the card is typed into, not on whether card numbers touch your server.
This is Part 7 of the series; the store itself is built in Part 2 (catalog and cart) and Part 3 (Stripe payments, webhooks & order lifecycle). This part is self-contained: it builds a three-layer test suite, security headers, a rate limit, a webhook handler and a k6 smoke test on a throwaway app, then maps Stripe integrations to SAQs from the PCI SSC documents. Everything was run on 2026-10-07 (PHP 8.4.26, Laravel 12.69.3, Dusk 8.7.1, Chromium and ChromeDriver 154.0.8037.92, k6 v2.3.0, all read from the lab container); nothing was run against live Stripe.
Decision rule for the PCI section (sources below):
| You do this | Questionnaire | Script criterion (2025) |
|---|---|---|
| Redirect to a hosted page (Stripe Checkout) | SAQ A | Doesn't apply |
| Embed the provider's iframe (Stripe Elements) | SAQ A | Applies (FAQ 1588) |
| Your own JS or form fields posting card data | SAQ A-EP or worse | 6.4.3 / 11.6.1 are questions you answer |
The setup: a tiny store you can rebuild from this page
The lab is a Dockerfile (Dockerfile.p7) with everything Dusk needs:
FROM php:8.4-cli
RUN echo 'Acquire::ForceIPv4 "true";' > /etc/apt/apt.conf.d/99v4 \
&& apt-get update && apt-get install -y chromium chromium-driver git unzip libzip-dev \
&& docker-php-ext-install zip pcntl \
&& php -r "copy('https://getcomposer.org/installer','/tmp/cs.php');" \
&& php /tmp/cs.php --install-dir=/usr/local/bin --filename=composer
ENV COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app
docker network create p7net
docker build -f Dockerfile.p7 -t p7lab .
docker run -d --name p7lab --network p7net p7lab sleep infinity
docker exec p7lab sh -c 'composer create-project laravel/laravel shop "^12.0" && cd shop && composer require --dev laravel/dusk'
The ForceIPv4 line is there because the Debian mirror was unreachable over IPv6 on my network. The tests depend on two environment settings:
.env:APP_URL=http://p7lab:8000(Dusk visits this URL) andDB_CONNECTION=sqlite.phpunit.xml(the Laravel 12 default, unchanged):CACHE_STORE=array,DB_CONNECTION=sqlite,DB_DATABASE=:memory:.
Money lives in app/Support/Cart.php, in integer cents with the tax rate in basis points:
<?php
namespace App\Support;
use InvalidArgumentException;
final class Cart
{
/** @param array<int, array{price_cents:int, qty:int}> $lines */
public function __construct(private array $lines = [], private int $taxBps = 0) {}
public function subtotal(): int
{
return array_sum(array_map(function ($l) {
if ($l['qty'] < 1 || $l['price_cents'] < 0) {
throw new InvalidArgumentException('bad line');
}
return $l['price_cents'] * $l['qty'];
}, $this->lines));
}
public function tax(): int
{
return intdiv($this->subtotal() * $this->taxBps + 5000, 10000); // round half up
}
public function total(): int { return $this->subtotal() + $this->tax(); }
}
POST /checkout validates product_id and qty and reads the price from the database; it never accepts a price from the request. The limiter is registered in app/Providers/AppServiceProvider.php, per the Laravel 12 docs:
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
RateLimiter::for('checkout', fn (Request $r) => Limit::perMinute(5)->by($r->ip()));
}
}
And the complete routes/web.php, including the webhook handler discussed below. The handler is demo-only: I hand-rolled the check so the test runs without an API key, and it does not dedupe on event.id. Don't copy it; the production version is below. The hand-rolled version follows Stripe's documented signature format: t=<timestamp>,v1=<HMAC-SHA256 of "{t}.{payload}">, compared with hash_equals, rejecting timestamps older than 300 seconds.
<?php
use App\Models\{Order, Product};
use App\Support\Cart;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('shop', ['products' => Product::all()]));
// Price comes from the DB, never from the request.
Route::post('/checkout', function (Request $r) {
$data = $r->validate([
'product_id' => ['required', 'integer', 'exists:products,id'],
'qty' => ['required', 'integer', 'between:1,10'],
]);
$p = Product::findOrFail($data['product_id']);
$total = (new Cart([['price_cents' => $p->price_cents, 'qty' => $data['qty']]], 825))->total();
$order = Order::create(['product_id' => $p->id, 'qty' => $data['qty'], 'total_cents' => $total]);
return response()->json(['order' => $order->id, 'total_cents' => $total], 201);
})->middleware('throttle:checkout')->name('checkout');
Route::post('/webhooks/stripe', function (Request $r) {
$secret = config('services.stripe.webhook_secret');
// A missing secret is a deploy mistake: fail loudly instead of a TypeError inside hash_hmac().
abort_if(! $secret, 500, 'webhook secret not configured');
// Header format per Stripe docs: t=<timestamp>,v1=<hmac_sha256("{t}.{payload}")>
parse_str(str_replace(',', '&', (string) $r->header('Stripe-Signature')), $sig);
$t = $sig['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $r->getContent(), $secret);
abort_unless($t !== '' && hash_equals($expected, $sig['v1'] ?? '') && abs(time() - (int) $t) <= 300, 400);
$event = $r->json()->all();
if (($event['type'] ?? '') === 'payment_intent.succeeded') {
// Stripe retries and can redeliver: only pending orders become paid, so a repeat is a no-op.
Order::where('payment_intent_id', $event['data']['object']['id'])
->where('status', 'pending')
->update(['status' => 'paid']);
}
return response()->noContent();
})->name('webhooks.stripe');
The files not shown in the body are in the appendix.
For production, let the Stripe library verify the signature and dedupe on the event id. This sketch was not run (it needs stripe/stripe-php and a real secret); processed_events is a table you create with a unique event_id column:
Route::post('/webhooks/stripe', function (Request $r) {
try {
$event = \Stripe\Webhook::constructEvent(
$r->getContent(), (string) $r->header('Stripe-Signature'), config('services.stripe.webhook_secret'));
} catch (\UnexpectedValueException|\Stripe\Exception\SignatureVerificationException) {
abort(400);
}
// insertOrIgnore returns 0 when this event id was already handled.
if (DB::table('processed_events')->insertOrIgnore(['event_id' => $event->id]) === 0) {
return response()->noContent();
}
// ... handle $event->type here
return response()->noContent();
});
Laravel testing pyramid: unit vs feature vs Dusk
| Layer | Boots Laravel? | Put here | Cost (measured here) |
|---|---|---|---|
| Unit | No (PHPUnit\Framework\TestCase) |
Money math, rounding, pure rules | 0.02 s |
| Feature | Yes, in-memory SQLite | Validation, CSRF, rate limits, headers, webhook | 0.03-0.15 s each; the first tests pay ~1 s of bootstrap |
| Browser (Dusk) | Yes, plus real Chromium and a running server | One happy path per money-moving screen, plus the CSP check | 4.5-12 s for one test (Docker Desktop on Windows, noisy) |
Unit tests are where money bugs live. 5997 * 825 bps is 494.75 cents; the test in tests/Unit/CartTest.php (complete file in the appendix) pins half-up rounding so nobody "fixes" it to truncation: tax must be 495, total 6492.
Laravel CSRF testing gotcha: your tests don't run CSRF
The Laravel CSRF docs cover the middleware and its except list, not the test-time shortcut. In the 12.x source (Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php, as installed in 12.69.3), handle() lets the request through when any of these is true:
$this->isReading($request) ||
$this->runningUnitTests() ||
$this->inExceptArray($request) ||
$this->tokensMatch($request)
and runningUnitTests() is $this->app->runningInConsole() && $this->app->runningUnitTests(), always true under php artisan test. A POST without a token gets 201, not 419, so a "CSRF is enabled" test either passes trivially or never gets written.
The fix is to bind a subclass that turns the shortcut off. Laravel 12's web group uses ValidateCsrfToken, an empty subclass of VerifyCsrfToken, so that is the class to bind; binding VerifyCsrfToken has no effect:
private function enforceCsrf(): void
{
$this->app->bind(ValidateCsrfToken::class, fn ($app) => new class($app, $app['encrypter']) extends ValidateCsrfToken {
protected function runningUnitTests() { return false; }
});
}
Stripe webhook and CSRF: a missing exemption is invisible to tests
Laravel's docs already tell you to exempt webhook routes, because Stripe can't send a CSRF token. The non-obvious part is that forgetting the exemption can't fail any default test, since the harness skips CSRF. To see what production would do, I called enforceCsrf() in the webhook test and deleted validateCsrfTokens(except: ['webhooks/*']) from bootstrap/app.php on purpose:
⨯ webhook rejects bad signature and accepts good one
Expected response status code [400] but received 419.
With the exemption gone, every real webhook would be rejected. The bootstrap/app.php middleware block as it ships in this lab (add use App\Http\Middleware\SecurityHeaders; at the top; the lab file uses the fully qualified name):
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['webhooks/*']);
$middleware->web(append: [SecurityHeaders::class]);
})
A signature check is half of Stripe's requirement. The integration security guide says: "You must also verify webhook signatures and allowlist Stripe's IP addresses to ensure that every Stripe webhook you receive is sent exclusively by Stripe." The list is on the IPs page. The IP half belongs at your firewall, load balancer or WAF, not in this route. Stripe publishes the list as text and JSON (https://stripe.com/files/ips/ips_webhooks.txt), and the IPs page says: "You can subscribe to the API announce mailing list to be notified of any changes to our IP addresses. We provide seven days’ notice through that mailing list before making changes." I did not test IP filtering here.
Stripe retries events that don't get a 2xx and can redeliver ones that did, so the handler must tolerate repeats. The 300-second window only limits how old a replayed request can be; it doesn't dedupe. The demo guards the update with where('status', 'pending'), and a test redelivers a signed event after a refund and asserts the status stays refunded; the production sketch above also records event.id.
The full run, re-executed for this revision with the exemption restored (the last test checks that an unconfigured secret returns 500):
$ php artisan test
PASS Tests\Unit\CartTest
✓ total is integer cents with tax rounded half up 0.01s
✓ zero quantity is rejected
PASS Tests\Feature\CheckoutSecurityTest
✓ post without csrf token is 419 0.99s
✓ client cannot set price 0.10s
✓ qty out of range is 422 0.06s
✓ checkout is rate limited to 5 per minute 0.09s
✓ security headers present 0.04s
✓ webhook rejects bad signature and accepts good one 0.59s
✓ redelivered webhook does not overwrite a later status 0.03s
✓ webhook without configured secret is 500 0.02s
Tests: 10 passed (36 assertions)
Duration: 2.12s
The other feature tests assert (full source in the appendix): posting price_cents=1 and total_cents=1 still stores 3248 (two mugs at 1500 plus 8.25%); quantity 11 and an unknown product get 422; the sixth request in a minute gets 429 with Retry-After. Five a minute per IP is my demo choice. Pick yours from your own checkout data, and remember many shoppers share one NAT'd IP.
The rate-limit test does not reset the limiter, and doesn't need to. Limiter keys are hashed from the limiter name plus the by() value, so RateLimiter::clear('checkout') would not touch the per-IP key anyway. Instead, phpunit.xml sets CACHE_STORE=array, and each test boots a fresh application, so the counter starts at zero in every test. (Outside tests, the limiter state lives in your real cache; see the k6 section.)
Laravel security headers and CSP for Stripe
app/Http/Middleware/SecurityHeaders.php, with the Stripe.js directives from Stripe's guide: script-src and frame-src for js.stripe.com and *.js.stripe.com (Stripe says the wildcard lets Stripe.js start frames on different origins for performance), connect-src for api.stripe.com, and frame-src for hooks.stripe.com (3-D Secure redirects).
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$csp = "default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; "
. "frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; "
. "connect-src 'self' https://api.stripe.com; img-src 'self' data: https://*.stripe.com; "
. "style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
$response->headers->set('Content-Security-Policy', $csp);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(self)');
if ($request->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
Against the running server (curl -sI http://localhost:8000/):
HTTP/1.1 200 OK
X-Powered-By: PHP/8.4.26
Content-Security-Policy: default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' data: https://*.stripe.com; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self)
X-Powered-By comes from PHP, not Laravel; set expose_php = Off in php.ini. Strict-Transport-Security is absent because the request was plain HTTP.
The Stripe allowances were not exercised in a browser. The test page never loads Stripe.js, so the Stripe-specific directives are checked only as header text. Load your real Elements page under this policy before you trust it.
The header feature test passes even when the page is broken. My first Blade view had an inline <script> and an onclick=, and the browser test timed out waiting for the total. To make that a repeatable check, BuyTest reads Chromium's console log through the driver and fails on any CSP violation (code in the Dusk section). With one <script>document.title="x"</script> added to the view:
FAIL Tests\Browser\BuyTest
⨯ buyer sees total with tax after clicking buy 14.66s
CSP violations: http://p7lab:8000/ 10 Executing inline script violates the following Content Security Policy directive 'script-src 'self' https://js.stripe.com https://*.js.stripe.com'. Either the 'unsafe-inline' keyword, a hash ('sha256-eawmn9Ja0slk0t23UB0XPhR3DidQg5gBGKEBS+LWEMY='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
The fix was public/shop.js plus addEventListener, with the view reduced to a form and a script tag:
<form class="buy" method="POST" action="/checkout">
@csrf
<input type="hidden" name="product_id" value="{{ $p->id }}">
<input type="number" name="qty" value="1" min="1" max="10" dusk="qty-{{ $p->id }}">
<button type="button" dusk="buy-{{ $p->id }}">Buy</button>
</form>
<p id="result" dusk="result"></p>
<script src="/shop.js"></script>
document.querySelectorAll('form.buy button').forEach((btn) => {
btn.addEventListener('click', async () => {
const f = btn.form;
const r = await fetch(f.action, {
method: 'POST',
headers: {'Accept': 'application/json', 'X-CSRF-TOKEN': f.querySelector('[name=_token]').value},
body: new FormData(f),
});
const j = await r.json();
document.getElementById('result').textContent = r.ok ? 'Total: $' + (j.total_cents / 100).toFixed(2) : 'Error ' + r.status;
});
});
If you can't avoid inline scripts, use a per-request nonce rather than 'unsafe-inline'.
Laravel Dusk browser test in Docker
Dusk drives Chromium over ChromeDriver. One test: open /, set quantity 2, click Buy, wait for the total, with the real CSRF token and a real fetch(), then fail if Chromium logged a CSP violation:
$b->visit('/')->assertSee('Mug')
->type("@qty-{$p->id}", '2')
->click("@buy-{$p->id}")
->waitForTextIn('@result', 'Total: $32.48')
->assertSeeIn('@result', 'Total: $32.48');
$violations = collect($b->driver->manage()->getLog('browser'))
->filter(fn ($e) => str_contains($e['message'], 'Content Security Policy'))
->pluck('message')->all();
$this->assertSame([], $violations, 'CSP violations: ' . implode("\n", $violations));
getLog('browser') needed no extra setup here: my DuskTestCase sets no goog:loggingPrefs capability, and the log still returned the CSP violation above, because ChromeDriver exposes the browser log by default. If your driver setup disables logging, add that capability yourself.
It only sees violations on pages the test visits. To trial a new policy on real traffic first, send it as Content-Security-Policy-Report-Only.
Start ChromeDriver and the app by hand (inside p7lab), then run Dusk:
chromedriver --port=9515 --allowed-ips= &
PHP_CLI_SERVER_WORKERS=4 php artisan serve --host=0.0.0.0 &
php artisan dusk
PASS Tests\Browser\BuyTest
✓ buyer sees total with tax after clicking buy 9.33s
Tests: 1 passed (6 assertions)
Duration: 9.91s
Even the fast end of the browser run costs more than the whole PHPUnit run, so keep the browser layer to one test per money-moving screen. The DuskTestCase changes I made for root-in-Docker are in the appendix.
Gotchas, in the order I hit them:
type('qty-1', ...)failed withUnable to locate element: body qty-1. Selectors fordusk="..."attributes need the@prefix:@qty-1.- Dusk hits a real URL, so the server must be running.
DatabaseMigrationsalso wipes that database (I had to re-migrate and re-seed before the k6 runs below), so don't point Dusk at data you care about.
k6 smoke test for a Laravel checkout
A smoke test answers "does the deployed thing respond, with the right headers, under a few concurrent users?" It isn't a capacity test. I used the grafana/k6:2.3.0 image (docker run --rm grafana/k6:2.3.0 version prints k6 v2.3.0) on the same Docker network, with thresholds so the run exits non-zero instead of needing a human to read it. k6/smoke.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = __ENV.BASE_URL || 'http://p7lab:8000';
export const options = {
vus: 5, duration: '20s',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500'],
checks: ['rate>0.99'],
},
};
export default function () {
const home = http.get(`${BASE}/`);
check(home, {
'home 200': (r) => r.status === 200,
'CSP header': (r) => !!r.headers['Content-Security-Policy'],
'nosniff': (r) => r.headers['X-Content-Type-Options'] === 'nosniff',
});
check(http.get(`${BASE}/up`), { 'health 200': (r) => r.status === 200 });
sleep(0.2);
}
Run it from the shop directory on the host (the --network flag is what lets the container reach p7lab):
docker run --rm --network p7net -v "$PWD/k6:/scripts" grafana/k6:2.3.0 run /scripts/smoke.js
Against a server that hadn't started, the run reported checks_failed: 100.00%, http_req_failed: 100.00% and crossed the thresholds. With the server up:
✓ 'rate>0.99' rate=100.00%
✓ 'p(95)<500' p(95)=186.56ms
✓ 'rate<0.01' rate=0.00%
checks_succeeded...: 100.00% 1072 out of 1072
http_req_duration..: avg=86.5ms min=13.94ms med=72.85ms max=442.7ms p(90)=157.8ms p(95)=186.56ms
http_reqs..........: 536 26.34/s
Read these numbers carefully. This is PHP's built-in dev server (4 workers) in a container on a Windows laptop, debug on, no OPcache. Another run of the same script gave p95 = 482.78 ms, close to the 500 ms threshold. It says nothing about Laravel under production load; set your threshold from a baseline on your own staging environment.
The smoke test never calls /checkout: 5 VUs for 20 s only GET / and /up, and POSTing from all of them would trip the 5-per-minute limiter and fail the run. The limiter gets its own script, k6/ratelimit.js: one VU, eight POSTs. k6's cookie jar keeps the session, so it fetches the page, reads the _token input and posts:
import http from 'k6/http';
import { check } from 'k6';
const BASE = __ENV.BASE_URL || 'http://p7lab:8000';
export const options = { vus: 1, iterations: 1 };
export default function () {
// The cookie jar keeps the session; the page carries the CSRF token.
const page = http.get(`${BASE}/`);
const token = page.html().find('input[name=_token]').first().attr('value');
const statuses = [];
for (let i = 0; i < 8; i++) {
const r = http.post(`${BASE}/checkout`, { _token: token, product_id: '1', qty: '1' },
{ headers: { Accept: 'application/json' } });
statuses.push(r.status);
}
console.log('statuses: ' + statuses.join(','));
check(statuses, {
'first 5 are 201': (s) => s.slice(0, 5).every((x) => x === 201),
'rest are 429': (s) => s.slice(5).every((x) => x === 429),
});
}
Run it with the same docker run line, swapping in ratelimit.js:
statuses: 201,201,201,201,201,429,429,429
✓ first 5 are 201
✓ rest are 429
Here the limiter state lives in the real cache (CACHE_STORE=database in .env), so run php artisan cache:clear between k6 runs or the second run starts already throttled.
PCI DSS SAQ A vs SAQ A-EP for a Laravel checkout
Scope note: the SAQ A points below rest on PCI SSC's FAQ 1588 and announcement (v4.0.1); the SAQ A-EP conclusions come from the v4.0 PDF only, because the v4.0.1 PDFs returned HTTP 403 for me. The deciding question is who produces the payment page. Where the customer's browser gets the card fields from decides which questionnaire you are eligible for. Stripe's PCI compliance guide maps its integrations like this:
- Checkout and Elements: SAQ A, because, in Stripe's words, "your customers' card information never touches your servers".
- Stripe.js v2, which passes "card data entered in a form hosted on your own site": SAQ A-EP.
- Passing card details directly to Stripe's API: SAQ D, "the most demanding of the SAQs".
Primary text. The questionnaire quotes below are from SAQ A and SAQ A-EP v4.0 (April 2022) in PCI SSC's document library. Check the eligibility wording in the current version before you attest. The e-commerce eligibility criteria:
- SAQ A: "All elements of the payment page(s)/form(s) delivered to the customer's browser originate only and directly from a PCI DSS compliant TPSP/payment processor."
- SAQ A-EP: "Each element of the payment page(s) delivered to the customer's browser originates from either the merchant's website or a PCI DSS compliant TPSP."
PCI SSC FAQ 1291 (August 2015) uses nearly the same wording and explains why direct post lands in A-EP while iframe and URL redirect land in A: with direct post, "the merchant website generates part of the payment acceptance page" while with iframe or redirect the processor creates the entire page. So "card data never touches my server" is not enough for SAQ A. A-EP exists for sites that never receive card data but still serve the page it's typed into, which makes your page a target for skimming scripts.
What changed in 2025. Requirements 6.4.3 (payment-page script inventory and authorisation) and 11.6.1 (change/tamper detection) appear in the v4.0 SAQ A, with the note "For SAQ A, Requirement 6.4.3 applies to the page(s) on the merchant's website(s) that provides the address (the URL) of the TPSP's payment page/form". The PCI SSC announcement says the January 2025 SAQ A takes effect on 31 March 2025, when the October 2024 version retires, the same date 6.4.3 and 11.6.1 become effective in PCI DSS v4.0.1. It drops those requirements (and 12.3.1) from SAQ A in favour of an eligibility criterion: the merchant has confirmed their site "is not susceptible to attacks from scripts that could affect the merchant's e-commerce system(s)". The criterion's scope comes from PCI SSC FAQ 1588 (February 2025). It applies to merchants whose page embeds a third-party payment page or form, such as an iframe, and not to merchants who redirect customers away (HTTP 30x, meta refresh or JavaScript redirect) or fully outsource payment. Those merchants confirm their site isn't susceptible to script attacks either by using the techniques in 6.4.3 and 11.6.1 or by getting confirmation from their PCI DSS-compliant payment provider that its embedded solution protects against script attacks. FAQ 1588 doesn't mention SAQ A-EP.
SAQ A-EP keeps 6.4.3 and 11.6.1. The v4.0 SAQ A-EP carries the notes "For SAQ A-EP, Requirement 6.4.3 applies to the payment page(s) provided from the merchant's website to the customer's browser" and "For SAQ A-EP, Requirement 11.6.1 applies to webservers that host the payment page(s) provided from the merchant's website to the customer's browser." I could not open the v4.0.1 A-EP PDF, which per PCI SSC adds completion guidance to these two requirements.
What this means for the code. With an Elements-style iframe you are on the SAQ A side but inside the script criterion, so your own JavaScript and third-party tags on the checkout page matter. That is why the CSP above is strict and why the inline script moved to an external file; Stripe's guide says that including JavaScript from other sites makes your security dependent on theirs. Stripe's security guide also says handling untokenized card numbers might mean meeting more than 300 PCI DSS security controls, and that you must attest annually. For the webhook and order lifecycle these tests protect, see Part 3. Your acquirer or payment provider decides which SAQ they'll accept, not a blog post.
Appendix: the remaining files
database/migrations/2026_10_07_000001_create_shop_tables.php:
return new class extends Migration {
public function up(): void
{
Schema::create('products', function (Blueprint $t) {
$t->id(); $t->string('name'); $t->unsignedInteger('price_cents'); $t->timestamps();
});
Schema::create('orders', function (Blueprint $t) {
$t->id(); $t->foreignId('product_id'); $t->unsignedInteger('qty');
$t->unsignedInteger('total_cents'); $t->string('status')->default('pending');
$t->string('payment_intent_id')->nullable()->unique(); $t->timestamps();
});
}
public function down(): void { Schema::dropIfExists('orders'); Schema::dropIfExists('products'); }
};
app/Models/Product.php and app/Models/Order.php are both class X extends Model { protected $guarded = []; }. resources/views/shop.blade.php loops over $products and renders the form shown earlier; the Mug product is price_cents = 1500.
tests/Feature/CheckoutSecurityTest.php (complete)
namespace Tests\Feature;
use App\Models\{Order, Product};
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CheckoutSecurityTest extends TestCase
{
use RefreshDatabase;
private function enforceCsrf(): void
{
$this->app->bind(ValidateCsrfToken::class, fn ($app) => new class($app, $app['encrypter']) extends ValidateCsrfToken {
protected function runningUnitTests() { return false; }
});
}
private function product(): Product
{
return Product::create(['name' => 'Mug', 'price_cents' => 1500]);
}
private function signed(string $body, string $secret = 'whsec_test'): array
{
$t = time();
$v1 = hash_hmac('sha256', "$t.$body", $secret);
return ['HTTP_STRIPE_SIGNATURE' => "t=$t,v1=$v1", 'CONTENT_TYPE' => 'application/json'];
}
public function test_post_without_csrf_token_is_419(): void
{
$this->enforceCsrf();
$this->post('/checkout', ['product_id' => $this->product()->id, 'qty' => 1])->assertStatus(419);
}
public function test_client_cannot_set_price(): void
{
$p = $this->product();
$res = $this->postJson('/checkout', ['product_id' => $p->id, 'qty' => 2, 'price_cents' => 1, 'total_cents' => 1]);
$res->assertCreated()->assertJson(['total_cents' => 3248]); // 3000 + 8.25% = 3247.5 -> 3248
$this->assertSame(3248, Order::first()->total_cents);
}
public function test_qty_out_of_range_is_422(): void
{
$p = $this->product();
$this->postJson('/checkout', ['product_id' => $p->id, 'qty' => 11])->assertStatus(422);
$this->postJson('/checkout', ['product_id' => 999, 'qty' => 1])->assertStatus(422);
}
public function test_checkout_is_rate_limited_to_5_per_minute(): void
{
// No limiter reset needed: the array cache starts empty in every test.
$p = $this->product();
foreach (range(1, 5) as $i) {
$this->postJson('/checkout', ['product_id' => $p->id, 'qty' => 1])->assertCreated();
}
$this->postJson('/checkout', ['product_id' => $p->id, 'qty' => 1])
->assertStatus(429)->assertHeader('Retry-After');
}
public function test_security_headers_present(): void
{
$this->get('/')->assertOk()
->assertHeader('X-Content-Type-Options', 'nosniff')
->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
$csp = $this->get('/')->headers->get('Content-Security-Policy');
$this->assertStringContainsString("frame-ancestors 'none'", $csp);
$this->assertStringContainsString('https://js.stripe.com', $csp);
$this->assertStringContainsString('https://*.js.stripe.com', $csp);
}
public function test_webhook_rejects_bad_signature_and_accepts_good_one(): void
{
$this->enforceCsrf(); // the webhook must work with CSRF really on
config(['services.stripe.webhook_secret' => 'whsec_test']);
$order = Order::create(['product_id' => 1, 'qty' => 1, 'total_cents' => 100, 'payment_intent_id' => 'pi_123']);
$body = json_encode(['type' => 'payment_intent.succeeded', 'data' => ['object' => ['id' => 'pi_123']]]);
$t = time();
$bad = ['HTTP_STRIPE_SIGNATURE' => "t=$t,v1=deadbeef", 'CONTENT_TYPE' => 'application/json'];
$this->call('POST', '/webhooks/stripe', [], [], [], $bad, $body)->assertStatus(400);
$this->assertSame('pending', $order->fresh()->status);
$this->call('POST', '/webhooks/stripe', [], [], [], $this->signed($body), $body)->assertNoContent();
$this->assertSame('paid', $order->fresh()->status);
}
public function test_redelivered_webhook_does_not_overwrite_a_later_status(): void
{
config(['services.stripe.webhook_secret' => 'whsec_test']);
$order = Order::create(['product_id' => 1, 'qty' => 1, 'total_cents' => 100, 'payment_intent_id' => 'pi_123']);
$body = json_encode(['type' => 'payment_intent.succeeded', 'data' => ['object' => ['id' => 'pi_123']]]);
$this->call('POST', '/webhooks/stripe', [], [], [], $this->signed($body), $body)->assertNoContent();
$order->update(['status' => 'refunded']);
$this->call('POST', '/webhooks/stripe', [], [], [], $this->signed($body), $body)->assertNoContent(); // Stripe retry
$this->assertSame('refunded', $order->fresh()->status);
}
public function test_webhook_without_configured_secret_is_500(): void
{
config(['services.stripe.webhook_secret' => null]);
$this->call('POST', '/webhooks/stripe', [], [], [], ['HTTP_STRIPE_SIGNATURE' => 't=1,v1=x'], '{}')->assertStatus(500);
}
}
tests/Unit/CartTest.php has the two tests shown in the run output: 1999 x 3 at 825 bps gives subtotal 5997, tax 495, total 6492, and qty = 0 expects InvalidArgumentException. tests/Browser/BuyTest.php is the Dusk snippet above wrapped in $this->browse() with DatabaseMigrations.
tests/DuskTestCase.php is the stock Laravel 12 one with --no-sandbox, --disable-dev-shm-usage and --headless=new added to the Chrome arguments, a no-op #[BeforeClass] prepare() so Dusk doesn't launch its own chromedriver, and the driver URL read from DUSK_DRIVER_URL.
What to do next
- Add
enforceCsrf()to your test base class, then test two things with CSRF really on: a state-changing route without a token returns 419, and your webhook route works (if it returns 419, add it tovalidateCsrfTokens(except: [...])). - Swap the demo handler for
constructEventplusevent.iddedupe, and allowlist Stripe's webhook IPs at your edge. - Run the k6 smoke test against staging after each deploy. Part 5 (Docker, Kubernetes manifests & CI/CD) is a natural place to wire it in, and Part 6 (scaling, Redis caching & query optimization) is where to take real load numbers.
- Open the current SAQ A and SAQ A-EP PDFs, match your integration to the eligibility text, and confirm with your acquirer before you attest.
Keep reading
Production-Ready Authentication: A Deep Dive into JWT, OAuth2, and Session Security
21 min · 1,331 views
Artificial IntelligenceAdvanced Security Measures for Protecting Against Sophisticated Threats
18 min · 1,300 views
Comprehensive TutorialsBuilding a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,087 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Building a REST API with Laravel - Part 1: API Design, Versioning & Sanctum Authentication
May 10, 2026
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 2: Product Catalog & Cart with Database Design
May 12, 2026