TrellisCart
Developer Documentation
A self-hosted PHP ecommerce platform built for full control — no bloat, no lock-in.
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.
/plugins/ and it appears in the admin panel automatically.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_rewriteenabled
Steps
- Upload the TrellisCart ZIP to your server and extract it to your web directory (e.g.
public_html/cart/trellis/). - Visit
yourdomain.com/cart/trellis/install/in your browser. - Follow the 5-step installer: check requirements, enter database credentials, set store info, create admin account.
- The installer writes
includes/config.phpand runs the full schema automatically. - Log into your admin panel at
yourdomain.com/cart/trellis/admin/.
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
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"
<?= 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] );
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.
"/cart/trellis". Cached after first call.tc_asset('/public/css/admin.css') → "/cart/trellis/public/css/admin.css".tc_base() for absolute paths starting with /. Do not call tc_base() yourself — it will double-prefix.tc_money(9.99) → "$9.99"."My Product!" → "my-product".true if the submitted token matches the session token. Check this in every POST handler before processing.Content-Type: application/json, encodes the data, and exits. Use this in all AJAX endpoints.[$offset, $totalPages, $currentPage] for building paginated queries and UI."just now", "5 min ago", "3 days ago".$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(). Neverechouser 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.
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.
function_exists() call in the appropriate core file.Storefront Integration Points
| Location | File | Where to Add |
|---|---|---|
after_product_description | catalog/product.php | Below the product description block. Receives $product array. |
after_product_add_to_cart | catalog/product.php | After the Add to Cart button. Use for upsells, warranty add-ons. |
storefront_header | templates/storefront/header.php | Just after <body> opens. Use for announcement banners. |
storefront_footer | templates/storefront/footer.php | Just before </body>. Use for chat widgets, analytics. |
before_cart_checkout | cart/index.php | Above the Checkout button. Receives cart items array. |
checkout_payment_methods | checkout/index.php | Inside the Payment section. Echo radio button HTML for each payment option. |
checkout_payment_scripts | checkout/index.php | Bottom of checkout page. Echo <script> tags for payment SDKs. |
Admin Integration Points
| Location | File | Where to Add |
|---|---|---|
admin_product_edit_extra | admin/catalog/product-edit.php | Inside the product edit form. Receives $product array. |
admin_product_save | admin/catalog/product-edit.php | After a product is saved. Receives $productId and $_POST. |
admin_order_save | admin/orders/view.php | After an order status is updated. Receives $orderId. |
admin_customer_view | admin/customers/view.php | On the customer detail page. Receives $customer array. |
process_payment | checkout/index.php | During 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>'; }
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
| Key | Type | Description |
|---|---|---|
total | float | Order total to charge (read-only) |
email | string | Customer email (read-only) |
transaction_id | string | Set this to the gateway transaction ID |
payment_method | string | Set this to the display name (e.g. "Stripe") |
payment_status | string | Set to "paid" or "pending" |
order_status | string | Set to "processing" or "pending" |
error | string | Set 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.phpsale-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">×</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 #
null if no rows found.false if no rows found.$table should be tc_table('name').$where and returns affected row count.DELETE, complex WHERE clauses, CREATE TABLE, etc.tc_table('products') → 'tc_products'.Full Helper Reference #
| Function | Returns | Description |
|---|---|---|
e($val) | string | HTML-escape for safe output. Always use on user data. |
tc_base() | string | URL path to TrellisCart install, e.g. /cart/trellis. |
tc_asset($path) | string | Full URL to a TrellisCart file: tc_asset('/public/css/admin.css'). |
tc_redirect($url) | never | Redirect and exit. Prepends tc_base() for /-prefixed paths automatically. |
tc_config($key, $default) | string | Read a store setting. Cached per request. |
tc_money($amount, $symbol) | string | Format as currency: tc_money(9.99) → "$9.99". |
tc_sanitize($input) | string | Strip HTML tags, trim whitespace. |
tc_slugify($text) | string | URL slug: "My Product!" → "my-product". |
tc_csrf_token() | string | Generate/return CSRF token from session. |
tc_csrf_verify($token) | bool | Verify submitted CSRF token. |
tc_json($data, $status) | never | Output JSON and exit. For AJAX endpoints. |
tc_paginate($total, $per, $page) | array | Returns [$offset, $totalPages, $currentPage]. |
tc_current_url() | string | Full 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) | string | Truncate to $max chars with .... |
tc_weight($lbs) | string | Smart weight display: 0.5 → "8 oz", 2.5 → "2.5 lbs". |
tc_product_image($filename) | string | URL to product image, with fallback to no-image.svg. |
tc_admin_log($action, $desc) | void | Write to tc_admin_activity_log. Call when plugin does significant actions. |
Configuration Keys #
All keys readable via tc_config('key', 'default'):
| Key | Group | Default |
|---|---|---|
store_name | general | My TrellisCart Store |
store_email | general | |
store_owner | general | |
store_tagline | general | |
default_country | regional | US |
default_currency | regional | USD |
timezone | regional | America/Denver |
currency_symbol | display | $ |
products_per_page | display | 24 |
tax_rate | tax | 0 |
free_shipping_min | shipping | 49.00 |
shipping_flat_rate | shipping | 6.99 |
shipping_expedited_rate | shipping | 12.99 |
shipping_overnight_rate | shipping | 24.99 |
cart_save_enabled | cart | 1 |
cart_save_days | cart | 30 |
guest_checkout | checkout | 1 |
meta_title | seo | TrellisCart Store |
meta_description | seo | |
email_from_name | ||
email_from | ||
smtp_host | localhost | |
smtp_port | 587 | |
smtp_user | ||
smtp_pass | ||
payment_stripe_enabled | payment | 0 |
payment_stripe_pk | payment | |
payment_stripe_sk | payment | |
payment_paypal_enabled | payment | 0 |
payment_paypal_email | payment | |
payment_cod_enabled | payment | 0 |
payment_bank_enabled | payment | 0 |
Deployment Notes #
Apache / LiteSpeed
- TrellisCart's
.htaccesssetsRewriteBase /cart/trellis/— update this if you move the install path. - If running WordPress at the domain root, add to WordPress's
.htaccessabove# 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 writesconfig.phpherepublic/images/products/— product image uploadsstorage/— 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
| Symptom | Cause | Fix |
|---|---|---|
| 500 error, white page | PHP fatal or parse error | Check PHP error log. Add TC_DEBUG temporarily. |
ONLY_FULL_GROUP_BY SQL error | Non-aggregated column in GROUP BY query | Wrap joined columns: ANY_VALUE(m.name) |
| CSS not loading | Hardcoded /public/css/ path | Use tc_asset('/public/css/admin.css') |
| Links go to wrong URL | Missing tc_base() prefix | All links: href="<?= tc_base() ?>/path" |
| Redirect loop after redirect | Double-prefixing tc_base() | Use tc_redirect('/admin/') not tc_redirect(tc_base().'/admin/') |
| CSRF verification fails | Form missing token, or token field name wrong | Add <input type="hidden" name="csrf_token" value="<?= e(tc_csrf_token()) ?>"> |
| Plugin function never runs | Plugin not registered in bootstrap.php | Add require_once TC_ROOT . '/plugins/mine/mine.php'; to bootstrap |
| DB error: table doesn't exist | Plugin install code didn't run | Wrap DB calls in try/catch, or use CREATE TABLE IF NOT EXISTS |
| Product card broken | Wrong variable name passed to template | Template requires variable named exactly $prod |
| Wrong bootstrap depth | Wrong dirname(__DIR__, N) count | Count 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_onceinincludes/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 bareCREATE TABLE
Output & Templates
- All user data output through
e()— no directecho $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 hardcodedtc_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()notdo_thing() - Admin integration points check ownership before modifying data
API Versioning #
Current Plugin API: v1.0 (Stable)
| API Surface | Status | Notes |
|---|---|---|
| All documented integration point names | Stable | Will not be renamed or removed in v1.x |
| Core array keys ($product, $order, $category, $customer) | Stable | Existing keys will not be removed. Guard new keys with ?? null |
| All db helper functions (tc_row, tc_rows, tc_insert, etc.) | Stable | Method signatures will not change |
| All documented helper functions | Stable | tc_base, tc_asset, tc_config, tc_money, etc. |
| DB_PREFIX constant | Stable | Always use tc_table() — value can change per install |
| Admin CSS class names | Best Effort | May change between minor versions as UI evolves |
| Database schema of core tables | Best Effort | Columns will not be removed. New columns may be added. Never rely on SELECT * column order. |
| Internal function names (renderProduct, etc.) | Unstable | Internal functions are subject to change. Only call documented helpers. |
| Template file structure | Unstable | Use 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