This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from PHP: The Right Way and adapted into practical project instructions.
Source reference: https://phptherightway.com/
Write PHP that is:
PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style.
Use the current stable PHP version supported by the project.
Default expectation:
PHP 8.x+
Do not introduce code that depends on unsupported PHP versions unless the project explicitly targets a legacy runtime.
When adding a language feature, verify that it is supported by the project’s configured PHP version in composer.json.
Example:
{
"require": {
"php": ">=8.2"
}
}
Follow recognized PHP standards unless the repository already defines stricter rules.
Preferred standards:
Use automated tooling rather than manual formatting arguments.
Recommended tools:
composer require --dev squizlabs/php_codesniffer
composer require --dev friendsofphp/php-cs-fixer
Example checks:
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/php-cs-fixer fix --dry-run --diff
Example fix:
vendor/bin/php-cs-fixer fix
Use English names for code symbols and infrastructure.
Use:
class InvoiceRepository
{
public function findByCustomerId(int $customerId): array
{
// ...
}
}
Avoid unclear abbreviations:
class InvRepo
{
public function fbcid($cid)
{
// ...
}
}
<?php tags. Do not use short open tags.declare(strict_types=1);
public, protected, or private.Prefer a predictable structure.
Example:
project-root/
public/
index.php
src/
Controller/
Service/
Repository/
Entity/
ValueObject/
templates/
config/
tests/
var/
cache/
logs/
vendor/
composer.json
Rules:
public/ is the web root.src/, config/, tests/, vendor/, or .env files through the web server.src/.var/ or another ignored runtime directory.All new application classes must use namespaces.
Use PSR-4 autoloading through Composer.
Example composer.json:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
After changing autoload rules, run:
composer dump-autoload
Example class:
<?php
declare(strict_types=1);
namespace App\Service;
final class InvoiceCalculator
{
public function calculateTotal(array $items): int
{
// Return cents, not floating-point dollars.
return array_sum(array_column($items, 'amountCents'));
}
}
Use Composer for PHP dependencies.
Rules:
composer require or composer require --dev.composer.json and composer.lock for applications.vendor/.Commands:
composer install
composer update vendor/package
composer audit
composer validate
Use composer update intentionally. Do not casually update every dependency in unrelated work.
Prefer clear object-oriented code for domain and application logic.
Use classes for cohesive behavior:
final class CustomerName
{
public function __construct(private string $value)
{
if (trim($value) === '') {
throw new InvalidArgumentException('Customer name is required.');
}
}
public function value(): string
{
return $this->value;
}
}
Guidelines:
Prefer dependency injection over creating dependencies inside classes.
Good:
final class RegisterUser
{
public function __construct(
private UserRepository $users,
private PasswordHasher $passwords
) {
}
public function handle(string $email, string $plainPassword): void
{
$hash = $this->passwords->hash($plainPassword);
$this->users->create($email, $hash);
}
}
Avoid:
final class RegisterUser
{
public function handle(string $email, string $plainPassword): void
{
$users = new UserRepository();
$passwords = new PasswordHasher();
// ...
}
}
Rules:
Use PDO or a well-maintained database abstraction layer/ORM.
Never concatenate untrusted input into SQL.
Bad:
$sql = "SELECT * FROM users WHERE id = " . $_GET['id'];
Good:
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Rules:
Transaction example:
$pdo->beginTransaction();
try {
$orders->create($order);
$auditLog->record('order.created', $order->id());
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
Treat all external data as untrusted.
Untrusted data includes:
$_GET$_POST$_REQUEST$_COOKIE$_SERVERExample:
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
if ($email === false || $email === null) {
throw new InvalidArgumentException('A valid email address is required.');
}
For HTML output:
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
Usage:
<p><?= e($user->name()) ?></p>
Rules:
escapeshellarg() when passing controlled values to shell commands, and avoid shell execution when possible.../, /, \, and null bytes.Never store plain-text passwords.
Use PHP’s password API:
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
if (! password_verify($plainPassword, $hash)) {
throw new RuntimeException('Invalid credentials.');
}
Rules:
password_hash() for new password hashes.password_verify() for login checks.password_needs_rehash() when algorithm/cost settings change.md5, sha1, or raw sha256 for passwords.Do not call unserialize() on untrusted data.
Prefer JSON for data exchange:
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
$json = json_encode($data, JSON_THROW_ON_ERROR);
Rules:
JSON_THROW_ON_ERROR for new code.Rules:
.env.example.Example .gitignore entries:
.env
.env.local
/config/local.php
/var/cache/
/var/log/
/vendor/
Example .env.example:
APP_ENV=local
APP_DEBUG=true
DATABASE_URL=mysql://user:password@localhost:3306/app
Use exceptions for exceptional failure paths.
Development:
Production:
Do not leak:
Example:
try {
$service->handle($request);
} catch (Throwable $e) {
$logger->error('Order processing failed.', [
'exception' => $e,
'requestId' => $requestId,
]);
http_response_code(500);
echo 'An unexpected error occurred.';
}
Keep presentation separate from business logic.
Rules:
Plain PHP template example:
<h1><?= e($pageTitle) ?></h1>
<ul>
<?php foreach ($users as $user): ?>
<li><?= e($user->name()) ?></li>
<?php endforeach; ?>
</ul>
Rules:
X-Forwarded-For unless configured behind a trusted proxy.Example POST guard:
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
Before completing any feature, verify:
password_hash() and password_verify().eval, exec, shell_exec, system, passthru, unserialize.composer audit.Automated tests are expected for new behavior.
Preferred tools:
Rules:
var_dump() or manual browser testing as the only verification.Example PHPUnit test:
final class InvoiceCalculatorTest extends TestCase
{
public function testItCalculatesTotalInCents(): void
{
$calculator = new InvoiceCalculator();
$total = $calculator->calculateTotal([
['amountCents' => 1000],
['amountCents' => 2500],
]);
self::assertSame(3500, $total);
}
}
Run tests:
vendor/bin/phpunit
Use static analysis when available.
Recommended tools:
composer require --dev phpstan/phpstan
composer require --dev vimeo/psalm
Common quality commands:
composer validate
composer audit
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/phpunit
vendor/bin/phpstan analyse src tests
Do not ignore tool failures without documenting why.
Use PHPDoc where it adds clarity, especially for arrays, generics-like structures, complex return values, and public APIs.
Good:
/**
* @return list<Customer>
*/
public function findActiveCustomers(): array
{
// ...
}
Avoid noisy comments that repeat the code:
// Increment i by one.
$i++;
Rules:
Rules:
When modifying this codebase, the AI agent must:
Before considering work complete:
If this project contains legacy PHP:
Legacy code should still move toward:
The agent must not:
md5, sha1, or raw fast hashes for passwords.unserialize() untrusted data.vendor/.A project may include scripts like this:
{
"scripts": {
"test": "phpunit",
"style": "phpcs --standard=PSR12 src tests",
"style:fix": "php-cs-fixer fix",
"analyse": "phpstan analyse src tests",
"quality": [
"@style",
"@test",
"@analyse"
]
}
}
Then run:
composer quality
When in doubt, choose the boring, obvious, secure PHP solution:
This project is a small PHP MVC framework called MindVisionCode PHP.
It is intentionally inspired by a Classic ASP MVC framework style:
Do not turn this into Laravel, Symfony, Slim, or another large framework.
Install dependencies:
composer install
Regenerate autoload files:
composer dump-autoload
Run local server:
php -S localhost:8000 -t public
Run basic tests:
php tests/run.php
Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response
When you create tables and code for a table you will need a corrisponding _audit table with audit_id , id that refrences the object id , an action R I U D , the fields in json , username and created at
Visual direction: calm operational clarity with high-signal status semantics.
Core palette:
#1F4E79 (municipal navy; trust and structure)#0F766E (teal support/action)#2563EB (interactive focus/action emphasis)Semantic status palette:
#2E7D32#B45309#B91C1C#2563EB#7F1D1DNeutral foundation:
#F7F9FC#FFFFFF#D0D7E2#111827#4B5563Ant Design token strategy:
colorPrimary, colorSuccess, colorWarning, colorError, colorInfo, colorBgBase, colorText, borderRadius, density-related sizing).Tone: professional, clear, non-decorative, optimized for dense operational reading.
Type stack:
Public Sans, Segoe UI, Arial, sans-serifIBM Plex Sans, Segoe UI, sans-serifIBM Plex Mono, Consolas, monospaceType hierarchy (desktop-first):
Typography principles:
Layout direction: compact, structured, desktop-optimized operational workspace.
Spacing system:
Grid and containers:
Component density rules:
Visual rhythm:
These rules define the established UI patterns for this project. Follow them exactly when building new pages, views, or components. Do not invent new patterns — extend the existing ones.
Link structure
Every nav link must be an <a class="nav-link"> with:
fill="none", stroke="currentColor", stroke-width="1.75", Heroicons-style path).aria-hidden="true" on the SVG.<a class="nav-link" href="/campaigns">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="..."/>
</svg>
Campaigns
</a>
Group separators
Nav links are divided into logical groups separated by <span class="nav-sep" aria-hidden="true"></span>:
Add a separator between groups and before the auth area. Never put a separator at the very start or end of the nav.
Active state
Add is-active class to the current page's link. Use exact match for /, prefix match (str_starts_with) for all others.
Logout button
The logout button must use class="nav-logout-btn" — never button button-secondary. It is a ghost outlined button styled for the dark header background. Do not use the standard button classes here.
Contrast
Nav link text uses rgba(255, 255, 255, 0.88). Never go below 0.85 — the minimum for WCAG AA on the #1F4E79 header.
Focus rings
All nav links and the logout button have outline: 2px solid rgba(255, 255, 255, 0.6) on :focus-visible. Do not remove this.
Every page content area uses a .content-stack as its root element. This is a CSS grid with gap: 24px that stacks the page toolbar, panels, and other sections vertically.
<section class="content-stack">
<div class="page-toolbar">...</div>
<section class="section-panel">...</section>
</section>
Page toolbar (.page-toolbar) always contains:
.section-heading div with an <h1> and optional <p> description on the left.<div class="page-toolbar">
<div class="section-heading">
<h1>Page Title</h1>
<p>One-line description of what this page manages.</p>
</div>
<a class="button button-primary" href="/resource/create">+ New Resource</a>
</div>
Page h1 size is 32px / 40px line-height (set in .section-heading h1). Do not override this inline.
Every content block on a page must be wrapped in <section class="section-panel">. Key rules:
border-left: 3px solid var(--primary) — this is set globally in CSS and must not be removed or overridden..panel-header, which automatically adds a border-bottom separator below it..panel-header contains a left <div> with <h2> + optional <p>, and a right side for action buttons.<h2> is 18px. Do not make it larger than the page <h1>.<section class="section-panel">
<div class="panel-header">
<div>
<h2>Section Title</h2>
<p>Brief description of this section's content.</p>
</div>
<button class="button button-secondary" type="button">Action</button>
</div>
<!-- panel body content here -->
</section>
Use .stat-card for any numeric summary. Rules:
<a class="stat-card" href="..."> — not a <div>. The CSS a.stat-card handles hover lift and the “View all →” affordance automatically.<div class="stat-card">.var(--primary)..stat-card span style: 11px uppercase, var(--text-secondary)..stats-grid container. For 4 cards use .stats-grid.stats-grid-4. For 3 use .stats-grid alone (default 3-column).<div class="stats-grid stats-grid-4">
<a class="stat-card" href="/campaigns">
<span>Campaigns</span>
<strong><?= e((string) $model->totalCampaigns) ?></strong>
</a>
<!-- ... -->
</div>
When a page is an overview/metrics page (like the home dashboard):
.stats-grid of stat cards covering the main record counts..dashboard-panels grid of two side-by-side .section-panel blocks..dashboard-table..type-breakdown.Dashboard table (.dashboard-table) rules:
.dashboard-table-id), name/type, date (.dashboard-table-date), action link (.dashboard-table-action)..empty-state) with a helpful create link for when there is no data.Type breakdown (.type-breakdown) rules:
.type-breakdown-row showing: label, proportional bar, count.<a class="type-breakdown-row" href="...">. The CSS handles hover and focus-visible.round(count / max * 100)%. Pre-calculate $max before the loop.Structure
All multi-section forms use .ct-form (a CSS grid with gap: 24px) containing one or more .form-section blocks, ending with .form-actions.
<form method="post" action="..." class="ct-form" novalidate>
<?= csrf_field() ?>
<div class="form-section">
<label class="field field-full">
<span>Field label <span class="required-mark">*</span></span>
<input class="input" type="text" name="name" required>
</label>
</div>
<div class="form-actions">
<button class="button button-primary" type="submit">Save</button>
<a class="button button-secondary" href="/resource">Cancel</a>
</div>
</form>
Sticky save bar
.ct-form .form-actions is position: sticky; bottom: 0 — the save bar sticks to the viewport bottom when the form is taller than the screen. This is set globally in CSS. Do not override or move form-actions outside .ct-form.
Native validation
Use HTML5 required, maxlength, pattern, min, max on inputs. The CSS rule .input:invalid:not(:placeholder-shown) automatically shows an error border without JavaScript. Use novalidate on the form tag to prevent browser-native popups while still benefiting from the CSS pseudo-class.
Required mark
Mark required fields with <span class="required-mark">*</span> inside the label. Color is var(--error) via CSS.
Error messages
Server-side validation errors go in <small class="field-error"> immediately after the input. Add input-error class to the input when it has a server error.
When a form includes a reorderable list of items (like campaign type attributes):
.attribute-row with draggable="true" and Alpine.js drag event handlers.<span class="attr-drag-handle" title="Drag to reorder">↕</span>.font-size: 0 and replaces it with a ::before radial-gradient dot grid — this is automatic. Do not change the HTML to use an SVG or other icon.is-dragging and is-drag-over classes are applied by Alpine and styled by CSS — do not add custom drag styles.Never use plain text for loading indicators in content areas. Use .skeleton-rows with .skeleton-row children.
<div class="skeleton-rows" x-cloak x-show="isLoading">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
Rules:
x-cloak and x-show on Alpine-controlled sections so the skeleton only shows while loading.:nth-child selectors.Cards, rows, and panels that navigate must be <a> elements, not <div> elements with onclick. This applies to:
Use the appropriate CSS modifier class (a.stat-card, a.type-breakdown-row) — the hover and focus styles are already defined.
Inline table action links (Edit, Delete) in Tabulator formatter functions must use these conventions:
class="button button-secondary button-sm"class="button button-danger button-sm" with a window.confirmXxx() guard functionclass="button button-primary button-sm"Focus rings
outline: 2px solid var(--accent); outline-offset: 2px on :focus-visible — set globally in CSS via .page-content a:focus-visible. Do not suppress this.outline: 2px solid var(--accent); outline-offset: 2px via .button:focus-visible.Contrast minimums
rgba(255,255,255, 0.85) against #1F4E79.var(--text-secondary): #4B5563) on white: ~7.2:1 — acceptable.var(--text-muted) (#6B7280) for body text — only for supplementary labels and counts.ARIA
aria-hidden="true"..nav-sep elements must have aria-hidden="true".When a page needs aggregate data (counts, recent records, breakdowns), add methods to the relevant repository. Do not inline aggregate SQL in the controller.
Count method pattern
public function count(): int
{
$row = $this->database->first('SELECT COUNT(*) AS total FROM table_name');
return (int) ($row['total'] ?? 0);
}
Recent records pattern
Inline the TOP limit directly — SQL Server does not accept bound parameters in TOP expressions.
public function recentWithType(int $limit = 5): array
{
return $this->database->query(
"SELECT TOP ({$limit}) t.id, t.created_at, ref.name AS type_name
FROM main_table t
INNER JOIN ref_table ref ON t.ref_id = ref.id
ORDER BY t.id DESC"
);
}
Grouped count pattern
Use LEFT JOIN so that reference rows with zero records still appear.
public function countByType(): array
{
return $this->database->query(
'SELECT ref.name AS type_name, COUNT(t.id) AS record_count
FROM ref_table ref
LEFT JOIN main_table t ON t.ref_id = ref.id
GROUP BY ref.id, ref.name
ORDER BY record_count DESC, ref.name ASC'
);
}
Dashboard pages use a dedicated ViewModel. Metric fields use typed defaults so the view is never working with null.
class MyDashboardViewModel
{
public int $totalThings = 0;
public int $totalTypes = 0;
public array $recentItems = [];
public array $itemsByType = [];
}
Populate it in the controller using private repo() methods or inline new Repository(database()) calls — consistent with how other controllers are written in this project.
Powered by TurnKey Linux.