What We Are Building in Part 3
The storefront can already identify the exact color and size the customer selected. The next step is connecting that local selection to Shopify.
Variant Mapping
- Local variant record
- Shopify merchandise ID
- Availability validation
- Quantity validation
PHP Cart Endpoint
- JSON request handling
- CSRF protection
- MySQL verification
- Safe JSON responses
Shopify GraphQL
- Storefront API request
cartCreatemutation- User error handling
- Checkout URL retrieval
Secure Redirect
- Button loading state
- Safe checkout URL
- Customer redirect
- Private error logging
PHP validates the selected merchandise ID, creates the Shopify cart, receives the checkout URL, and sends the customer to Shopify's hosted checkout.
Contents
- How the checkout flow works
- Separate storefront and checkout responsibilities
- Understand Shopify GraphQL IDs
- Configure Storefront API access
- Create the private Shopify configuration
- Create a reusable GraphQL helper
- Build the cartCreate mutation
- Create the PHP cart endpoint
- Add CSRF protection
- Validate the selected variant
- Send the cart request from JavaScript
- Add loading and error states
- Redirect to Shopify checkout
- Extend the request to multiple lines
- Test the complete flow
- Security checklist
- Frequently asked questions
How the Checkout Flow Works
cartCreate
Separate Storefront and Checkout Responsibilities
Before Checkout
- Loads the product
- Displays images and content
- Shows colors and sizes
- Tracks the selected variant
- Validates local availability
Checkout and Order
- Creates the cart
- Applies discounts
- Collects customer information
- Calculates shipping and taxes
- Processes payment and creates the order
Understand Shopify GraphQL IDs
The Storefront API expects the selected product variant as a GraphQL merchandise ID.
gid://shopify/ProductVariant/...
cartCreate
The cart line must reference the exact product variant because the color and size are represented by that variant.
Configure Storefront API Access
The Shopify Storefront API is a GraphQL API associated with a specific store. The request needs the store domain, API version, and a Storefront access token.
/private/shopify.php
Store domain
Your myshopify.com hostname
API version
A supported Storefront API version
Storefront token
Used to authenticate API requests
Request helper
Reusable cURL and error handling
Create the Private Shopify Configuration
<?php
/**
* /private/shopify.php
*/
declare(strict_types=1);
const SHOPIFY_STORE_DOMAIN =
'your-store.myshopify.com';
const SHOPIFY_STOREFRONT_API_VERSION =
'2026-04';
const SHOPIFY_STOREFRONT_ACCESS_TOKEN =
'REPLACE_WITH_YOUR_STOREFRONT_TOKEN';
function shopifyStorefrontEndpoint(): string
{
return sprintf(
'https://%s/api/%s/graphql.json',
SHOPIFY_STORE_DOMAIN,
SHOPIFY_STOREFRONT_API_VERSION
);
}
Create a Reusable GraphQL Helper
<?php
function shopifyStorefrontRequest(
string $query,
array $variables = []
): array {
$payload = json_encode(
[
'query' => $query,
'variables' => $variables,
],
JSON_THROW_ON_ERROR
);
$curl = curl_init(
shopifyStorefrontEndpoint()
);
curl_setopt_array(
$curl,
[
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 25,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Shopify-Storefront-Access-Token: ' .
SHOPIFY_STOREFRONT_ACCESS_TOKEN,
],
CURLOPT_POSTFIELDS => $payload,
]
);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
$statusCode = (int) curl_getinfo(
$curl,
CURLINFO_HTTP_CODE
);
curl_close($curl);
if ($responseBody === false) {
throw new RuntimeException(
'Shopify request failed: ' . $curlError
);
}
$response = json_decode(
$responseBody,
true,
512,
JSON_THROW_ON_ERROR
);
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException(
'Shopify returned HTTP ' . $statusCode
);
}
if (!empty($response['errors'])) {
throw new RuntimeException(
'Shopify GraphQL error: ' .
json_encode($response['errors'])
);
}
return $response;
}
Build the cartCreate Mutation
mutation CartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
totalQuantity
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
}
lines(first: 20) {
nodes {
id
quantity
merchandise {
... on ProductVariant {
id
title
product {
title
}
}
}
}
}
}
userErrors {
field
message
code
}
warnings {
code
message
target
}
}
}
The variables contain the merchandise ID and quantity:
{
"input": {
"lines": [
{
"merchandiseId": "gid://shopify/ProductVariant/1234567890",
"quantity": 1
}
]
}
}
Add CSRF Protection
Create the token before rendering the product page:
<?php
if (empty($_SESSION['cart_csrf_token'])) {
$_SESSION['cart_csrf_token'] =
bin2hex(random_bytes(32));
}
Make the token available to the product page:
<meta name="cart-csrf-token"
content="<?= htmlspecialchars(
$_SESSION['cart_csrf_token'],
ENT_QUOTES,
'UTF-8'
) ?>">
Validate the Selected Variant
The browser sends the Shopify variant ID, but PHP confirms that the ID belongs to a current, active, sellable local variant.
$variantStmt = $pdo->prepare("
SELECT
sv.id,
sv.shopify_variant_id,
sv.color,
sv.size,
sv.price,
s.item_number,
s.title
FROM shirt_variants AS sv
INNER JOIN shirts AS s
ON s.id = sv.shirt_id
WHERE sv.shopify_variant_id = :shopify_variant_id
AND sv.enabled = 1
AND sv.available = 1
AND s.active = 1
LIMIT 1
");
$variantStmt->execute([
':shopify_variant_id' => $shopifyVariantId,
]);
$variant = $variantStmt->fetch();
if (!$variant) {
throw new InvalidArgumentException(
'The selected product option is unavailable.'
);
}
Create the PHP Cart Endpoint
This endpoint accepts JSON, validates the request, verifies the variant, creates the Shopify cart, and returns the checkout URL.
<?php
/**
* /api/create-cart.php
*/
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/../../private/connection.php';
require_once __DIR__ . '/../../private/shopify.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function respondJson(
array $payload,
int $statusCode = 200
): never {
http_response_code($statusCode);
echo json_encode(
$payload,
JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE
);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
respondJson(
[
'ok' => false,
'error' => 'Method not allowed.',
],
405
);
}
try {
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
throw new InvalidArgumentException(
'The cart request was empty.'
);
}
$request = json_decode(
$rawBody,
true,
512,
JSON_THROW_ON_ERROR
);
$csrfToken = (string) (
$request['csrfToken'] ?? ''
);
$sessionToken = (string) (
$_SESSION['cart_csrf_token'] ?? ''
);
if (
$csrfToken === '' ||
$sessionToken === '' ||
!hash_equals($sessionToken, $csrfToken)
) {
respondJson(
[
'ok' => false,
'error' => 'The cart session expired. Reload the page and try again.',
],
403
);
}
$shopifyVariantId = trim(
(string) (
$request['shopifyVariantId'] ?? ''
)
);
$quantity = filter_var(
$request['quantity'] ?? 1,
FILTER_VALIDATE_INT,
[
'options' => [
'min_range' => 1,
'max_range' => 10,
],
]
);
if (
$shopifyVariantId === '' ||
!str_starts_with(
$shopifyVariantId,
'gid://shopify/ProductVariant/'
)
) {
throw new InvalidArgumentException(
'A valid product variant is required.'
);
}
if ($quantity === false) {
throw new InvalidArgumentException(
'The quantity must be between 1 and 10.'
);
}
$variantStmt = $pdo->prepare("
SELECT
sv.id,
sv.shopify_variant_id,
sv.color,
sv.size,
sv.price,
s.item_number,
s.title
FROM shirt_variants AS sv
INNER JOIN shirts AS s
ON s.id = sv.shirt_id
WHERE sv.shopify_variant_id = :shopify_variant_id
AND sv.enabled = 1
AND sv.available = 1
AND s.active = 1
LIMIT 1
");
$variantStmt->execute([
':shopify_variant_id' => $shopifyVariantId,
]);
$variant = $variantStmt->fetch();
if (!$variant) {
throw new InvalidArgumentException(
'The selected product option is unavailable.'
);
}
$mutation = <<<'GRAPHQL'
mutation CartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
totalQuantity
}
userErrors {
field
message
code
}
warnings {
code
message
target
}
}
}
GRAPHQL;
$response = shopifyStorefrontRequest(
$mutation,
[
'input' => [
'lines' => [
[
'merchandiseId' =>
$shopifyVariantId,
'quantity' => $quantity,
],
],
],
]
);
$payload = $response['data']['cartCreate']
?? null;
if (!$payload) {
throw new RuntimeException(
'Shopify did not return a cart payload.'
);
}
if (!empty($payload['userErrors'])) {
error_log(
'Shopify cart user errors: ' .
json_encode($payload['userErrors'])
);
respondJson(
[
'ok' => false,
'error' => 'Shopify could not create the cart. Please try again.',
],
422
);
}
$checkoutUrl = filter_var(
$payload['cart']['checkoutUrl'] ?? '',
FILTER_VALIDATE_URL
);
if (!$checkoutUrl) {
throw new RuntimeException(
'Shopify did not return a checkout URL.'
);
}
respondJson([
'ok' => true,
'cartId' => $payload['cart']['id'],
'checkoutUrl' => $checkoutUrl,
]);
} catch (JsonException $exception) {
respondJson(
[
'ok' => false,
'error' => 'The cart request was invalid.',
],
400
);
} catch (InvalidArgumentException $exception) {
respondJson(
[
'ok' => false,
'error' => $exception->getMessage(),
],
422
);
} catch (Throwable $exception) {
error_log(
'Cart creation failed: ' .
$exception->getMessage()
);
respondJson(
[
'ok' => false,
'error' => 'The cart could not be created. Please try again.',
],
500
);
}
Send the Cart Request from JavaScript
const buyButton = document.querySelector(
'#add-to-cart-button'
);
const variantMessage = document.querySelector(
'#variant-message'
);
const csrfToken = document
.querySelector('meta[name="cart-csrf-token"]')
?.getAttribute('content');
async function createShopifyCart() {
const shopifyVariantId =
buyButton.dataset.shopifyVariantId;
if (!shopifyVariantId) {
variantMessage.textContent =
'Select an available size first.';
return;
}
setCartButtonState('loading');
try {
const response = await fetch(
'/api/create-cart.php',
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
shopifyVariantId,
quantity: 1,
csrfToken
})
}
);
const result = await response.json();
if (!response.ok || !result.ok) {
throw new Error(
result.error ||
'The cart could not be created.'
);
}
setCartButtonState('success');
window.location.assign(
result.checkoutUrl
);
} catch (error) {
console.error(error);
variantMessage.textContent =
error.message ||
'The cart could not be created.';
setCartButtonState('error');
}
}
buyButton.addEventListener(
'click',
createShopifyCart
);
Add Loading and Error States
function setCartButtonState(state) {
buyButton.classList.remove(
'is-loading',
'is-success',
'is-error'
);
if (state === 'loading') {
buyButton.disabled = true;
buyButton.classList.add('is-loading');
buyButton.textContent = 'Creating Secure Checkout...';
return;
}
if (state === 'success') {
buyButton.disabled = true;
buyButton.classList.add('is-success');
buyButton.textContent = 'Redirecting to Shopify...';
return;
}
if (state === 'error') {
buyButton.disabled = false;
buyButton.classList.add('is-error');
buyButton.textContent = 'Try Again';
return;
}
buyButton.disabled = false;
buyButton.textContent = 'Add to Cart';
}
.product-buy-button.is-loading {
cursor: wait;
opacity: .72;
}
.product-buy-button.is-success {
background: #2d2d2d;
}
.product-buy-button.is-error {
background: #555;
}
.product-variant-message {
min-height: 1.5em;
margin: .75rem 0;
color: #444;
font-size: .86rem;
line-height: 1.5;
}
Redirect to Shopify Checkout
The successful endpoint response contains a URL returned by Shopify:
{
"ok": true,
"cartId": "gid://shopify/Cart/...",
"checkoutUrl": "https://your-store.myshopify.com/cart/c/..."
}
The browser then navigates to the returned URL:
window.location.assign(result.checkoutUrl);
Extend the Request to Multiple Cart Lines
A persistent cart can send several validated variants in one
cartCreate request.
$lines = [
[
'merchandiseId' =>
'gid://shopify/ProductVariant/111',
'quantity' => 1,
],
[
'merchandiseId' =>
'gid://shopify/ProductVariant/222',
'quantity' => 2,
],
];
$response = shopifyStorefrontRequest(
$mutation,
[
'input' => [
'lines' => $lines,
],
]
);
Each line should be validated against MySQL before the request is sent to Shopify.
Test the Complete Checkout Flow
Select Every Color and Size
Confirm that available options map to the correct Shopify variant IDs.
Test Valid and Invalid Requests
Try missing IDs, disabled variants, bad quantities, malformed JSON, and expired CSRF tokens.
Verify the Correct Merchandise
Confirm the title, color, size, quantity, price, currency, and product image in checkout.
Complete a Test Order
Verify shipping, discounts, tax behavior, payment, order creation, and the Printify connection.
Part 3 Security Checklist
- The Shopify configuration is stored outside the public web root.
- The Storefront API version is stored in one configuration value.
- The endpoint accepts only POST requests.
- The endpoint accepts and returns JSON.
- The endpoint sends
Cache-Control: no-store. - The request includes a valid CSRF token.
- The quantity is converted to a validated integer.
- The Shopify ID must begin with the product-variant GraphQL prefix.
- The submitted variant is verified against MySQL.
- The local product must be active.
- The local variant must be enabled and available.
- The browser cannot submit an arbitrary Shopify variant.
- Shopify GraphQL errors are handled.
- Shopify cart user errors are handled.
- Detailed errors are written only to private logs.
- Customer-facing errors do not expose tokens or API responses.
- The returned checkout URL is validated before use.
- The buy button prevents accidental duplicate requests.
- JavaScript errors restore a usable button state.
- A complete test order is placed after integration changes.
Shopify Checkout for a Custom PHP Store FAQ
Why use Shopify checkout with a custom PHP storefront?
The custom PHP site can control catalog design, product presentation, collections, and local data while Shopify handles the secure checkout, payment processing, shipping choices, discounts, taxes, and order creation.
Which Shopify API creates the cart?
Use the Shopify Storefront API cartCreate mutation. It can create a cart with one or more merchandise lines and returns a cart object containing a checkoutUrl.
What Shopify ID should be sent to cartCreate?
The merchandiseId must be the Shopify product variant GraphQL ID, not the local database variant ID, Printify variant ID, or Shopify product ID.
Should the Storefront API request be made from PHP or directly from JavaScript?
A public Storefront token can be used client-side, but a server-side PHP endpoint gives the store more control over validation, logging, rate limiting, error messages, and which merchandise IDs are accepted.
Does the custom PHP site process the payment?
No. The PHP site creates a Shopify cart and redirects the customer to the returned checkout URL. Shopify hosts the checkout and processes the payment.
Why should the selected variant be validated against MySQL?
Browser data can be changed by a visitor. The server should confirm that the submitted Shopify variant ID belongs to an active local product and an enabled, available variant before creating the cart.
Should the Storefront access token be stored in JavaScript?
A public Storefront access token is designed for storefront access, but storing configuration in a private PHP file and routing requests through a server endpoint still makes validation, rotation, debugging, and deployment easier.
What happens when Shopify returns userErrors?
The endpoint should not redirect. It should log the detailed error privately and return a short safe message to the browser so the customer can retry or select another variant.
Can cartCreate include more than one product?
Yes. The lines array can contain multiple merchandise IDs and quantities. This chapter starts with one selected product variant and can be extended into a persistent cart.
Do I still need server-side validation when the button is disabled in JavaScript?
Yes. JavaScript controls the interface only. The PHP endpoint must validate the request independently because users can modify browser requests.
Continue to Part 4
The custom storefront can now create a Shopify cart and send the customer to secure checkout. Part 4 connects the other side of the system by importing and synchronizing Printify product data.