Plugin API v1.0

TrellisCart
Developer Documentation

A self-hosted PHP ecommerce platform built for full control — no bloat, no lock-in.

PHP 8.0+ MySQL / MariaDB Shared Hosting Ready Plugin System

TrellisCart is a self-hosted PHP shopping cart designed to run on any standard LAMP/LEMP stack including shared hosting. It ships with a full storefront, an admin panel, and a complete plugin system — with no Composer, no NPM, no build tools required.

🔌
Plugin Architecture
Drop a folder in /plugins/ and it appears in the admin panel automatically.
🛡️
Security First
PDO prepared statements, bcrypt passwords, CSRF, CSP headers built in.
💳
Payment Ready
Stripe, PayPal, bank transfer, and COD hooks built into the checkout flow.
📦
Shared Hosting
Works on cPanel hosting. No root access or server config required.

Installation #

Requirements

  • PHP 8.0 or higher (8.3 recommended)
  • MySQL 5.7+ or MariaDB 10.3+
  • PDO MySQL extension
  • cURL extension
  • GD or Imagick extension (for image uploads)
  • Apache with mod_rewrite enabled

Steps

  1. Upload the TrellisCart ZIP to your server and extract it to your web directory (e.g. public_html/cart/trellis/).
  2. Visit yourdomain.com/cart/trellis/install/ in your browser.
  3. Follow the 5-step installer: check requirements, enter database credentials, set store info, create admin account.
  4. The installer writes includes/config.php and runs the full schema automatically.
  5. Log into your admin panel at yourdomain.com/cart/trellis/admin/.
After Installation — Delete or rename the install/ directory after completing setup. TrellisCart blocks the installer once config.php exists, but removing the directory is best practice.

If TrellisCart is installed alongside a WordPress site in the parent directory, add these two lines to the WordPress .htaccess above # BEGIN WordPress:

RewriteCond %{REQUEST_URI} ^/cart/trellis/ [NC]
RewriteRule ^ - [L]

Directory Structure #

trelliscart/                 ← Install root (TC_ROOT)
├── index.php                ← Root entry point
├── .htaccess                ← Apache config, RewriteBase, security
├── includes/
│   ├── bootstrap.php        ← Loaded first by every page
│   ├── config.php           ← DB credentials (auto-generated by installer)
│   ├── functions/
│   │   ├── database.php     ← tc_row/rows/val/insert/update/query/table
│   │   ├── helpers.php      ← tc_money/base/asset/redirect/sanitize/csrf
│   │   ├── cart.php         ← tc_cart_add/get/update/save/restore
│   │   └── auth.php         ← tc_require_admin/customer, login functions
│   └── classes/             ← PSR-4 autoloaded. Drop MyClass.php here.
├── plugins/                 ← YOUR PLUGINS GO HERE
│   └── my-plugin/
│       ├── plugin.json      ← Manifest (name, settings schema)
│       └── plugin.php       ← Plugin class
├── public/
│   ├── index.php            ← Storefront homepage
│   ├── css/                 ← storefront.css, admin.css
│   ├── js/                  ← storefront.js, admin.js
│   └── images/products/     ← Product image uploads
├── admin/                   ← All admin panel pages
├── catalog/                 ← Storefront: category, product, search
├── cart/                    ← Cart AJAX endpoints
├── checkout/                ← Checkout and confirmation
├── account/                 ← Customer account pages
├── templates/
│   ├── admin/               ← sidebar.php, topbar.php
│   └── storefront/          ← header.php, footer.php, product-card.php
├── install/                 ← Web installer (delete after setup)
└── storage/                 ← Backups, cache, logs (not web-accessible)

Configuration #

The installer generates includes/config.php. You can edit it directly:

define('DB_HOST',    'localhost');
define('DB_NAME',    'your_database');
define('DB_USER',    'db_user');
define('DB_PASS',    'db_password');
define('DB_PREFIX',  'tc_');
define('STORE_URL',  'https://yourstore.com/cart/trellis');
define('SECRET_KEY', '64-char-random-hex...');

// Optional: enable full stack traces on errors
// define('TC_DEBUG', true);

All other store settings (currency, tax, shipping, payment keys) are managed through Admin → Store → Configuration and stored in the tc_configuration database table. Access them in code with:

tc_config('store_name', 'My Store');    // returns value or default
tc_config('currency_symbol', '$');          // always use tc_config() not constants
All tc_config() calls are cached per request. Reading the same key 100 times costs one database query.

How Routing Works #

TrellisCart uses direct PHP file routing — each URL maps to a PHP file. The .htaccess RewriteBase ensures all paths resolve correctly regardless of subdirectory depth.

// URL: /cart/trellis/catalog/product.php?slug=my-product
// → Maps directly to catalog/product.php
// No router, no MVC — just a PHP file that loads bootstrap
// and queries the DB based on $_GET['slug']

// All links and redirects use tc_base() to get the install path:
tc_base();  // → "/cart/trellis"
tc_asset('/public/css/admin.css');  // → "/cart/trellis/public/css/admin.css"
Critical for subdirectory installs: Always prefix internal links with <?= tc_base() ?> and asset paths with <?= tc_asset('/path') ?>. Never hardcode /public/css/admin.css — it will break if TrellisCart is installed in a subdirectory.

POST Actions

All form submissions include a hidden CSRF token and are handled server-side before the page renders:

// In every form:
<form method="post">
  <input type="hidden" name="csrf_token" value="<?= e(tc_csrf_token()) ?>">
  ...
</form>

// In the handler:
if (tc_csrf_verify($_POST['csrf_token'] ?? '')) {
    // process form...
}

Database Layer #

TrellisCart uses a thin PDO wrapper. All queries use prepared statements — SQL injection is not possible through the DB functions.

// Fetch a single row (returns array or null)
$product = tc_row(
    "SELECT * FROM " . tc_table('products') . " WHERE product_id=? AND is_active=1",
    [(int)$_GET['id']]
);

// Fetch multiple rows
$orders = tc_rows(
    "SELECT * FROM " . tc_table('orders') . " WHERE customer_id=? ORDER BY order_id DESC",
    [$customerId]
);

// Insert a row (returns new auto-increment ID)
$newId = tc_insert(tc_table('my_table'), [
    'name'    => $name,
    'user_id' => $userId,
]);

// Update rows (returns affected row count)
tc_update(
    tc_table('products'),
    ['is_featured' => 1],   // SET
    'product_id=?',           // WHERE
    [$productId]
);

// Raw query (DELETE, complex WHERE, CREATE TABLE)
tc_query(
    "DELETE FROM " . tc_table('my_table') . " WHERE id=?",
    [$id]
);
Always use tc_table() — never hardcode table names. tc_table('products') returns 'tc_products' using the configured prefix. This allows multiple TrellisCart installs to share a database.

GROUP BY and MySQL Strict Mode

MySQL's ONLY_FULL_GROUP_BY mode is enabled by default in MySQL 5.7+. When writing queries with GROUP BY, wrap any selected columns from joined tables in ANY_VALUE():

// ✗ WRONG — will fail with ONLY_FULL_GROUP_BY error
"SELECT p.*, m.name AS brand FROM tc_products p
 LEFT JOIN tc_manufacturers m ON p.manufacturer_id=m.manufacturer_id
 GROUP BY p.product_id"

// ✓ CORRECT — wrap joined columns in ANY_VALUE()
"SELECT p.*, ANY_VALUE(m.name) AS brand FROM tc_products p
 LEFT JOIN tc_manufacturers m ON p.manufacturer_id=m.manufacturer_id
 GROUP BY p.product_id"

Helper Functions #

These global functions are available everywhere after bootstrap.php loads.

e(mixed $val): string
HTML-escapes a value for safe output. Always use this when printing user data in HTML. Prevents XSS.
tc_base(): string
Returns the URL path to the TrellisCart install. E.g. "/cart/trellis". Cached after first call.
tc_asset(string $path): string
Returns the full URL-path to a file inside the TrellisCart directory. tc_asset('/public/css/admin.css')"/cart/trellis/public/css/admin.css".
tc_redirect(string $url): never
Redirects and exits. Automatically prepends tc_base() for absolute paths starting with /. Do not call tc_base() yourself — it will double-prefix.
tc_config(string $key, string $default = ''): string
Reads a store setting from the database. Results are cached per request.
tc_money(float $amount, string $symbol = null): string
Formats a number as currency using the store's currency symbol. tc_money(9.99)"$9.99".
tc_sanitize(string $input): string
Strips HTML tags and trims whitespace. Use on all user input before storing.
tc_slugify(string $text): string
Converts text to a URL-safe slug. "My Product!""my-product".
tc_csrf_token(): string
Generates a CSRF token (stored in session) and returns it. Output in a hidden field in every form that changes data.
tc_csrf_verify(string $token): bool
Returns true if the submitted token matches the session token. Check this in every POST handler before processing.
tc_json(mixed $data, int $status = 200): never
Sets Content-Type: application/json, encodes the data, and exits. Use this in all AJAX endpoints.
tc_paginate(int $total, int $perPage, int $current): array
Returns [$offset, $totalPages, $currentPage] for building paginated queries and UI.
tc_time_ago(string $datetime): string
Returns a human-readable time string. "just now", "5 min ago", "3 days ago".
tc_stars(float $rating, int $count = 0): string
Returns ★★★★☆ HTML for a rating out of 5. Optional review count in parentheses.
tc_truncate(string $text, int $max = 120): string
Truncates to $max characters and appends ....

Security #

TrellisCart ships with comprehensive security built in. Here's what's active by default:

  • SQL Injection: All queries use PDO prepared statements with EMULATE_PREPARES=false.
  • XSS: All output should go through e(). Never echo user data directly.
  • CSRF: All forms include a CSRF token via tc_csrf_token() / tc_csrf_verify().
  • Passwords: bcrypt hashing via password_hash($pass, PASSWORD_BCRYPT, ['cost' => 12]).
  • Sessions: httponly, SameSite=Lax cookies. Session IDs regenerated on login.
  • Error handling: A global exception handler prevents raw stack traces from leaking to users.
Security Checklist for Plugin Authors: Always use e() when outputting data. Use tc_query/row/rows/insert/update (never string interpolation in SQL). Validate all POST/GET input. Call tc_csrf_verify() in any POST handler. Wrap DB calls in try/catch if the table might not exist yet.

Plugin Overview #

TrellisCart's plugin system lets you extend any part of the cart without editing core files. Plugins are loaded from the plugins/ directory when registered in includes/bootstrap.php.

A minimal plugin is a folder containing one file:

plugins/
└── my-plugin/
    └── my-plugin.php    ← Main file. Loaded from bootstrap.php.

Register it by adding one line to the bottom of includes/bootstrap.php:

if (file_exists(TC_ROOT . '/plugins/my-plugin/my-plugin.php')) {
    require_once TC_ROOT . '/plugins/my-plugin/my-plugin.php';
}

The plugin file runs once per request. Keep it lightweight — define functions and run any one-time setup only if needed.

plugin.json Manifest #

For admin UI integration, create a plugin.json alongside your plugin file. This enables automatic settings forms and admin panel display.

{
  "name":        "My Plugin",
  "slug":        "my-plugin",
  "version":     "1.0.0",
  "description": "Does something awesome.",
  "author":      "Your Name",
  "icon":        "🚀",
  "settings": [
    {
      "key":     "api_key",
      "label":   "API Key",
      "type":    "text",
      "default": "",
      "hint":    "Your API key from example.com"
    },
    {
      "key":     "enabled",
      "label":   "Enable Plugin",
      "type":    "checkbox",
      "default": "1"
    }
  ]
}

Plugin Main File #

A complete plugin file with auto-install, settings storage, and clean structure:

<?php
// plugins/my-plugin/my-plugin.php
// This file is required by bootstrap.php on every request.

// ── One-time install ───────────────────────────────────────────────
if (!tc_config('my_plugin_installed', '')) {
    $sql = file_get_contents(TC_ROOT . '/plugins/my-plugin/install.sql');
    foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) {
        if ($stmt) tc_db()->exec($stmt);
    }
    try {
        tc_insert(tc_table('configuration'), [
            'config_key'   => 'my_plugin_installed',
            'config_value' => '1',
            'group_name'   => 'plugins',
        ]);
    } catch (Exception $e) { /* already exists */ }
}

// ── Plugin functions ───────────────────────────────────────────────

/** Get a plugin-specific config value. */
function my_plugin_config(string $key, string $default = ''): string {
    return tc_config('my_plugin_' . $key, $default);
}

/** Save a plugin-specific config value. */
function my_plugin_set(string $key, string $value): void {
    $k = 'my_plugin_' . $key;
    $exists = tc_val("SELECT config_id FROM " . tc_table('configuration') . " WHERE config_key=?", [$k]);
    if ($exists) tc_update(tc_table('configuration'), ['config_value' => $value], 'config_key=?', [$k]);
    else tc_insert(tc_table('configuration'), ['config_key' => $k, 'config_value' => $value, 'group_name' => 'plugins']);
}

Hooks Reference #

Hooks are the primary way plugins interact with TrellisCart. TrellisCart does not currently have a built-in add_action() system — hooks are implemented by calling your plugin functions at specific points in the code. The recommended pattern is to use function_exists() guards in core files, or to directly include plugin-specific calls in the relevant pages.

The integration points below describe where in the request flow you can insert plugin code. Each one maps to a location where you can add a function_exists() call in the appropriate core file.

Storefront Integration Points

LocationFileWhere to Add
after_product_descriptioncatalog/product.phpBelow the product description block. Receives $product array.
after_product_add_to_cartcatalog/product.phpAfter the Add to Cart button. Use for upsells, warranty add-ons.
storefront_headertemplates/storefront/header.phpJust after <body> opens. Use for announcement banners.
storefront_footertemplates/storefront/footer.phpJust before </body>. Use for chat widgets, analytics.
before_cart_checkoutcart/index.phpAbove the Checkout button. Receives cart items array.
checkout_payment_methodscheckout/index.phpInside the Payment section. Echo radio button HTML for each payment option.
checkout_payment_scriptscheckout/index.phpBottom of checkout page. Echo <script> tags for payment SDKs.

Admin Integration Points

LocationFileWhere to Add
admin_product_edit_extraadmin/catalog/product-edit.phpInside the product edit form. Receives $product array.
admin_product_saveadmin/catalog/product-edit.phpAfter a product is saved. Receives $productId and $_POST.
admin_order_saveadmin/orders/view.phpAfter an order status is updated. Receives $orderId.
admin_customer_viewadmin/customers/view.phpOn the customer detail page. Receives $customer array.
process_paymentcheckout/index.phpDuring order placement. Receives and must return $context array.

Adding a Hook Call to a Core File

// In catalog/product.php, after the description output:
if (function_exists('my_plugin_after_description')) {
    my_plugin_after_description($product);
}

// In your plugin file, define the function:
function my_plugin_after_description(array $product): void {
    echo '<p>Extended warranty available for ' . e($product['name']) . '</p>';
}
Always wrap plugin calls in function_exists() checks. This lets the plugin be disabled without breaking the page.

Plugin Settings #

Store plugin settings in tc_configuration using namespaced keys:

// Read a setting
$apiKey = tc_config('my_plugin_api_key', '');
$enabled = tc_config('my_plugin_enabled', '1');

// Guard: only run if plugin is enabled
if (!tc_config('my_plugin_enabled', '1')) return;

// Write a setting
$exists = tc_val("SELECT config_id FROM " . tc_table('configuration') . " WHERE config_key=?", ['my_plugin_api_key']);
if ($exists) {
    tc_update(tc_table('configuration'), ['config_value' => $value], 'config_key=?', ['my_plugin_api_key']);
} else {
    tc_insert(tc_table('configuration'), ['config_key' => 'my_plugin_api_key', 'config_value' => $value, 'group_name' => 'plugins']);
}

Add a settings page to the admin panel by creating plugins/my-plugin/admin/settings.php and linking it in the sidebar (see Add an Admin Section).

Payment Plugins #

Payment plugins hook into the checkout flow using the checkout_payment_methods, checkout_payment_scripts, and process_payment integration points.

// plugins/my-gateway/my-gateway.php

/** Render the payment radio button at checkout */
function my_gateway_render_option(): void {
    echo '<label><input type="radio" name="payment_method" value="my-gateway"> Pay with My Gateway</label>';
}

/** Process the payment — called during order placement */
function my_gateway_process(array $context): array {
    // Only handle if our method was selected
    if (($_POST['payment_method'] ?? '') !== 'my-gateway') {
        return $context; // not our method — pass through unchanged
    }
    try {
        // Call your gateway API here...
        $context['transaction_id'] = 'txn_123abc';
        $context['payment_method'] = 'My Gateway';
        $context['payment_status'] = 'paid';
    } catch (Throwable $e) {
        $context['error'] = 'Payment failed: ' . $e->getMessage();
    }
    return $context; // always return context
}

Payment Context Array

KeyTypeDescription
totalfloatOrder total to charge (read-only)
emailstringCustomer email (read-only)
transaction_idstringSet this to the gateway transaction ID
payment_methodstringSet this to the display name (e.g. "Stripe")
payment_statusstringSet to "paid" or "pending"
order_statusstringSet to "processing" or "pending"
errorstringSet this on failure — redirects back to checkout with error

Your First Plugin: Sale Banner #

A complete plugin that shows a configurable announcement banner at the top of every storefront page.

File structure

plugins/
└── sale-banner/
    └── sale-banner.php

sale-banner.php

<?php
// plugins/sale-banner/sale-banner.php
// Register in bootstrap.php: require_once TC_ROOT . '/plugins/sale-banner/sale-banner.php';

function sale_banner_render(): void {
    if (!tc_config('sale_banner_enabled', '1')) return;
    $message = tc_config('sale_banner_message', 'Free shipping on orders over $49!');
    $bg      = tc_config('sale_banner_color', '#2d6a4f');
    echo '<div id="sale-banner" style="background:'
       . e($bg)
       . ';color:#fff;text-align:center;padding:.6rem 1rem;font-size:.88rem">'
       . e($message)
       . ' <button onclick="this.parentNode.style.display=\'none\'"'
       . ' style="background:none;border:none;color:#fff;cursor:pointer;margin-left:.5rem">&times;</button></div>';
}

Add it to the storefront header

In templates/storefront/header.php, just after <body> opens:

<?php if (function_exists('sale_banner_render')) sale_banner_render(); ?>

How-To: Add a Storefront Page #

<?php
// plugins/my-plugin/storefront/my-page.php
// URL: /cart/trellis/plugins/my-plugin/storefront/my-page.php
require_once dirname(__DIR__, 3) . '/includes/bootstrap.php';

// Adjust depth: count levels from your file to TC_ROOT
// catalog/product.php      → dirname(__DIR__)
// account/orders.php       → dirname(__DIR__)
// admin/catalog/page.php   → dirname(__DIR__, 2)
// plugins/mine/admin/page  → dirname(__DIR__, 3)

$data = tc_rows('SELECT * FROM ' . tc_table('my_table') . ' ORDER BY name');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My Page — <?= e(tc_config('store_name')) ?></title>
<link rel="stylesheet" href="<?= tc_asset('/public/css/storefront.css') ?>">
</head>
<body>
<?php require_once TC_ROOT . '/templates/storefront/header.php'; ?>

<nav class="tc-breadcrumb"><ol>
  <li><a href="<?= tc_base() ?>/">Home</a></li>
  <li class="active">My Page</li>
</ol></nav>

<div class="tc-section"><div class="tc-container">
  <h1 class="tc-section-title">My <em>Page</em></h1>
  <!-- your content -->
</div></div>

<?php require_once TC_ROOT . '/templates/storefront/footer.php'; ?>
<script src="<?= tc_asset('/public/js/storefront.js') ?>"></script>
</body></html>

How-To: Add an Admin Section #

<?php
// plugins/my-plugin/admin/index.php
require_once dirname(__DIR__, 3) . '/includes/bootstrap.php';
tc_require_admin(); // redirect to login if not authenticated

$success = false;
$errors  = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!tc_csrf_verify($_POST['csrf_token'] ?? '')) {
        $errors[] = 'Invalid token.';
    } else {
        // handle form...
        $success = true;
    }
}

$items = tc_rows('SELECT * FROM ' . tc_table('my_table') . ' ORDER BY id DESC');
?>
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<title>My Plugin — Admin</title>
<link rel="stylesheet" href="<?= tc_asset('/public/css/admin.css') ?>">
</head><body class="tc-admin">
<?php require_once TC_ROOT . '/templates/admin/sidebar.php'; ?>
<div class="tc-admin-main">
  <?php require_once TC_ROOT . '/templates/admin/topbar.php'; ?>
  <div class="tc-admin-content">
    <div class="tc-page-header">
      <div><h1 class="tc-page-title">My Plugin</h1>
      <div class="tc-breadcrumb-admin">Plugins › My Plugin</div></div>
    </div>
    <?php if ($success): ?><div class="tc-alert tc-alert-ok">Saved.</div<?php endif; ?>
    <div class="tc-card">
      <div class="tc-card-header">Items</div>
      <div class="tc-card-body">
        <form method="post">
          <input type="hidden" name="csrf_token" value="<?= e(tc_csrf_token()) ?>">
          <!-- fields -->
        </form>
      </div>
    </div>
  </div>
</div>
<script src="<?= tc_asset('/public/js/admin.js') ?>"></script>
</body></html>

Add to the Sidebar

In templates/admin/sidebar.php, add your link using the _si() and _ss() functions:

<?php
// _si($url, $label, $icon, $currentUri) — main nav item
// _ss($url, $label, $currentUri)        — sub-item
?>
<div class="tc-sidebar-divider"></div>
<div class="tc-sidebar-section">
  <div class="tc-sidebar-label">Plugins</div>
  <?= _si(tc_base().'/plugins/my-plugin/admin/index.php', 'My Plugin', '🔌', $_cur) ?>
  <?= _ss(tc_base().'/plugins/my-plugin/admin/settings.php', 'Settings', $_cur) ?>
</div>

How-To: Add a Product Field #

Use the admin_product_edit_extra and admin_product_save integration points to add meta fields to products.

// 1. Render the field in the product edit form
// Add to admin/catalog/product-edit.php inside the form:
<?php if (function_exists('my_plugin_product_field')) my_plugin_product_field($product); ?>

// In your plugin:
function my_plugin_product_field(array $product): void {
    $val = '';
    if (!empty($product['product_id'])) {
        $row = tc_row("SELECT value FROM " . tc_table('product_meta') .
                       " WHERE product_id=? AND meta_key='my_field'", [$product['product_id']]);
        $val = $row['value'] ?? '';
    }
    echo '<div class="tc-fg">'
       . '<label class="tc-fl">My Custom Field</label>'
       . '<input type="text" name="my_plugin_field" class="tc-fc" value="' . e($val) . '">'
       . '</div>';
}

// 2. Save the field after the product saves
// Add to admin/catalog/product-edit.php after the product is saved:
<?php if (function_exists('my_plugin_product_save')) my_plugin_product_save($productId, $_POST); ?>

function my_plugin_product_save(int $productId, array $post): void {
    $val = tc_sanitize($post['my_plugin_field'] ?? '');
    tc_query(
        "INSERT INTO " . tc_table('product_meta') .
        " (product_id, meta_key, value) VALUES (?,?,?)" .
        " ON DUPLICATE KEY UPDATE value=VALUES(value)",
        [$productId, 'my_field', $val]
    );
}

How-To: Send Emails #

TrellisCart stores SMTP settings in tc_configuration. Use them with PHPMailer (install via Composer, or drop in a single-file version):

function my_plugin_send_email(string $toEmail, string $toName, string $subject, string $body): bool {
    // Try PHPMailer first, fall back to mail()
    if (class_exists('PHPMailer\PHPMailer\PHPMailer')) {
        $mail = new PHPMailer\PHPMailer\PHPMailer(true);
        $mail->isSMTP();
        $mail->Host       = tc_config('smtp_host', 'localhost');
        $mail->SMTPAuth   = true;
        $mail->Username   = tc_config('smtp_user');
        $mail->Password   = tc_config('smtp_pass');
        $mail->SMTPSecure = 'tls';
        $mail->Port       = (int) tc_config('smtp_port', '587');
        $mail->setFrom(tc_config('email_from'), tc_config('email_from_name'));
        $mail->addAddress($toEmail, $toName);
        $mail->isHTML(true);
        $mail->Subject = $subject;
        $mail->Body    = $body;
        return $mail->send();
    }
    // Fallback
    return mail($toEmail, $subject, strip_tags($body));
}

How-To: Rate Limiting #

Use session-based rate limiting to protect any action from abuse:

function my_plugin_rate_limit(string $key, int $max, int $windowSeconds): bool {
    $now       = time();
    $sessionKey = 'rl_' . $key;
    $record    = $_SESSION[$sessionKey] ?? ['count' => 0, 'start' => $now];

    if ($now - $record['start'] > $windowSeconds) {
        $record = ['count' => 0, 'start' => $now]; // reset window
    }
    $record['count']++;
    $_SESSION[$sessionKey] = $record;
    return $record['count'] <= $max;
}

// Usage: allow 5 submissions per 10 minutes
if (!my_plugin_rate_limit('my_action', 5, 600)) {
    $errors[] = 'Too many attempts. Please wait before trying again.';
}

DB Function Reference #

tc_db(): PDO
Returns the singleton PDO connection. Use this for advanced queries not covered by the helper functions.
tc_row(string $sql, array $params = []): ?array
Fetches the first row as an associative array. Returns null if no rows found.
tc_rows(string $sql, array $params = []): array
Fetches all rows as an array of associative arrays. Returns an empty array if no results.
tc_val(string $sql, array $params = []): mixed
Fetches a single scalar value (first column, first row). Returns false if no rows found.
tc_insert(string $table, array $data): int
Inserts a row and returns the new auto-increment ID. $table should be tc_table('name').
tc_update(string $table, array $data, string $where, array $whereParams = []): int
Updates rows matching $where and returns affected row count.
tc_query(string $sql, array $params = []): PDOStatement
Runs any SQL statement. Use for DELETE, complex WHERE clauses, CREATE TABLE, etc.
tc_table(string $name): string
Returns the prefixed table name. tc_table('products')'tc_products'.

Full Helper Reference #

FunctionReturnsDescription
e($val)stringHTML-escape for safe output. Always use on user data.
tc_base()stringURL path to TrellisCart install, e.g. /cart/trellis.
tc_asset($path)stringFull URL to a TrellisCart file: tc_asset('/public/css/admin.css').
tc_redirect($url)neverRedirect and exit. Prepends tc_base() for /-prefixed paths automatically.
tc_config($key, $default)stringRead a store setting. Cached per request.
tc_money($amount, $symbol)stringFormat as currency: tc_money(9.99)"$9.99".
tc_sanitize($input)stringStrip HTML tags, trim whitespace.
tc_slugify($text)stringURL slug: "My Product!""my-product".
tc_csrf_token()stringGenerate/return CSRF token from session.
tc_csrf_verify($token)boolVerify submitted CSRF token.
tc_json($data, $status)neverOutput JSON and exit. For AJAX endpoints.
tc_paginate($total, $per, $page)arrayReturns [$offset, $totalPages, $currentPage].
tc_current_url()stringFull current URL including scheme and query string.
tc_time_ago($datetime)string"just now", "5 min ago", "3 days ago".
tc_stars($rating, $count)string★★★★☆ HTML span with optional review count.
tc_truncate($text, $max)stringTruncate to $max chars with ....
tc_weight($lbs)stringSmart weight display: 0.5"8 oz", 2.5"2.5 lbs".
tc_product_image($filename)stringURL to product image, with fallback to no-image.svg.
tc_admin_log($action, $desc)voidWrite to tc_admin_activity_log. Call when plugin does significant actions.

Configuration Keys #

All keys readable via tc_config('key', 'default'):

KeyGroupDefault
store_namegeneralMy TrellisCart Store
store_emailgeneral
store_ownergeneral
store_taglinegeneral
default_countryregionalUS
default_currencyregionalUSD
timezoneregionalAmerica/Denver
currency_symboldisplay$
products_per_pagedisplay24
tax_ratetax0
free_shipping_minshipping49.00
shipping_flat_rateshipping6.99
shipping_expedited_rateshipping12.99
shipping_overnight_rateshipping24.99
cart_save_enabledcart1
cart_save_dayscart30
guest_checkoutcheckout1
meta_titleseoTrellisCart Store
meta_descriptionseo
email_from_nameemail
email_fromemail
smtp_hostemaillocalhost
smtp_portemail587
smtp_useremail
smtp_passemail
payment_stripe_enabledpayment0
payment_stripe_pkpayment
payment_stripe_skpayment
payment_paypal_enabledpayment0
payment_paypal_emailpayment
payment_cod_enabledpayment0
payment_bank_enabledpayment0

Deployment Notes #

Apache / LiteSpeed

  • TrellisCart's .htaccess sets RewriteBase /cart/trellis/ — update this if you move the install path.
  • If running WordPress at the domain root, add to WordPress's .htaccess above # BEGIN WordPress:
RewriteCond %{REQUEST_URI} ^/cart/trellis/ [NC]
RewriteRule ^ - [L]

Subdirectory Installs

If STORE_URL is set correctly in config.php, tc_base() auto-detects the subdirectory path. If links are broken, verify STORE_URL matches the exact URL you use to access the store.

PHP Error Logging

Add define('TC_DEBUG', true); to includes/config.php to show full stack traces on error pages. Remove before going to production. Error details are also written to your PHP error log, typically at public_html/error_log on cPanel hosting.

Storage Permissions

These directories must be writable by the web server:

  • includes/ — installer writes config.php here
  • public/images/products/ — product image uploads
  • storage/ — backups and cache

Set with: chmod 755 includes/ public/images/ storage/

$product Array #

The $product array is passed to product-related integration points. It maps directly to the tc_products table row plus any JOINed data.

$product = [
    // Identity
    'product_id'       => 42,
    'sku'              => 'TC-001',
    'name'             => 'Blue Spruce Seedling',
    'slug'             => 'blue-spruce-seedling',
    // Content
    'description'      => '<p>Full HTML description.</p>',
    'short_desc'       => 'Brief description.',
    'meta_title'       => 'SEO title',
    'meta_desc'        => 'SEO description',
    // Pricing
    'price'            => '29.99',    // always a string from DB
    'sale_price'       => '24.99',    // null if no sale
    'cost_price'       => '10.00',    // internal use only
    // Inventory
    'stock_qty'        => '50',
    'stock_low_alert'  => '5',
    'track_stock'      => '1',
    // Shipping
    'weight_lbs'       => '1.500',
    'length_in'        => '10.00',
    'width_in'         => '6.00',
    'height_in'        => '4.00',
    // Flags
    'is_active'        => '1',
    'is_featured'      => '0',
    'is_new'           => '0',
    // Media
    'image'            => 'product_abc123.jpg',  // filename, may be null
    // JOINed
    'brand_name'       => 'TrellisCart Nursery',  // from tc_manufacturers
    'effective_price'  => '24.99',  // COALESCE(sale_price, price)
    // Timestamps
    'created_at'       => '2024-01-15 09:30:00',
    'updated_at'       => '2024-06-01 14:22:00',
];

// Tip: All values are strings from PDO. Cast explicitly:
(float)$product['price']      // → 29.99 (float)
(int)$product['stock_qty']  // → 50 (int)

$category Array #

$category = [
    'category_id'  => 5,
    'parent_id'    => 2,           // null if top-level
    'name'         => 'Trees',
    'slug'         => 'trees',
    'description'  => '<p>HTML description.</p>',
    'image'        => 'cat_trees.jpg',  // null if no image
    'meta_title'   => 'SEO title',
    'meta_desc'    => 'SEO description',
    'sort_order'   => '10',
    'is_active'    => '1',
    'is_featured'  => '0',
];

$user / $customer Array #

$customer = [
    'customer_id'  => 12,
    'group_id'     => 1,            // 1=Standard, 2=VIP, 3=Wholesale
    'firstname'    => 'Jane',
    'lastname'     => 'Smith',
    'email'        => 'jane@example.com',
    'phone'        => '555-1234',
    'newsletter'   => '1',
    'is_active'    => '1',
    'last_login'   => '2024-06-01 09:00:00',
    'created_at'   => '2024-01-01 00:00:00',
    // password_hash is never returned in queries — use password_verify() for checks
];

$order Array #

$order = [
    'order_id'        => 101,
    'customer_id'     => 12,       // null for guest orders
    'status_id'       => 2,        // 1=Pending,2=Processing,3=Shipped,4=Delivered
    'firstname'       => 'Jane',
    'lastname'        => 'Smith',
    'email'           => 'jane@example.com',
    'phone'           => '555-1234',
    'ship_address1'   => '123 Main St',
    'ship_city'       => 'Denver',
    'ship_state'      => 'CO',
    'ship_postcode'   => '80201',
    'ship_country'    => 'US',
    'ship_method'     => 'standard',
    'subtotal'        => '89.97',   // always strings from DB
    'shipping_cost'   => '6.99',
    'tax_amount'      => '0.00',
    'discount_amount' => '0.00',
    'total'           => '96.96',
    'coupon_code'     => 'SAVE10',  // null if no coupon
    'payment_method'  => 'Stripe',
    'payment_ref'     => 'txn_abc123',
    'tracking_number' => '9400111899223',  // null until shipped
    'customer_notes'  => 'Leave at door.',
    'ip_address'      => '192.168.1.1',
    'created_at'      => '2024-06-01 14:22:00',
];

// Fetch order items separately:
$items = tc_rows("SELECT * FROM " . tc_table('order_items') . " WHERE order_id=?", [$order['order_id']]);
// Each item: order_id, product_id, sku, name, options_text, quantity, unit_price, total_price

Full Admin Plugin Example #

A complete plugin that adds a custom badge label to products, shows it on the storefront, and adds a management page to the admin panel.

install.sql

CREATE TABLE IF NOT EXISTS `tc_product_badges` (
  `product_id`  INT UNSIGNED NOT NULL,
  `badge_text`  VARCHAR(30)  DEFAULT NULL,
  `badge_color` VARCHAR(20)  DEFAULT NULL,
  PRIMARY KEY (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

product-badges.php

<?php
// plugins/product-badges/product-badges.php
if (!tc_config('product_badges_installed', '')) {
    tc_db()->exec(file_get_contents(TC_ROOT . '/plugins/product-badges/install.sql'));
    try { tc_insert(tc_table('configuration'),['config_key'=>'product_badges_installed','config_value'=>'1','group_name'=>'plugins']); } catch(Exception $e){}
}

/** Render badge on product page (call from catalog/product.php) */
function pb_show_badge(array $product): void {
    try {
        $row = tc_row("SELECT * FROM ".tc_table('product_badges')." WHERE product_id=?", [$product['product_id']]);
    } catch (Throwable $e) { return; }
    if (!$row || empty($row['badge_text'])) return;
    $color = $row['badge_color'] ?: '#ef4444';
    echo '<span style="background:'.e($color).';color:#fff;padding:.25rem .7rem;border-radius:4px;font-size:.8rem;font-weight:700">'.e($row['badge_text']).'</span>';
}

/** Render edit field in admin product form */
function pb_product_field(array $product): void {
    try { $row = tc_row("SELECT * FROM ".tc_table('product_badges')." WHERE product_id=?", [$product['product_id'] ?? 0]); }
    catch (Throwable $e) { $row = null; }
    echo '<div class="tc-fg"><label class="tc-fl">Badge Label</label>'
       . '<input type="text" name="pb_badge" class="tc-fc" value="'.e($row['badge_text'] ?? '').'" placeholder="NEW, HOT, LIMITED"></div>';
}

/** Save badge when product is saved */
function pb_product_save(int $productId, array $post): void {
    $text = tc_sanitize($post['pb_badge'] ?? '');
    try {
        if ($text) tc_query("INSERT INTO ".tc_table('product_badges')." (product_id,badge_text) VALUES(?,?) ON DUPLICATE KEY UPDATE badge_text=VALUES(badge_text)", [$productId,$text]);
        else tc_query("DELETE FROM ".tc_table('product_badges')." WHERE product_id=?", [$productId]);
    } catch (Throwable $e) {}
}

Hook it in

// In catalog/product.php, below product description:
<?php if (function_exists('pb_show_badge')) pb_show_badge($product); ?>

// In admin/catalog/product-edit.php, inside the form:
<?php if (function_exists('pb_product_field')) pb_product_field($product ?? []); ?>

// In admin/catalog/product-edit.php, after tc_insert/tc_update:
<?php if (function_exists('pb_product_save')) pb_product_save($productId, $_POST); ?>

Debugging Plugins #

Enable Debug Mode

Add to includes/config.php temporarily:

define('TC_DEBUG', true); // Shows full stack trace on error pages

Common Errors

SymptomCauseFix
500 error, white pagePHP fatal or parse errorCheck PHP error log. Add TC_DEBUG temporarily.
ONLY_FULL_GROUP_BY SQL errorNon-aggregated column in GROUP BY queryWrap joined columns: ANY_VALUE(m.name)
CSS not loadingHardcoded /public/css/ pathUse tc_asset('/public/css/admin.css')
Links go to wrong URLMissing tc_base() prefixAll links: href="<?= tc_base() ?>/path"
Redirect loop after redirectDouble-prefixing tc_base()Use tc_redirect('/admin/') not tc_redirect(tc_base().'/admin/')
CSRF verification failsForm missing token, or token field name wrongAdd <input type="hidden" name="csrf_token" value="<?= e(tc_csrf_token()) ?>">
Plugin function never runsPlugin not registered in bootstrap.phpAdd require_once TC_ROOT . '/plugins/mine/mine.php'; to bootstrap
DB error: table doesn't existPlugin install code didn't runWrap DB calls in try/catch, or use CREATE TABLE IF NOT EXISTS
Product card brokenWrong variable name passed to templateTemplate requires variable named exactly $prod
Wrong bootstrap depthWrong dirname(__DIR__, N) countCount levels from file to TC_ROOT. admin/page.php = 2, plugins/x/admin/page.php = 3

Minimal Working Plugin Test

Strip your plugin to this and confirm output before adding complexity:

<?php
// plugins/my-plugin/my-plugin.php
// If you see the HTML comment in view-source, the plugin loads correctly.
function my_plugin_footer(): void {
    echo '<!-- my-plugin is alive -->';
}

Then in templates/storefront/footer.php: <?php if (function_exists('my_plugin_footer')) my_plugin_footer(); ?>

View page source and search for <!-- my-plugin is alive -->. If it appears, your plugin loads correctly.

Plugin Author Checklist #

File Structure

  • Plugin is registered with require_once in includes/bootstrap.php
  • Bootstrap include uses the correct dirname(__DIR__, N) depth for your file's location
  • One-time install uses CREATE TABLE IF NOT EXISTS — never bare CREATE TABLE

Output & Templates

  • All user data output through e() — no direct echo $user_value
  • All asset paths use tc_asset() — no hardcoded /public/css/...
  • All internal links prefixed with tc_base()
  • Product card template called with variable named exactly $prod

Forms & CSRF

  • Every form includes <input type="hidden" name="csrf_token" value="<?= e(tc_csrf_token()) ?>">
  • Every POST handler calls tc_csrf_verify($_POST['csrf_token'] ?? '') first
  • After handling POST, always redirect (Post/Redirect/Get pattern)

Authentication

  • Every admin page calls tc_require_admin() before any logic
  • Customer-only pages call tc_require_customer()
  • Ownership checks include the customer/admin ID in the WHERE clause

Database

  • All table references use tc_table('name') — no hardcoded tc_ prefix
  • All queries use parameterized statements — no string concatenation of user input
  • DB calls that might fail (table not yet created) wrapped in try/catch
  • GROUP BY queries wrap joined columns in ANY_VALUE()

Integration Points

  • All integration point calls guarded with function_exists()
  • Plugin function names are namespaced to avoid collisions: myplugin_do_thing() not do_thing()
  • Admin integration points check ownership before modifying data

API Versioning #

Current Plugin API: v1.0 (Stable)

API SurfaceStatusNotes
All documented integration point namesStableWill not be renamed or removed in v1.x
Core array keys ($product, $order, $category, $customer)StableExisting keys will not be removed. Guard new keys with ?? null
All db helper functions (tc_row, tc_rows, tc_insert, etc.)StableMethod signatures will not change
All documented helper functionsStabletc_base, tc_asset, tc_config, tc_money, etc.
DB_PREFIX constantStableAlways use tc_table() — value can change per install
Admin CSS class namesBest EffortMay change between minor versions as UI evolves
Database schema of core tablesBest EffortColumns will not be removed. New columns may be added. Never rely on SELECT * column order.
Internal function names (renderProduct, etc.)UnstableInternal functions are subject to change. Only call documented helpers.
Template file structureUnstableUse integration points rather than editing template files. Updates may restructure templates.

Defensive Coding

// Check if a function exists before calling it
if (function_exists('some_other_plugin_function')) {
    some_other_plugin_function($args);
}

// Check if a table exists before querying it
try {
    $rows = tc_rows("SELECT * FROM " . tc_table('my_table'));
} catch (Throwable $e) {
    $rows = []; // table not yet installed
}

// Guard new array keys
$newField = $product['new_field_added_in_v1_1'] ?? null;

Plugin Versioning

Always increment your plugin's version in plugin.json when releasing updates. Use semantic versioning: MAJOR.MINOR.PATCH.

  • PATCH (1.0.1) — Bug fixes, no new features, backward compatible
  • MINOR (1.1.0) — New features, backward compatible
  • MAJOR (2.0.0) — Breaking changes, existing installs may need migration