Free shipping on orders over $75  ·  Shop Now

PHP and MySQL Store Architecture

Part 1 of 6 · PHP · MySQL · PDO · Products · Variants · Collections

What We Are Building in Part 1

This chapter builds the local foundation beneath the storefront. Before creating product cards, color selectors, carts, or API synchronization, the site needs a clear place to store and retrieve its product information.

By the end of this chapter, the project will have:

Project Structure

  • Public storefront files
  • Reusable includes
  • Protected configuration
  • Admin and API directories

Database Schema

  • Products
  • Variants
  • Collections
  • Reviews and platform IDs

Secure PDO Access

  • Private credentials
  • Prepared statements
  • Exception handling
  • UTF-8 database connections

Two-Page Architecture

  • One catalog page
  • One reusable product page
  • Database-driven content
  • No separate product files
The goal is not to recreate Shopify or Printify.

The goal is to create a fast local storefront layer that controls the product presentation while keeping the checkout and fulfillment platforms connected behind it.

Contents

The Architecture Goal

The custom store needs to answer a product-page request quickly without asking Shopify or Printify for the same information every time a visitor loads a page.

Customer request shirt.php?item=1001
Local application PHP + MySQL
Rendered result
Product content Available variants Images and SEO

Shopify and Printify remain connected through stored IDs and synchronization tools, but ordinary page views use the local database.

Separate Content, Checkout, and Fulfillment

Local PHP and MySQL

Storefront Content

  • Product titles and descriptions
  • Collections and categories
  • Images and related products
  • SEO and structured data
  • Likes, reviews, and sorting
Shopify

Checkout

  • Cart creation
  • Customer checkout
  • Payment processing
  • Discounts and shipping
  • Order creation
Printify

Fulfillment

  • Blank products
  • Print providers
  • Production variants
  • Printing and packaging
  • Shipping and tracking
Custom tools

Synchronization

  • Platform IDs
  • Variant mapping
  • Price updates
  • Availability updates
  • Image and content imports

The Two-Page Storefront

The customer-facing store can begin with only two important page templates.

Catalog and collections index.php
Reusable product template shirt.php
Database-driven products
shirt.php?item=1001 shirt.php?item=1002 shirt.php?item=1003
One template, many products.

The item number changes. The PHP file does not. The product record, variants, images, descriptions, and metadata determine what appears.

Recommended Folder Structure

Public web directory

/public_html/

Customer pages, reusable includes, browser assets, public tools, and protected administration pages live inside the hosted web directory.

Storefront

Public Pages

  • index.php Catalog and collection views
  • shirt.php Reusable product template
  • about.php Company and brand story
Shared PHP

/includes/

  • tcsc-head.php Shared metadata
  • tcsc-header.php Navigation and collection links
  • tcsc-footer.php Shared footer
  • product-card.php Reusable product card
Server endpoints

/api/

  • create-cart.php Shopify cart creation
  • like-shirt.php Product-like updates
  • submit-review.php Customer review submission
Protected management

/admin/

  • products.php Catalog administration
  • edit-product.php Product and SEO editing
  • sync-printify.php Platform synchronization
Outside the public web root

/private/

Database credentials, private API settings, service helpers, and logs should not be directly accessible through a URL.

connection.php PDO connection
config.php Application configuration
shopify.php Shopify API helper
printify.php Printify API helper
logging.php Private error logging

Plan the Database Before Creating Tables

Start by separating information that belongs to the product from information that belongs to a specific color and size combination.

Product-Level Data Variant-Level Data
Title Color
Description Size
Collection Variant price
Category Availability
SEO fields Shopify variant ID
Primary product IDs Printify variant ID

Storing variants separately prevents the product table from becoming a collection of columns such as black_small_available, black_medium_available, and white_large_available.

Create the Products Table

The product table stores information shared by all variants of one design.

SQL
CREATE TABLE shirts (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

    item_number INT UNSIGNED NOT NULL,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(255) DEFAULT NULL,

    line VARCHAR(150) DEFAULT NULL,
    category VARCHAR(100) DEFAULT 'T-Shirts',

    short_description VARCHAR(255) DEFAULT NULL,
    description TEXT DEFAULT NULL,

    price DECIMAL(10,2) NOT NULL DEFAULT 0.00,

    active TINYINT(1) NOT NULL DEFAULT 1,

    likes INT UNSIGNED NOT NULL DEFAULT 0,
    buy_click_count INT UNSIGNED NOT NULL DEFAULT 0,

    seo_title VARCHAR(255) DEFAULT NULL,
    meta_description VARCHAR(320) DEFAULT NULL,
    og_title VARCHAR(255) DEFAULT NULL,
    og_description VARCHAR(320) DEFAULT NULL,

    printify_product_id VARCHAR(100) DEFAULT NULL,
    shopify_product_id VARCHAR(100) DEFAULT NULL,

    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL
        DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uq_shirts_item_number (item_number),
    UNIQUE KEY uq_shirts_slug (slug),
    KEY idx_shirts_active (active),
    KEY idx_shirts_line (line),
    KEY idx_shirts_category (category),
    KEY idx_shirts_created_at (created_at)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

Why keep both id and item_number?

The numeric primary key is efficient for table relationships. The item number is a stable business identifier that can be used in:

  • Product URLs
  • Image-folder names
  • Admin searches
  • Print files
  • Reports and exports
The table above is a clean starting point. A real existing database should be altered carefully rather than replaced without reviewing current data and column names.

Create the Variants Table

A product can have several colors, sizes, prices, images, and platform IDs. Each sellable combination becomes one variant row.

SQL
CREATE TABLE shirt_variants (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

    shirt_id BIGINT UNSIGNED NOT NULL,

    color VARCHAR(100) NOT NULL,
    size VARCHAR(30) NOT NULL,

    price DECIMAL(10,2) NOT NULL DEFAULT 0.00,

    enabled TINYINT(1) NOT NULL DEFAULT 1,
    available TINYINT(1) NOT NULL DEFAULT 1,

    shopify_variant_id VARCHAR(100) DEFAULT NULL,
    printify_variant_id VARCHAR(100) DEFAULT NULL,

    image_url VARCHAR(500) DEFAULT NULL,

    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL
        DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_shirt_variants_shirt
        FOREIGN KEY (shirt_id)
        REFERENCES shirts(id)
        ON DELETE CASCADE,

    UNIQUE KEY uq_shirt_color_size (
        shirt_id,
        color,
        size
    ),

    KEY idx_variants_shirt_id (shirt_id),
    KEY idx_variants_available (available),
    KEY idx_variants_color (color),
    KEY idx_variants_size (size),
    KEY idx_variants_shopify_id (shopify_variant_id),
    KEY idx_variants_printify_id (printify_variant_id)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;
The important relationship

One Product Can Have Many Variants

The shirt_id foreign key connects every color and size combination to its parent product.

Local Variant Black / XL
Shopify Variant Checkout merchandise ID
Printify Variant Production variant ID

Store Collections and Product Lines

The simplest structure stores one collection name directly in the product record:

SQL
line VARCHAR(150) DEFAULT NULL

That works well when each product belongs to one primary line, such as:

  • The American Slice
  • Catastrophe Cats
  • Conspiracy Core
  • The Cryptids
  • Folklore and Superstition

When to use separate collection tables

A many-to-many structure is better when one product must belong to several collections.

SQL
CREATE TABLE collections (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(150) NOT NULL,
    slug VARCHAR(180) NOT NULL,
    description TEXT DEFAULT NULL,
    active TINYINT(1) NOT NULL DEFAULT 1,

    UNIQUE KEY uq_collections_name (name),
    UNIQUE KEY uq_collections_slug (slug)
);

CREATE TABLE shirt_collections (
    shirt_id BIGINT UNSIGNED NOT NULL,
    collection_id BIGINT UNSIGNED NOT NULL,

    PRIMARY KEY (shirt_id, collection_id),

    CONSTRAINT fk_shirt_collections_shirt
        FOREIGN KEY (shirt_id)
        REFERENCES shirts(id)
        ON DELETE CASCADE,

    CONSTRAINT fk_shirt_collections_collection
        FOREIGN KEY (collection_id)
        REFERENCES collections(id)
        ON DELETE CASCADE
);
Start with the simplest structure that supports the current store. Do not add a many-to-many system unless products actually need to belong to several collections.

Create the Reviews Table

SQL
CREATE TABLE reviews (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

    shirt_id BIGINT UNSIGNED NOT NULL,

    reviewer_name VARCHAR(120) NOT NULL,
    rating TINYINT UNSIGNED NOT NULL,
    review_text TEXT NOT NULL,

    approved TINYINT(1) NOT NULL DEFAULT 0,

    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_reviews_shirt
        FOREIGN KEY (shirt_id)
        REFERENCES shirts(id)
        ON DELETE CASCADE,

    KEY idx_reviews_shirt_approved (
        shirt_id,
        approved
    )
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

Review submission should validate the rating, reviewer name, text length, CSRF token, and rate limits before inserting anything.

Add Database Indexes Deliberately

Indexes help MySQL locate rows without scanning the entire table. They are most useful on columns used frequently in:

  • WHERE filters
  • JOIN conditions
  • ORDER BY clauses
  • Unique lookups
Column Reason
item_number Loads one product from its public URL
slug Supports clean unique product URLs
active Filters products visible in the store
line Filters collection pages
shirt_id Loads variants and reviews for a product
shopify_variant_id Matches checkout variants
printify_variant_id Matches fulfillment variants

Create a Secure PDO Connection

The database connection belongs in a reusable private file.

PHP
<?php
/**
 * /private/connection.php
 */

declare(strict_types=1);

$databaseHost = 'localhost';
$databaseName = 'shirt_store';
$databaseUser = 'shirt_store_user';
$databasePass = 'REPLACE_WITH_A_STRONG_PASSWORD';

$dsn = sprintf(
    'mysql:host=%s;dbname=%s;charset=utf8mb4',
    $databaseHost,
    $databaseName
);

$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
    $pdo = new PDO(
        $dsn,
        $databaseUser,
        $databasePass,
        $options
    );
} catch (PDOException $exception) {
    error_log(
        'Database connection failed: ' .
        $exception->getMessage()
    );

    http_response_code(500);

    exit('The store is temporarily unavailable.');
}

Important PDO settings

Exceptions

PDO::ERRMODE_EXCEPTION makes database failures easier to catch and log.

Associative Results

PDO::FETCH_ASSOC returns rows using column names instead of duplicate numeric indexes.

Native Prepared Statements

Disabling emulated prepares uses MySQL's native prepared statements where supported.

UTF-8

utf8mb4 supports full Unicode, including symbols and emoji.

Keep Credentials Outside Public HTML

The guide pages load the connection file with:

PHP
require_once __DIR__ . '/../../private/connection.php';

That path moves upward from the public guide directory and loads a file stored outside the web root.

Never expose these publicly

Private Configuration

Database password Used only by the server
Shopify token Private API configuration
Printify token Account API authentication
Error logs May contain sensitive debugging details

Load the Product Catalog

The catalog page should request only active products and only the columns it needs.

PHP
<?php

$page     = max(1, (int) ($_GET['page'] ?? 1));
$perPage  = 24;
$offset   = ($page - 1) * $perPage;

$stmt = $pdo->prepare("
    SELECT
        id,
        item_number,
        title,
        slug,
        line,
        category,
        short_description,
        price,
        likes,
        created_at
    FROM shirts
    WHERE active = 1
    ORDER BY item_number DESC
    LIMIT :limit
    OFFSET :offset
");

$stmt->bindValue(
    ':limit',
    $perPage,
    PDO::PARAM_INT
);

$stmt->bindValue(
    ':offset',
    $offset,
    PDO::PARAM_INT
);

$stmt->execute();

$shirts = $stmt->fetchAll();

Pagination prevents the page from loading the entire catalog at once.

Load One Product Safely

The public product page receives an item number:

URL
/shirt.php?item=1001

Validate it before querying the database.

PHP
<?php

$itemNumber = filter_input(
    INPUT_GET,
    'item',
    FILTER_VALIDATE_INT,
    [
        'options' => [
            'min_range' => 1,
        ],
    ]
);

if ($itemNumber === false || $itemNumber === null) {
    http_response_code(404);
    exit('Product not found.');
}

$stmt = $pdo->prepare("
    SELECT
        *
    FROM shirts
    WHERE item_number = :item_number
      AND active = 1
    LIMIT 1
");

$stmt->execute([
    ':item_number' => $itemNumber,
]);

$shirt = $stmt->fetch();

if (!$shirt) {
    http_response_code(404);
    exit('Product not found.');
}
The prepared statement protects the SQL query. Output escaping is still required when the retrieved values are printed into HTML.

Load Available Product Variants

PHP
<?php

$variantStmt = $pdo->prepare("
    SELECT
        id,
        color,
        size,
        price,
        shopify_variant_id,
        printify_variant_id,
        image_url
    FROM shirt_variants
    WHERE shirt_id = :shirt_id
      AND enabled = 1
      AND available = 1
    ORDER BY
        color ASC,
        FIELD(
            size,
            'XS',
            'S',
            'M',
            'L',
            'XL',
            '2XL',
            '3XL'
        )
");

$variantStmt->execute([
    ':shirt_id' => $shirt['id'],
]);

$variants = $variantStmt->fetchAll();

Later, the variants can be passed to JavaScript:

JavaScript
<script>
const variants = <?= json_encode(
    $variants,
    JSON_UNESCAPED_SLASHES |
    JSON_UNESCAPED_UNICODE
) ?>;
</script>

Build Collection Navigation from the Database

The collection navigation can update automatically as active products are added.

PHP
<?php

$allLines = $pdo->query("
    SELECT
        TRIM(line) AS line,
        COUNT(*) AS cnt
    FROM shirts
    WHERE active = 1
      AND line IS NOT NULL
      AND TRIM(line) != ''
    GROUP BY TRIM(line)
    ORDER BY MAX(item_number) DESC
")->fetchAll(PDO::FETCH_ASSOC);

Then render the links:

PHP / HTML
<?php foreach ($allLines as $line): ?>
    <a href="/index.php?line=<?= urlencode($line['line']) ?>">
        <?= htmlspecialchars(
            $line['line'],
            ENT_QUOTES,
            'UTF-8'
        ) ?>

        (<?= (int) $line['cnt'] ?>)
    </a>
<?php endforeach; ?>

Escape Database Output

Prepared statements protect database queries. They do not make database values safe to print inside HTML.

HTML
<h1>
    <?= htmlspecialchars(
        $shirt['title'],
        ENT_QUOTES,
        'UTF-8'
    ) ?>
</h1>
Context Safe Handling
HTML text htmlspecialchars()
URL query value urlencode()
Integer output Explicit integer cast
JSON for JavaScript json_encode()
SQL values Prepared statement parameters

Handle Missing Products Cleanly

A missing or inactive product should return a real HTTP 404 status, not an empty product template with a successful status.

PHP
if (!$shirt) {
    http_response_code(404);

    $pageTitle = 'Product Not Found | The Conspiracy Shirt Company';

    include __DIR__ . '/includes/404-product.php';

    exit;
}

A styled product-not-found page can still include:

  • A link back to the catalog
  • Current collections
  • Popular products
  • A site search

Allow the Database Schema to Evolve

The first schema will not contain every field the store eventually needs. New requirements appear as the catalog and workflows grow.

1
Initial store

Products and Prices

The earliest version needs titles, descriptions, categories, prices, and active status.

2
Product growth

Variants and Images

Colors, sizes, availability, platform IDs, and image mappings become separate structured data.

3
Marketing

SEO and Collections

Metadata, collection copy, related products, and product feeds require additional fields.

4
Automation

Synchronization and Reporting

Platform mappings, update timestamps, error states, counters, and automation controls are added.

Make schema changes through deliberate SQL migrations or documented ALTER statements. Do not casually replace production tables that already contain product and order-related data.

Part 1 Architecture Checklist

  • The public and private directories are separated.
  • Database credentials are outside the public web root.
  • The connection uses UTF-8 with utf8mb4.
  • PDO throws exceptions on database errors.
  • Native prepared statements are enabled.
  • Products and variants use separate tables.
  • Every variant connects to a parent product.
  • Item numbers are unique.
  • Product slugs are unique when used.
  • Shopify and Printify IDs have dedicated fields.
  • Active products can be filtered efficiently.
  • Collection fields are indexed.
  • Variant availability is indexed.
  • Catalog queries use pagination.
  • Product URLs validate the item number.
  • Missing products return a 404 status.
  • SQL values use prepared parameters.
  • HTML output uses context-appropriate escaping.
  • The catalog page and product template are reusable.
  • The schema can be extended without rebuilding the storefront.

PHP and MySQL Store Architecture FAQ

Why build a custom T-shirt store with PHP and MySQL?

PHP and MySQL provide direct control over product data, collections, storefront behavior, SEO fields, custom features, and integrations. They are useful when a standard hosted theme cannot support the desired workflow or presentation.

Why does the storefront use only two main PHP pages?

The catalog page displays products and collections, while one reusable product template displays every shirt. Products are stored as database records, so adding a shirt does not require creating another PHP page.

What is PDO?

PDO is PHP Data Objects, a database-access layer that supports prepared statements, configurable error handling, and a consistent interface for working with databases such as MySQL.

Why should prepared statements be used?

Prepared statements keep SQL instructions separate from user-provided values. They are an important defense against SQL injection and make parameter handling clearer.

Should database credentials be stored in public_html?

No. Database passwords, API tokens, and private configuration should be stored outside the public web root whenever the hosting environment allows it.

Why store product data locally when Shopify and Printify already have it?

The local database can hold storefront-specific descriptions, collections, SEO fields, image paths, likes, reviews, sorting values, and platform mappings. It also avoids external API requests during normal page views.

Should products and variants use separate database tables?

Yes. A product can have many color and size combinations, so variants should normally be stored in a related table rather than adding many repeated columns to the product table.

What is a foreign key?

A foreign key connects a record in one table to a record in another. In this architecture, shirt_variants.shirt_id connects each variant to its parent record in shirts.

Why use an internal numeric ID and an item number?

The internal ID is efficient for database relationships. The item number can remain a stable business-facing identifier used in URLs, image folders, admin tools, and product references.

How are collections represented in the database?

A simple store can use a line or collection field on each product. A more advanced store can use separate collections and product_collection mapping tables when one product belongs to several collections.

What database indexes are important?

Common indexes include unique indexes on item numbers and slugs, and regular indexes on active status, collection, category, creation date, shirt_id, availability, color, and size.

Can this architecture support hundreds of products?

Yes. A normalized schema, appropriate indexes, pagination, local images, caching, and efficient queries can support a substantial catalog.

Does this guide create the Shopify and Printify integrations?

This part creates the local foundation and platform-mapping fields. Shopify checkout and Printify synchronization are covered in later parts of the series.

Continue to Part 2

The database and PHP foundation are now defined. Part 2 uses this structure to build the actual catalog, collection filters, product cards, product pages, image switching, and color and size selection.

0
cart