Free shipping on orders over $75  ·  Shop Now

Shopify Checkout for a Custom PHP Store

Part 3 of 6 · Shopify Storefront API · GraphQL · PHP · JavaScript

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
  • cartCreate mutation
  • User error handling
  • Checkout URL retrieval

Secure Redirect

  • Button loading state
  • Safe checkout URL
  • Customer redirect
  • Private error logging
The custom site selects the product. Shopify completes the order.

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

Color and Size Selected
PHP Validates Variant
cartCreate
Shopify Returns Checkout URL
Secure Shopify Checkout

Separate Storefront and Checkout Responsibilities

Custom PHP store

Before Checkout

  • Loads the product
  • Displays images and content
  • Shows colors and sizes
  • Tracks the selected variant
  • Validates local availability
Shopify

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.

Local Variant ID Internal MySQL row ID
Shopify Variant ID gid://shopify/ProductVariant/...
Cart Merchandise ID Sent to cartCreate
Do not send the Shopify product ID.

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 application configuration

/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
<?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
    );
}
Keep the version in one configuration value. When Shopify retires an API version, the integration can be tested and updated in one place.

Create a Reusable GraphQL Helper

PHP
<?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

GraphQL
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:

JSON
{
    "input": {
        "lines": [
            {
                "merchandiseId": "gid://shopify/ProductVariant/1234567890",
                "quantity": 1
            }
        ]
    }
}

Add CSRF Protection

Create the token before rendering the product page:

PHP
<?php

if (empty($_SESSION['cart_csrf_token'])) {
    $_SESSION['cart_csrf_token'] =
        bin2hex(random_bytes(32));
}

Make the token available to the product page:

HTML and PHP
<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.

PHP
$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
<?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

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

JavaScript
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';
}
CSS
.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:

JSON Response
{
    "ok": true,
    "cartId": "gid://shopify/Cart/...",
    "checkoutUrl": "https://your-store.myshopify.com/cart/c/..."
}

The browser then navigates to the returned URL:

JavaScript
window.location.assign(result.checkoutUrl);
Do not invent or assemble the checkout URL yourself. Use the exact URL returned by Shopify for that cart.

Extend the Request to Multiple Cart Lines

A persistent cart can send several validated variants in one cartCreate request.

PHP
$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

1
Product page

Select Every Color and Size

Confirm that available options map to the correct Shopify variant IDs.

2
Endpoint

Test Valid and Invalid Requests

Try missing IDs, disabled variants, bad quantities, malformed JSON, and expired CSRF tokens.

3
Shopify cart

Verify the Correct Merchandise

Confirm the title, color, size, quantity, price, currency, and product image in checkout.

4
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.

0
cart