ab-socks-workflow
Comprehensive engineering workflow for the AB-Socks project: the release & versioning SOP, the layered architecture guide (controller / service / core / view), and the shared-hosting debugging playboo
SKILL.md.claude/skills/ab-socks-workflow/SKILL.md
GitHubAB-Socks Workflow Skill
This skill is the "how" that sits on top of AGENTS.md (the "what must
never break") and CONTRIBUTING.md (commit/versioning conventions). Read
AGENTS.md first if you haven't in this session — this skill assumes its
critical rules (money never written outside a service, snapshots, row
locking, CSRF, config.php untouched, English comments/Persian UI) are
already in effect.
1. Release & Versioning SOP
Follow this checklist, in order, for every change beyond a trivial typo fix. Skipping a step is how the "tag-less releases" and "inconsistent commit titles" problems this skill exists to prevent happened in the first place.
- Classify the change (see
CONTRIBUTING.md→ Versioning):PATCH— bug fix, no schema change, no new user-facing behavior. Also usePATCHfor a pure internal refactor (behavior-preserving) — it changes shipped code, so it still needs a version bump.MINOR— a new feature or a schema addition.MAJOR— breaks an existing integration or removes something (rare for a single-deployment store).- Pure documentation/governance edits (
AGENTS.md,CONTRIBUTING.md,docs/*.md, this skill file) do not need a version bump or aRelease:line — they aren't shipped application behavior.
- Write the code. Follow the layered architecture guide (§2) and the
Boy Scout Rule in
AGENTS.md— don't let a new bloated file ship alongside a release that's supposed to be paying down that same debt. - Schema change? Add a migration.
- New file:
database/migrations/NNN_vX.Y.Z_short-description.sql,NNN= next sequential number (check the highest existing one indatabase/migrations/first — never reuse or edit a past number). - Must be safe to re-run:
CREATE TABLE IF NOT EXISTS,INSERT IGNORE, guardedALTER TABLE(checkinformation_schemabefore adding a column, or useADD COLUMN IF NOT EXISTSwhere the MySQL/MariaDB version supports it). NeverDROP/TRUNCATEexisting data. - Mirror the exact same schema change into
database/schema.sql(the fresh-install baseline) in the same commit — the two must never drift.
- New file:
- Bump
APP_VERSIONinapp/bootstrap.php(semver, per step 1). - Update changelog: add a new dated entry at the top of
docs/CHANGELOG.mdin English. Match the existing heading style:## X.Y.Z — YYYY-MM-DDfollowed by a bold summary line and bullet groups. Keep only the most recent ~5 versions indocs/CHANGELOG.md; archive older releases indocs/CHANGELOG-ARCHIVE.md. - Update architecture docs if system design changed: if a new architectural
invariant, service responsibility, or storage pattern was introduced,
update the relevant domain file in
docs/architecture/<domain>.mdin place. Do not add historical bug diaries. - Run the Quality Gate Verifier:
This runs syntax checks across all PHP files, validates that controllers remain within line-count limits (soft ceiling 80, hard ceiling 120), ensures views contain 0 SQL queries, verifiesphp tools/verify.php.htaccessDirectAdmin compliance, checks thatAPP_VERSIONmatchesdocs/CHANGELOG.md, verifies changelog compact size (<= 7 versions), verifies all modular architecture domain docs are present, and enforces the Anti-Duplication & Front-end Ratchet Baseline (zero new clone functions, inline script budget <= 20 lines in views, and :root CSS variable discipline). Zero errors are required before committing. - Re-read money/stock logic once more if you touched anything in
PricingService.php,GiftService.php,OrderService.php,AccountingService.php, cart/checkout, or anySELECT ... FOR UPDATE/ conditional stockUPDATE— specifically for the concurrent-request case (two customers, or an admin + a customer, acting at the same moment). - Commit using the Conventional Commits format from
CONTRIBUTING.md:type(scope): concise description Release: vX.Y.Z - what changed and why, one bullet per notable change - Migration: NNN_vX.Y.Z_description.sql (if applicable) - Tag and push:
Do this in the same sitting as the commit — a version bump commit without a matching tag is an incomplete release.git tag -a vX.Y.Z -m "Release vX.Y.Z" git push origin main --follow-tags - Create the GitHub Release via GitHub CLI (
gh): Rungh release createdirectly from terminal:
Or point to a temporary release notes markdown file extracted fromgh release create vX.Y.Z --title "Release vX.Y.Z" --notes "### Release vX.Y.Z summary here..."docs/CHANGELOG.md:
Note:gh release create vX.Y.Z --title "Release vX.Y.Z" --notes-file release_notes.mdghis installed on the machine atC:\Program Files\GitHub CLI\gh.exe. If not in PATH, invoke directly.
Quick reference: full command sequence
# 1. Verify syntax and architecture
php tools/verify.php
# 2. Stage and commit
git add -A
git commit -m "feat(scope): concise description" -m "Release: vX.Y.Z" -m "- bullet 1" -m "- bullet 2"
# 3. Tag and push to GitHub
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin main --follow-tags
# 4. Create GitHub Release
gh release create vX.Y.Z --title "Release vX.Y.Z" --notes "Release notes from CHANGELOG..."
2. Layered Architecture Guide
AB-Socks is framework-free but not layer-free. Four layers, one direction of dependency (top depends on bottom, never the reverse):
Front controller (index.php / admin/*.php / ajax/*.php / payment/*.php)
│ routes by $_GET['route'] or filename, no logic
▼
Controller (app/controllers/site|admin/*.php)
│ reads $_GET/$_POST/$_FILES/$_SESSION, calls services/helpers,
│ hands variables to a view. Soft ceiling 80 lines, hard ceiling
│ 120 (see AGENTS.md Boy Scout Rule).
▼
Service / Core helper (app/services/*.php, app/core/*.php)
│ all business logic, all SQL, all validation rules live here.
│ Plain global functions grouped by filename — NOT PHP classes
│ (e.g. PricingService.php defines global functions like
│ recordPriceChange(), not a class with static methods).
▼
View (views/site|admin/*.php)
pure presentation: loops, e()-escaped output, no queries,
no business logic, no $_POST handling.
Where new code goes
| You're adding... | Goes in |
|---|---|
A new site page (e.g. /faq) |
Route in index.php, controller in app/controllers/site/, view in views/site/ |
| A new admin page | Controller in app/controllers/admin/, view in views/admin/, link it from views/admin/layout/ nav |
| A new AJAX endpoint | ajax/*.php — still requires app/bootstrap.php, still needs requireAdmin()/CSRF/prepared statements |
| Logic touching money, stock, gifts, shipping, orders, accounting | The matching existing service (PricingService, GiftService, ShippingService, OrderService, AccountingService) — never inline in a controller |
| A generic, domain-agnostic helper (formatting, slugs, uploads, tags, theme tokens) | app/core/ — see the existing split: functions.php (generic), uploads.php (image upload/rename), tags.php (product tags), theme.php (storefront theme tokens) |
| A brand-new domain concern (e.g. a future loyalty-points system) | A new app/services/XxxService.php, following the existing pattern: global functions, a file-level doc comment explaining the concurrency/snapshot model up front |
Controller pattern (copy this shape for new CRUD pages)
<?php
$id = (int) ($_GET['id'] ?? 0);
$record = $id ? getRecordById($id) : null; // from a service/helper
if ($id && !$record) redirect('list.php');
$pageTitle = $record ? 'ویرایش ...' : '... جدید';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = saveRecordFromRequest($record, $id); // service function does the work
$errors = $result['errors'];
$record = $result['record']; // repopulated on error, for re-rendering the form
}
renderView('admin/record_edit', compact('pageTitle', 'record', 'errors'));
The service function (saveRecordFromRequest above) owns: verifyCsrf(),
field parsing, validation, the DB write (transaction if it touches
stock/price), and either redirect()s on success (matching existing
controllers' behavior) or returns the errors/repopulated data for the
controller to hand to the view. This is the exact shape used by
ProductService::saveProductFromRequest()/app/services/ProductService.php
— read it as the canonical example before writing a new one.
Service function conventions
- Return
['ok' => bool, 'error' => string](orerrorsplural) for operations that can fail — never throw for an expected validation failure; reserve exceptions for truly unexpected states inside a transaction (seeGiftService::assignGiftToOrder()for the pattern:beginTransaction()→ validate with row locks →commit()/rollBack()in atry/catch(Throwable)). - Every function gets a one-line (or short doc-block) comment explaining why, not what — the code already says what.
- Snapshot financial/inventory fields into the target row at write time
(see
AGENTS.mdcritical rule 2) — never store just a foreign key you intend to resolve later.
3. Shared-Hosting Debugging Playbook
Production is Nginx (proxy) → Apache + PHP-FPM (backend) under DirectAdmin, 1.5 GB disk / 200 MB DB / 80 GB bandwidth per month, no shell access implied for the site owner. Keep this playbook in mind before proposing any fix that assumes a VPS-like environment.
"It works locally but 500s in production"
- Suspect
.htaccessfirst. DirectAdmin'sAllowOverrideis restricted toAuthConfig FileInfo Indexes Limit Options=Indexes,.... Anything usingOptions -ExecCGI,php_flag,ForceType, or an<IfModule>nested inside<FilesMatch>will 500 the entire directory, not just the offending request. Checkuploads/.htaccessand the root.htaccessfor anything beyond script execution blocking (<FilesMatch "\.(php|php[3-8]?|phtml|pl|py|cgi|asp|sh)$">) andOptions -Indexes. - Check PHP version compatibility. Production is PHP 8.x only —
verify no PHP 7-only or PHP 8.something-too-new syntax slipped in
(
readonlyproperties, enums, etc. need version confirmation against the actual host's PHP version before use). - Check
app/vendor/is intact. No Composer runs in production — PHPMailer and any other vendored library must be present as committed files, not installed via a build step that never runs there.
PDO / database errors
- Every query is a prepared statement via
db()(app/core/db.php). A raw PDOException reaching the browser means atry/catchboundary is missing — wrap it, log the detail server-side, and show a generic Persian error to the user (seeAGENTS.md→ Boy Scout Rule → defensive programming). - "Table doesn't exist" after a claimed-successful deploy almost always
means a migration
.sqlfile was never actually run through phpMyAdmin's SQL tab — there's no automatic migration runner. Confirm with the site owner which numbered files have actually been applied. - A stock/price race condition bug always traces back to a missing
SELECT ... FOR UPDATEor a plainUPDATEinstead of the conditionalUPDATE ... SET stock = stock - ? WHERE stock >= ?pattern. Grep for the table name acrossapp/services/before assuming new code is the cause.
Uploads & images
.webprequests are routed through/img.php?f=/uploads/...because shared-hosting MIME tables often lackimage/webp, andX-Content-Type-Options: nosniffmakes browsers refuse a mislabeled file. If a newly uploaded.webpdoesn't render, check the root.htaccessrewrite rule is intact and thatimg.phpis being hit (not a direct/uploads/xxx.webpURL bypassing the proxy).- All new image-upload code should call
handleImageUpload()inapp/core/uploads.php(or one of its thin wrappers,handleProductImageUpload()/handleBrandingImageUpload()) instead of hand-rolling MIME/size validation again — this exact duplication (byte for byte, in two different controllers) has happened once already. - Uploaded files must never be trusted by extension alone — validated via
finfo_file()real MIME type, thengetimagesize()(or an explicit<svg>/no-<script>check for SVG) before being written to disk.
Cache / sessions
- Sessions are cookie-based (
session.cookie_httponly,session.cookie_samesite=Lax,session.cookie_securewhen HTTPS) — set inapp/bootstrap.phpbeforesession_start(). Don't add a secondsession_start()anywhere else; it's already global via bootstrap. - Browser caching for static assets and
img.phpresponses is intentionally aggressive (1-year headers + ETag/304 forimg.php). If a content change (e.g. a replaced logo) doesn't show up for the store owner, it's almost always a caching issue, not a code bug — confirm with a hard refresh / incognito window before debugging further.
When you genuinely cannot verify a fix
There is no staging environment. Say so plainly rather than asserting a
production-only issue is fixed. php -l only catches syntax errors — it
proves nothing about .htaccess behavior, live MIME handling, or
concurrency. Where possible, reason through the two-request-at-once case on
paper before shipping a concurrency-sensitive fix.