codeigniter4/framework (^4.7)
Strict PHP 8.2+ typing, native CSRF and Content-Security-Policy filters, View Cells for reusable cart badges, and php spark CLI commands with sub-50ms execution overhead.
PSR-4 Modular Single-TLD Architecture
Instead of scattering catalog, cart, payment, and admin code into a monolithic app/Controllers directory, WinCart maps each bounded domain to its own PSR-4 namespace in app/Config/Autoload.php while keeping swappable storefront templates in themes/.
/modules
├── Catalog/ # Products, Variants, Categories, Media, JSON-LD SEO helpers
├── Cart/ # Session + DB cart persistence, Totals/Tax/Discount pipeline
├── Checkout/ # Stock reservations, PaymentGatewayInterface, ShippingInterface
├── Orders/ # Immutable Order & OrderItem snapshots, State machine, Refunds
├── Customers/ # Saved addresses, Order history (wraps Shield User entities)
├── Admin/ # Store management UI, Metrics, Settings, Order fulfillment
└── Storefront/ # Public controllers, Themeable views, Sitemap, Product feeds
/themes
└── default/ # Swappable storefront templates overriding Storefront viewsModules\* namespaces so themes and payment drivers extend clean boundaries without touching framework internals.First-Party Foundation
WinCart avoids fragile third-party dependency trees by building directly on audited first-party CodeIgniter 4 libraries.
Strict PHP 8.2+ typing, native CSRF and Content-Security-Policy filters, View Cells for reusable cart badges, and php spark CLI commands with sub-50ms execution overhead.
Handles customer registration, guest-to-customer order linking, admin RBAC permission matrices, session hardening, and personal access tokens out of the box.
Persists merchant configuration—currency ISO code, tax basis points, flat-rate shipping thresholds, and SMTP credentials—in MariaDB/MySQL with cached reads and app/Config/ defaults.
Ships with a native database queue driver so order confirmation emails, webhook retries, and receipt generation work reliably on standard PHP-FPM + MySQL hosts without Redis.
Extension Contracts & Lifecycle Events
Third-party Composer packages subscribe to Events::trigger('cart.calculated'), Events::trigger('order.paid'), and Events::trigger('order.shipped') via auto-discovered Config/Events.php files without modifying a single core file.
<?php
declare(strict_types=1);
namespace Modules\Checkout\Contracts;
use CodeIgniter\HTTP\IncomingRequest;
use Modules\Checkout\DTO\OrderSnapshot;
use Modules\Checkout\DTO\PaymentSessionResult;
use Modules\Checkout\DTO\RefundResult;
use Modules\Checkout\DTO\WebhookResult;
use Modules\Orders\Entities\PaymentTransaction;
interface PaymentGatewayInterface
{
public function getIdentifier(): string; // e.g. 'stripe', 'offline'
public function initiatePayment(OrderSnapshot $checkout): PaymentSessionResult;
public function handleWebhook(IncomingRequest $request): WebhookResult;
public function refund(PaymentTransaction $tx, int $amountMinor): RefundResult;
}<?php
declare(strict_types=1);
namespace Modules\Checkout\Services;
final class StockReservationService
{
public function reserveForCheckout(int $cartId, array $items, int $ttlSeconds = 900): void
{
$this->db->transException(true)->transStart();
$now = gmdate('Y-m-d H:i:s');
foreach ($items as $item) {
$variant = $this->db->query(
'SELECT id, stock_qty, track_inventory FROM product_variants WHERE id = ? FOR UPDATE',
[$item['variant_id']]
)->getRowArray();
if ((int) $variant['track_inventory'] === 1) {
$held = $this->sumActiveReservations((int) $variant['id'], $cartId, $now);
$available = (int) $variant['stock_qty'] - $held;
if ($available < (int) $item['qty']) {
throw new InsufficientStockException('Insufficient reservable stock for SKU.');
}
}
$this->upsertReservation($cartId, (int) $item['variant_id'], (int) $item['qty'], $ttlSeconds);
}
$this->db->transComplete();
}
}<?php
declare(strict_types=1);
namespace Modules\Cart\Services;
use CodeIgniter\Events\Events;
final class CartService
{
public function calculateTotals(string $cartToken): CartTotalsSnapshot
{
$lines = $this->cartRepository->findActiveVariantLines($cartToken);
$subtotalMinor = 0;
foreach ($lines as $line) {
// Universal default variant guarantees zero simple-vs-variable branching
$subtotalMinor += (int) $line['price_minor'] * (int) $line['qty'];
}
$discountMinor = $this->couponCalculator->computeDiscountMinor($cartToken, $subtotalMinor);
$taxableMinor = max(0, $subtotalMinor - $discountMinor);
$shippingMinor = $this->shippingCalculator->quoteFlatOrWeightMinor($lines, $taxableMinor);
$taxMinor = (int) round($taxableMinor * $this->settings->taxRateBasisPoints() / 10000);
$snapshot = new CartTotalsSnapshot(
subtotalMinor: $subtotalMinor,
discountMinor: $discountMinor,
shippingMinor: $shippingMinor,
taxMinor: $taxMinor,
grandTotalMinor: $taxableMinor + $shippingMinor + $taxMinor,
);
Events::trigger('cart.calculated', $snapshot);
return $snapshot;
}
}# 1. Clone or create your single-store WinCart project
composer create-project wincart/wincart my-store
cd my-store
# 2. Run the 1-command Spark installer with optional demo catalog
php spark cart:install \
--db-host=127.0.0.1 \
--db-name=wincart \
--db-user=wincart \
--admin-email=merchant@yourdomain.com \
--seed-demo
# 3. Verify locally (sub-50ms SSR + HTMX 2 cart drawer)
php spark serve --port 8080