
Php Development
- 24 installs
- 7 repo stars
- Updated August 2, 2026
- practicalswan/agent-skills
php-development is a Claude Code skill for ai & agent building.
About
php-development is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- php-development
- AI & Agent Building
- AI-coding skill
Php Development by the numbers
- 24 all-time installs (skills.sh)
- Ranked #9,912 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/practicalswan/agent-skills --skill php-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 2, 2026 |
| Repository | practicalswan/agent-skills ↗ |
How do I helps with ai & agent building tasks.?
Helps with ai & agent building tasks.
Who is it for?
Best when you're working on ai & agent building and need structured help with php development.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks., or when php-development is a claude code skill for ai & agent building.
What you get
Structured output aligned to php-development: php-development, AI & Agent Building.
Files
PHP Development
Optimized for current PHP 8.x releases, PHPUnit 11+, Composer 2.x, and PDO-backed MySQL or MariaDB apps.
Expert guidance for building high-quality PHP applications with PHP 8.0+, PDO for secure database access, RESTful API design, and XAMPP environment configuration following official PHP documentation at https://php.net.
- Leverage native parallel subagent dispatch and 200k+ context windows where available.
Anti-Patterns
- Interpolating SQL directly: Prepared statements are the baseline for correctness and security in PHP data access.
- Mixing request parsing, business rules, and rendering: Tightly coupled scripts are harder to test and evolve into APIs.
- Assuming validation alone prevents XSS: Output encoding still matters when user-controlled content is rendered back to HTML.
Verification Protocol
Before claiming "skill applied successfully":
1. Pass/fail: The PHP Development implementation names the target runtime, framework version, and affected files. 2. Pass/fail: Build, lint, test, or equivalent local validation is run for the changed surface. 3. Pass/fail: Edge cases for errors, dependency drift, and environment differences are addressed or explicitly out of scope. 4. Pressure-test scenario: Apply the workflow to a change that passes happy-path tests but fails one boundary condition. 5. Success metric: Zero untested success claims; every implementation claim maps to a command or artifact.
Before and After Example
<?php
// Before
$stmt = $pdo->query("SELECT * FROM users WHERE email = '$email'");
$user = $stmt->fetch();
// After
$stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);Replaces string interpolation with a prepared statement and a narrower result shape.
Activation Conditions
Use symptom -> action triggers: when one matches, apply this skill and verify with the protocol below.
Core PHP Development:
- Building PHP RESTful APIs with proper HTTP methods
- Working with XAMPP (Apache + MySQL + PHP) environment
- Implementing secure database operations with PDO
- Creating authentication and session management systems
- Handling file uploads and form submissions
Database & Data Layer:
- Connecting PHP to MySQL/MariaDB with PDO
- Writing prepared statements to prevent SQL injection
- Implementing transaction handling for data integrity
- Creating repository patterns for data access
- Working with MySQLi vs PDO comparisons
Security & Best Practices:
- Implementing password hashing (password_hash, password_verify)
- Securing against XSS, CSRF, and SQL injection
- Validating and sanitizing user input
- Managing sessions and authentication tokens
- Configuring CORS headers for API access
API Development:
- Designing RESTful endpoints with proper HTTP status codes
- Handling JSON requests and responses
- Implementing middleware for authentication and authorization
- Error handling and logging
- Rate limiting and API versioning
---
Part 1: PHP 8.0+ Fundamentals
Modern PHP Features
<?php
// Named arguments (PHP 8.0+)
function createUser(string $name, string $email, bool $isAdmin = false): User {
return new User($name, $email, $isAdmin);
}
// Call with named arguments
$user = createUser(email: 'user@example.com', name: 'John Doe');
// Union types (PHP 8.0+)
function processValue(string|int|float $value): string {
return (string)$value;
}
// Nullsafe operator (PHP 8.0+)
$country = $session?->user?->address?->country ?? 'Unknown';
// Constructor property promotion (PHP 8.0+)
class User {
public function __construct(
public string $name,
public string $email,
private string $passwordHash
) {}
}Type Declarations & Strict Types
<?php
declare(strict_types=1); // Enforce type safety
// Typed properties and return types
class Recipe {
private int $id;
private string $title;
private ?DateTime $createdAt;
public function __construct(int $id, string $title) {
$this->id = $id;
$this->title = $title;
}
public function getTitle(): string {
return $this->title;
}
public function setCreatedAt(?DateTime $date): void {
$this->createdAt = $date;
}
}
// Union and intersection types
function processData(string|array $data): string|int {
return is_array($data) ? count($data) : strlen($data);
}---
Part 2: PDO Database Integration
Database Connection Class
<?php
class Database {
private static ?PDO $instance = null;
public static function getInstance(): PDO {
if (self::$instance === null) {
$host = $_ENV['DB_HOST'] ?? 'localhost';
$dbname = $_ENV['DB_NAME'] ?? 'recipe_sharing_system';
$username = $_ENV['DB_USER'] ?? 'root';
$password = $_ENV['DB_PASSWORD'] ?? '';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
try {
self::$instance = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
error_log("Database connection failed: " . $e->getMessage());
throw new RuntimeException("Database connection error");
}
}
return self::$instance;
}
}Prepared Statements for Security
<?php
class UserRepository {
private PDO $db;
public function __construct(PDO $db) {
$this->db = $db;
}
// Find user by email with prepared statement
public function findByEmail(string $email): ?array {
$stmt = $this->db->prepare(
"SELECT id, email, password_hash, role, status
FROM user
WHERE email = :email LIMIT 1"
);
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch();
return $user ?: null;
}
// Create new user with password hashing
public function create(string $name, string $email, string $password): int {
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->db->prepare(
"INSERT INTO user (name, email, password_hash, role, status, created_at, updated_at)
VALUES (:name, :email, :password_hash, 'user', 'active', NOW(), NOW())"
);
$stmt->bindParam(':name', $name, PDO::PARAM_STR);
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
$stmt->bindParam(':password_hash', $passwordHash, PDO::PARAM_STR);
$stmt->execute();
return (int) $this->db->lastInsertId();
}
// Authentication with password verification
public function authenticate(string $email, string $password): ?array {
$user = $this->findByEmail($email);
if ($user === null) {
return null;
}
if (!password_verify($password, $user['password_hash'])) {
return null;
}
// Check if password needs rehash
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
$this->updatePasswordHash($user['id'], $newHash);
}
unset($user['password_hash']); // Remove sensitive data
return $user;
}
private function updatePasswordHash(int $userId, string $hash): void {
$stmt = $this->db->prepare(
"UPDATE user SET password_hash = :hash WHERE id = :id"
);
$stmt->execute([':hash' => $hash, ':id' => $userId]);
}
}Transaction Management
<?php
class RecipeService {
private PDO $db;
public function __construct(PDO $db) {
$this->db = $db;
}
// Create recipe with ingredients, instructions, and images in a transaction
public function createRecipeWithDetails(array $recipeData, array $ingredients, array $instructions): int {
try {
$this->db->beginTransaction();
// Insert recipe
$stmt = $this->db->prepare(
"INSERT INTO recipe (title, description, category, difficulty, prep_time, cook_time, servings, author_id, status, created_at, updated_at)
VALUES (:title, :description, :category, :difficulty, :prep_time, :cook_time, :servings, :author_id, 'pending', NOW(), NOW())"
);
$stmt->execute([
':title' => $recipeData['title'],
':description' => $recipeData['description'],
':category' => $recipeData['category'],
':difficulty' => $recipeData['difficulty'],
':prep_time' => $recipeData['prepTime'],
':cook_time' => $recipeData['cookTime'],
':servings' => $recipeData['servings'],
':author_id' => $recipeData['authorId'],
]);
$recipeId = (int) $this->db->lastInsertId();
// Insert ingredients
$ingredientStmt = $this->db->prepare(
"INSERT INTO ingredient (recipe_id, name, quantity, unit, sort_order, created_at, updated_at)
VALUES (:recipe_id, :name, :quantity, :unit, :sort_order, NOW(), NOW())"
);
foreach ($ingredients as $index => $ingredient) {
$ingredientStmt->execute([
':recipe_id' => $recipeId,
':name' => $ingredient['name'],
':quantity' => $ingredient['quantity'],
':unit' => $ingredient['unit'],
':sort_order' => $index,
]);
}
// Insert instructions
$instructionStmt = $this->db->prepare(
"INSERT INTO instruction (recipe_id, step_number, instruction_text, created_at, updated_at)
VALUES (:recipe_id, :step_number, :instruction_text, NOW(), NOW())"
);
foreach ($instructions as $index => $instruction) {
$instructionStmt->execute([
':recipe_id' => $recipeId,
':step_number' => $index + 1,
':instruction_text' => $instruction['text'],
]);
}
$this->db->commit();
return $recipeId;
} catch (Exception $e) {
$this->db->rollBack();
error_log("Failed to create recipe: " . $e->getMessage());
throw $e;
}
}
}---
Part 3: RESTful API Development
JSON Response Helpers
<?php
class Response {
public static function json(mixed $data, int $statusCode = 200): never {
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
exit;
}
public static function error(string $message, int $statusCode = 400): never {
self::json([
'success' => false,
'error' => $message,
], $statusCode);
}
public static function success(mixed $data = null, string $message = 'Success'): never {
self::json([
'success' => true,
'message' => $message,
'data' => $data,
]);
}
}CORS Middleware
<?php
// Handle CORS headers
$allowedOrigins = [
'http://localhost:5173', // Vite dev server
'http://localhost:3000', // Alternative dev server
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: $origin");
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Credentials: true');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}Authentication Middleware
<?php
function requireAuth(): array {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
Response::error('Unauthorized: Missing or invalid token', 401);
}
$token = $matches[1];
// Validate token (example withJWT)
try {
$payload = JWT::decode($token, $_ENV['JWT_SECRET'], ['HS256']);
return (array) $payload;
} catch (Exception $e) {
Response::error('Unauthorized: Invalid token', 401);
}
}
function requireAdmin(): array {
$user = requireAuth();
if ($user['role'] !== 'admin') {
Response::error('Forbidden: Admin access required', 403);
}
return $user;
}API Controller Example
<?php
require_once '../../config/database.php';
require_once '../../middleware/cors.php';
require_once '../../utils/response.php';
class RecipeController {
private PDO $db;
public function __construct() {
$this->db = Database::getInstance();
}
// GET /api/recipes - Get all published recipes
public function index(): void {
$category = $_GET['category'] ?? null;
$difficulty = $_GET['difficulty'] ?? null;
$search = $_GET['search'] ?? null;
$limit = (int)($_GET['limit'] ?? 20);
$offset = (int)($_GET['offset'] ?? 0);
$query = "SELECT r.*, u.name as author_name,
COUNT(DISTINCT rv.id) as view_count,
COUNT(DISTINCT lr.id) as like_count,
AVG(rev.rating) as average_rating
FROM recipe r
JOIN user u ON r.author_id = u.id
LEFT JOIN recipe_view rv ON r.id = rv.recipe_id
LEFT JOIN like_record lr ON r.id = lr.recipe_id
LEFT JOIN review rev ON r.id = rev.recipe_id
WHERE r.status = 'published'";
$params = [];
if ($category !== null) {
$query .= " AND r.category = :category";
$params[':category'] = $category;
}
if ($difficulty !== null) {
$query .= " AND r.difficulty = :difficulty";
$params[':difficulty'] = $difficulty;
}
if ($search !== null) {
$query .= " AND (r.title LIKE :search OR r.description LIKE :search)";
$searchTerm = "%$search%";
$params[':search'] = $searchTerm;
$params[':search2'] = $searchTerm;
}
$query .= " GROUP BY r.id ORDER BY r.created_at DESC LIMIT :limit OFFSET :offset";
$stmt = $this->db->prepare($query);
$stmt->execute($params);
$recipes = $stmt->fetchAll();
Response::success($recipes);
}
// GET /api/recipes/:id - Get recipe by ID
public function show(int $id): void {
$stmt = $this->db->prepare(
"SELECT r.*, u.name as author_name, u.email as author_email,
GROUP_CONCAT(CONCAT(i.name, ' (', i.quantity, ' ', i.unit, ')') SEPARATOR ', ') as ingredients
FROM recipe r
JOIN user u ON r.author_id = u.id
LEFT JOIN ingredient i ON r.id = i.recipe_id
WHERE r.id = :id
GROUP BY r.id"
);
$stmt->execute([':id' => $id]);
$recipe = $stmt->fetch();
if ($recipe === false) {
Response::error('Recipe not found', 404);
}
// Fetch instructions
$instStmt = $this->db->prepare(
"SELECT step_number, instruction_text
FROM instruction
WHERE recipe_id = :recipe_id
ORDER BY step_number"
);
$instStmt->execute([':recipe_id' => $id]);
$recipe['instructions'] = $instStmt->fetchAll();
Response::success($recipe);
}
// POST /api/recipes - Create new recipe
public function store(): void {
$user = requireAuth();
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields
if (empty($data['title']) || empty($data['description'])) {
Response::error('Title and description are required');
}
$recipeData = [
'title' => $data['title'],
'description' => $data['description'],
'category' => $data['category'] ?? 'Uncategorized',
'difficulty' => $data['difficulty'] ?? 'Medium',
'prepTime' => (int)($data['prepTime'] ?? 0),
'cookTime' => (int)($data['cookTime'] ?? 0),
'servings' => (int)($data['servings'] ?? 1),
'authorId' => $user['id'],
];
$recipeService = new RecipeService($this->db);
try {
$recipeId = $recipeService->createRecipeWithDetails(
$recipeData,
$data['ingredients'] ?? [],
$data['instructions'] ?? []
);
Response::success(['id' => $recipeId], 'Recipe created successfully', 201);
} catch (Exception $e) {
Response::error('Failed to create recipe: ' . $e->getMessage(), 500);
}
}
}---
Part 4: Input Validation & Sanitization
Validation Functions
<?php
class Validator {
public static function email(string $email): bool {
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
public static function string(string $value, int $min = 1, int $max = 255): bool {
$length = strlen($value);
return $length >= $min && $length <= $max;
}
public static function integer(int $value, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): bool {
return $value >= $min && $value <= $max;
}
public static function enum(string $value, array $allowed): bool {
return in_array($value, $allowed, true);
}
public static function required(array $data, array $fields): array {
$errors = [];
foreach ($fields as $field) {
if (empty($data[$field])) {
$errors[] = "$field is required";
}
}
return $errors;
}
public static function sanitize(string $input): string {
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
}Validation Example
<?php
function validateRecipeData(array $data): array {
$errors = [];
// Validate title
if (empty($data['title'])) {
$errors[] = 'Title is required';
} elseif (!Validator::string($data['title'], 3, 200)) {
$errors[] = 'Title must be between 3 and 200 characters';
}
// Validate email
if (!empty($data['email']) && !Validator::email($data['email'])) {
$errors[] = 'Invalid email address';
}
// Validate rating
if (isset($data['rating']) && !Validator::integer((int)$data['rating'], 1, 5)) {
$errors[] = 'Rating must be between 1 and 5';
}
// Validate difficulty
if (!empty($data['difficulty']) &&
!Validator::enum($data['difficulty'], ['Easy', 'Medium', 'Hard'])) {
$errors[] = 'Difficulty must be Easy, Medium, or Hard';
}
// Sanitize all string inputs
foreach ($data as $key => $value) {
if (is_string($value)) {
$data[$key] = Validator::sanitize($value);
}
}
return ['errors' => $errors, 'data' => $data];
}---
Part 5: Security Best Practices
Password Management
<?php
class PasswordManager {
public static function hash(string $password): string {
// Use algorithm recommended by PHP
return password_hash($password, PASSWORD_DEFAULT);
}
public static function verify(string $password, string $hash): bool {
return password_verify($password, $hash);
}
public static function needsRehash(string $hash): bool {
return password_needs_rehash($hash, PASSWORD_DEFAULT);
}
public static function validateStrength(string $password): array {
$errors = [];
if (strlen($password) < 8) {
$errors[] = 'Password must be at least 8 characters';
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = 'Password must contain at least one uppercase letter';
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = 'Password must contain at least one lowercase letter';
}
if (!preg_match('/[0-9]/', $password)) {
$errors[] = 'Password must contain at least one number';
}
if (!preg_match('/[!@#$%^&*(),.?":{}|<>]/', $password)) {
$errors[] = 'Password must contain at least one special character';
}
return $errors;
}
}Session Management
<?php
class Session {
public static function start(): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
public static function set(string $key, mixed $value): void {
$_SESSION[$key] = $value;
}
public static function get(string $key, mixed $default = null): mixed {
return $_SESSION[$key] ?? $default;
}
public static function remove(string $key): void {
unset($_SESSION[$key]);
}
public static function destroy(): void {
$_SESSION = [];
session_destroy();
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
}
public static function regenerateId(): void {
session_regenerate_id(true);
}
}CSRF Protection
<?php
class CsrfProtection {
public static function generateToken(): string {
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function validateToken(string $token): bool {
return isset($_SESSION['csrf_token']) &&
hash_equals($_SESSION['csrf_token'], $token);
}
public static function invalidateToken(): void {
unset($_SESSION['csrf_token']);
}
public static function getInputField(): string {
$token = self::generateToken();
return "<input type='hidden' name='csrf_token' value='$token'>";
}
}---
Part 6: XAMPP Configuration
.htaccess for URL Rewriting
RewriteEngine On
# Redirect trailing slashes
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Handle API routes
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^api/(.*)$ api/index.php [QSA,L]
# Handle frontend routes (SPA)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.html [QSA,L]PHP Configuration (php.ini)
; Enable error reporting for development
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
; Log errors in production
log_errors = On
error_log = "C:/xampp/php/logs/php_error.log"
; Increase upload limits
upload_max_filesize = 10M
post_max_size = 10M
; Enable PDO extensions
extension=pdo_mysql
extension=mysqli
; Enable session handling
session.save_handler = files
session.save_path = "C:/xampp/tmp"
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 0 ; Set to 1 if HTTPS
session.use_only_cookies = 1
; Set timezone
date.timezone = "Asia/Bangkok"---
PHP Development Best Practices
Code Style (PSR-12)
- [ ] Use strict types (
declare(strict_types=1)) - [ ] Follow PSR-12 coding standards
- [ ] Use type hints for all functions and methods
- [ ] Use namespaces for autoloading classes
- [ ] Exception handling with try-catch blocks
Security
- [ ] Always use prepared statements with PDO
- [ ] Hash passwords with
password_hash() - [ ] Validate all user input
- [ ] Sanitize output for XSS prevention
- [ ] Use HTTPS in production
- [ ] Implement CSRF protection
API Design
- [ ] Use proper HTTP status codes (200, 201, 400, 401, 403, 404, 500)
- [ ] Return JSON responses
- [ ] Handle CORS headers
- [ ] Implement authentication middleware
- [ ] Rate limit endpoints
Database
- [ ] Use PDO for database connections
- [ ] Implement transactions for multi-step operations
- [ ] Use named parameters in prepared statements
- [ ] Handle connection errors gracefully
- [ ] Close connections properly
---
Common Pitfalls
- Interpolating SQL directly: Prepared statements are the baseline for correctness and security in PHP data access.
- Mixing request parsing, business rules, and rendering: Tightly coupled scripts become difficult to test or migrate into APIs.
- Ignoring output encoding: Input validation alone does not protect against XSS when data is rendered back to users.
References & Resources
Documentation
- PHP 8.4+ API Patterns — Modern PHP API development patterns
Examples
- PDO Database Patterns — PHP PDO database integration examples
Scripts
- XAMPP Setup Script — PowerShell script to configure XAMPP for PHP development
Official Documentation
- PHP Manual — Complete PHP reference
- PDO for MySQL — PDO MySQL driver documentation
- Password Hashing — Secure password functions
- REST API Best Practices — API design principles
PHP Standards
- PSR-12: Extended Coding Style — Modern PHP coding style
- XAMPP Documentation — XAMPP setup and configuration
Security Resources
- OWASP PHP Security — XSS prevention
- SQL Injection Prevention — SQL injection prevention
- PHP Security Guide — Official PHP security considerations
---
<!-- PORTABILITY:START -->
Cross-Client Portability
This skill is written to stay usable across GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- GitHub Copilot: keep the folder in a Copilot-visible skill or plugin path, or wrap the workflow as project instructions if the host does not support portable skill folders directly.
- Claude Code: keep the folder in a local skills directory or a compatible plugin or marketplace source.
- Codex: install or sync the folder into
$CODEX_HOME/skills/<skill-name>and restart Codex after major changes. - Gemini CLI: this repository generates a project command named
/skills:php-developmentfrom this skill. Rebuild commands withpython scripts/export-gemini-skill.py php-developmentand then run/commands reloadinside Gemini CLI.
<!-- PORTABILITY:END -->
<!-- MCP:START -->
MCP Availability And Fallback
Preferred MCP Server: None required
- Fallback prompt: "Use the PHP Development skill without MCP. Rely on the local
SKILL.md, bundled references or scripts, and manual verification. Show the exact commands, evidence, and final checks you used before concluding." - If the current host does not expose a matching server, use the bundled references, scripts, native toolchain, and manual workflow already described in this skill.
- Treat direct local verification, rendered output, logs, tests, or screenshots as the fallback evidence path before completion.
<!-- MCP:END -->
Related Skills
- sql-development: Use it when the workflow also needs SQL query, schema, and performance tuning work.
- code-quality: Use it when the workflow also needs two-stage review (spec compliance first, then code quality), maintainability, and refactoring guidance.
- systematic-debugging: Use it when the workflow also needs root-cause debugging before proposing fixes.
- development-workflow: Use it when the workflow also needs planning, quality gates, and delivery tracking.
Changelog
[2026-04-25] - Version 1.2 Verification Protocol Refresh
Added
- Added a
Verification Protocolsection with skill-specific pass/fail checks, one pressure-test scenario, and a measurable success metric. - Added guidance to leverage native parallel subagent dispatch and 200k+ context windows where available.
Changed
- Updated
SKILL.mdfrontmatter toversion: "1.2"andlast_updated: 2026-04-25. - Reframed activation guidance toward symptom -> action triggers and standardized two-stage review wording where applicable.
[2026-04-24] - Version 1.1 Refresh
Changed
- Updated the SKILL frontmatter version to
1.1for the 2026-04-24 catalog refresh. - Added an "Optimized for ..." note at the top so the guidance is anchored to current platform versions.
[2026-04-24] - Skill Refresh
Changed
- Standardized the SKILL frontmatter with version metadata, last-updated date, tags, and a concise catalog description.
- Reformatted the portability and MCP guidance with a preferred server line, a copy-paste fallback prompt, and consistent bullet lists.
- Added a catalog-standard Anti-Patterns section and refreshed the Related Skills links at the end of the skill.
- Added an explicit before-and-after example and a Common Pitfalls section for modern PHP backend implementation.
[2026-04-24] - Catalog Audit Cleanup
Fixed
- Replaced the stale
nestjsrelated-skill reference withjavascript-developmentas the maintained JavaScript alternative for PHP projects.
All notable changes to this skill will be documented in this file.
[2026-04-04] - Cross-Client Portability Refresh
Changed
- Added a standard portability note covering GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- Clarified that the core workflow does not require a dedicated MCP server and can run with local tools alone.
Tested
- Validated
SKILL.mdfrontmatter, portability sections, and Gemini export readiness withpython scripts/validate-skills.py.
[2026-03-09] - Workspace Modernization
Added
- Added a 2026-03-09 maintenance entry after reviewing the skill; the existing structure and guidance remained suitable.
[2026-02-28] — Description Rewrite & Cross-References
Changed
- Rewrote skill description to ~200 characters with clear, specific activation keywords
- Improved keyword specificity to reduce overlap with related skills
Added
## Related Skillscross-reference table with 2-4 related skills and "Use When" guidance
<?php
/**
* PDO Database Integration Patterns for PHP
*
* This file demonstrates secure database operations using PHP PDO,
* following the Recipe Sharing System architecture.
*/
/**
* Database Connection Class - Singleton Pattern
* Ensures only one database connection exists throughout application lifetime
*/
class Database {
private static ?PDO $instance = null;
/**
* Get PDO database instance
* Creates new connection if doesn't exist
*
* @return PDO Database connection object
* @throws RuntimeException If connection fails
*/
public static function getInstance(): PDO {
if (self::$instance === null) {
try {
$dsn = sprintf(
"mysql:host=%s;dbname=%s;charset=utf8mb4",
$_ENV['DB_HOST'] ?? 'localhost',
$_ENV['DB_NAME'] ?? 'recipe_sharing_system'
);
self::$instance = new PDO(
$dsn,
$_ENV['DB_USER'] ?? 'root',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00'",
]
);
} catch (PDOException $e) {
error_log("Database connection error: " . $e->getMessage());
throw new RuntimeException("Failed to connect to database");
}
}
return self::$instance;
}
/**
* Close database connection
*/
public static function closeConnection(): void {
self::$instance = null;
}
}
/**
* Base Repository Pattern
* Provides common database operations for all entities
*/
abstract class BaseRepository {
protected PDO $db;
protected string $table;
/**
* Constructor
*/
public function __construct() {
$this->db = Database::getInstance();
}
/**
* Find record by ID
*
* @param int $id Record ID
* @return array|null Record data or null if not found
*/
public function findById(int $id): ?array {
$stmt = $this->db->prepare(
"SELECT * FROM {$this->table} WHERE id = :id LIMIT 1"
);
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$record = $stmt->fetch();
return $record ?: null;
}
/**
* Find all records with optional pagination
*
* @param array $filters WHERE clause conditions
* @param array $orderBy ORDER BY columns and direction
* @param int|null $limit Maximum records to return
* @param int|null $offset Records to skip
* @return array Array of records
*/
public function findAll(
array $filters = [],
array $orderBy = [],
?int $limit = null,
?int $offset = null
): array {
$sql = "SELECT * FROM {$this->table}";
$params = [];
// Build WHERE clause
if (!empty($filters)) {
$conditions = [];
foreach ($filters as $column => $value) {
if ($value === null) {
$conditions[] = "$column IS NULL";
} else {
$paramKey = ":$column";
$conditions[] = "$column = $paramKey";
$params[$paramKey] = $value;
}
}
$sql .= " WHERE " . implode(" AND ", $conditions);
}
// Build ORDER BY clause
if (!empty($orderBy)) {
$orders = [];
foreach ($orderBy as $column => $direction) {
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$orders[] = "$column $direction";
}
$sql .= " ORDER BY " . implode(", ", $orders);
}
// Add pagination
if ($limit !== null) {
$sql .= " LIMIT :limit";
$params[':limit'] = $limit;
if ($offset !== null) {
$sql .= " OFFSET :offset";
$params[':offset'] = $offset;
}
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Create a new record
*
* @param array $data Column-value pairs to insert
* @return int Inserted record ID
* @throws RuntimeException If insert fails
*/
public function create(array $data): int {
$columns = array_keys($data);
$placeholders = array_map(fn(string $col): string => ":$col", $columns);
$sql = sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
$this->table,
implode(", ", $columns),
implode(", ", $placeholders)
);
$stmt = $this->db->prepare($sql);
foreach ($data as $key => $value) {
$paramType = is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR;
$stmt->bindValue(":$key", $value, $paramType);
}
if (!$stmt->execute()) {
error_log("Insert error: " . implode(", ", $stmt->errorInfo()));
throw new RuntimeException("Failed to create record");
}
return (int) $this->db->lastInsertId();
}
/**
* Update existing record
*
* @param int $id Record ID to update
* @param array $data Column-value pairs to update
* @return bool True if update successful
* @throws RuntimeException If update fails
*/
public function update(int $id, array $data): bool {
$setParts = [];
foreach (array_keys($data) as $column) {
$setParts[] = "$column = :$column";
}
$sql = sprintf(
"UPDATE %s SET %s WHERE id = :id",
$this->table,
implode(", ", $setParts)
);
$stmt = $this->db->prepare($sql);
foreach ($data as $key => $value) {
$paramType = is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR;
$stmt->bindValue(":$key", $value, $paramType);
}
$stmt->bindValue(":id", $id, PDO::PARAM_INT);
if (!$stmt->execute()) {
error_log("Update error: " . implode(", ", $stmt->errorInfo()));
throw new RuntimeException("Failed to update record");
}
return $stmt->rowCount() > 0;
}
/**
* Delete record by ID
*
* @param int $id Record ID to delete
* @return bool True if deletion successful
* @throws RuntimeException If delete fails
*/
public function delete(int $id): bool {
$stmt = $this->db->prepare(
"DELETE FROM {$this->table} WHERE id = :id"
);
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
if (!$stmt->execute()) {
error_log("Delete error: " . implode(", ", $stmt->errorInfo()));
throw new RuntimeException("Failed to delete record");
}
return $stmt->rowCount() > 0;
}
/**
* Count records with optional filters
*
* @param array $filters WHERE clause conditions
* @return int Number of matching records
*/
public function count(array $filters = []): int {
$sql = "SELECT COUNT(*) as count FROM {$this->table}";
$params = [];
if (!empty($filters)) {
$conditions = [];
foreach ($filters as $column => $value) {
if ($value === null) {
$conditions[] = "$column IS NULL";
} else {
$paramKey = ":$column";
$conditions[] = "$column = $paramKey";
$params[$paramKey] = $value;
}
}
$sql .= " WHERE " . implode(" AND ", $conditions);
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetch();
return (int)($result['count'] ?? 0);
}
}
/**
* User Repository
* Handles user-related database operations
*/
class UserRepository extends BaseRepository {
protected string $table = 'user';
/**
* Find user by email address
*
* @param string $email User email
* @return array|null User data or null
*/
public function findByEmail(string $email): ?array {
$stmt = $this->db->prepare(
"SELECT * FROM user WHERE email = :email LIMIT 1"
);
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Find user by username
*
* @param string $username Username
* @return array|null User data or null
*/
public function findByUsername(string $username): ?array {
$stmt = $this->db->prepare(
"SELECT * FROM user WHERE username = :username LIMIT 1"
);
$stmt->bindParam(':username', $username, PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Find users by role with pagination
*
* @param string $role User role (admin, user)
* @param int $limit Records per page
* @param int $offset Records to skip
* @return array Array of users
*/
public function findByRole(string $role, int $limit = 20, int $offset = 0): array {
$stmt = $this->db->prepare(
"SELECT * FROM user
WHERE role = :role
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset"
);
$stmt->bindParam(':role', $role, PDO::PARAM_STR);
$stmt->bindParam(':limit', $limit, PDO::PARAM_INT);
$stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Verify user credentials and return user data
*
* @param string $email User email
* @param string $password Plain text password
* @return array|null User data without password hash if successful, null otherwise
*/
public function verifyCredentials(string $email, string $password): ?array {
$stmt = $this->db->prepare(
"SELECT id, email, username, password_hash, first_name, last_name, role, status
FROM user
WHERE email = :email LIMIT 1"
);
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch();
if ($user === null) {
return null;
}
if (!password_verify($password, $user['password_hash'])) {
return null;
}
// Remove password hash before returning
unset($user['password_hash']);
return $user;
}
/**
* Update user's last active timestamp
*
* @param int $userId User ID
* @return bool True if update successful
*/
public function updateLastActive(int $userId): bool {
$stmt = $this->db->prepare(
"UPDATE user SET last_active = NOW() WHERE id = :id"
);
$stmt->bindParam(':id', $userId, PDO::PARAM_INT);
return $stmt->execute();
}
/**
* Get user statistics
*
* @param int $userId User ID
* @return array User statistics (recipe count, review count, etc.)
*/
public function getUserStats(int $userId): array {
$stmt = $this->db->prepare(
"SELECT
COUNT(DISTINCT r.id) as recipe_count,
COUNT(DISTINCT rv.id) as review_count,
COUNT(DISTINCT f.id) as favorite_count
FROM user u
LEFT JOIN recipe r ON u.id = r.author_id
LEFT JOIN review rv ON u.id = rv.user_id
LEFT JOIN favorite f ON u.id = f.user_id
WHERE u.id = :userId
GROUP BY u.id"
);
$stmt->bindParam(':userId', $userId, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch() ?: [
'recipe_count' => 0,
'review_count' => 0,
'favorite_count' => 0,
];
}
}
/**
* Recipe Repository
* Handles recipe-related database operations with transactions
*/
class RecipeRepository extends BaseRepository {
protected string $table = 'recipe';
/**
* Get published recipes with aggregated statistics
*
* @param array $filters Filter conditions
* @param int $limit Records limit
* @param int $offset Records offset
* @return array Array of recipes with stats
*/
public function findPublishedWithStats(
array $filters = [],
int $limit = 20,
int $offset = 0
): array {
$sql = "SELECT
r.*,
u.username as author_name,
COUNT(DISTINCT rv.id) as view_count,
COUNT(DISTINCT lr.id) as like_count,
AVG(rev.rating) as avg_rating
FROM recipe r
JOIN user u ON r.author_id = u.id
LEFT JOIN recipe_view rv ON r.id = rv.recipe_id
LEFT JOIN like_record lr ON r.id = lr.recipe_id
LEFT JOIN review rev ON r.id = rev.recipe_id
WHERE r.status = 'published'";
$params = [];
// Add filters
if (!empty($filters['category'])) {
$sql .= " AND r.category = :category";
$params[':category'] = $filters['category'];
}
if (!empty($filters['difficulty'])) {
$sql .= " AND r.difficulty = :difficulty";
$params[':difficulty'] = $filters['difficulty'];
}
if (!empty($filters['search'])) {
$sql .= " AND (r.title LIKE :search OR r.description LIKE :search)";
$searchTerm = "%{$filters['search']}%";
$params[':search'] = $searchTerm;
}
$sql .= " GROUP BY r.id
ORDER BY r.created_at DESC
LIMIT :limit OFFSET :offset";
$stmt = $this->db->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, PDO::PARAM_STR);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Get recipe with full details including ingredients and instructions
*
* @param int $recipeId Recipe ID
* @return array|null Recipe with nested details
*/
public function findByIdWithDetails(int $recipeId): ?array {
// Get main recipe data
$stmt = $this->db->prepare(
"SELECT r.*, u.username as author_name, u.email as author_email
FROM recipe r
JOIN user u ON r.author_id = u.id
WHERE r.id = :id LIMIT 1"
);
$stmt->bindParam(':id', $recipeId, PDO::PARAM_INT);
$stmt->execute();
$recipe = $stmt->fetch();
if ($recipe === false) {
return null;
}
// Get ingredients
$ingredientStmt = $this->db->prepare(
"SELECT * FROM ingredient
WHERE recipe_id = :recipe_id
ORDER BY sort_order ASC"
);
$ingredientStmt->bindParam(':recipe_id', $recipeId, PDO::PARAM_INT);
$ingredientStmt->execute();
$recipe['ingredients'] = $ingredientStmt->fetchAll();
// Get instructions
$instructionStmt = $this->db->prepare(
"SELECT * FROM instruction
WHERE recipe_id = :recipe_id
ORDER BY step_number ASC"
);
$instructionStmt->bindParam(':recipe_id', $recipeId, PDO::PARAM_INT);
$instructionStmt->execute();
$recipe['instructions'] = $instructionStmt->fetchAll();
// Get images
$imageStmt = $this->db->prepare(
"SELECT * FROM recipe_image
WHERE recipe_id = :recipe_id
ORDER BY display_order ASC"
);
$imageStmt->bindParam(':recipe_id', $recipeId, PDO::PARAM_INT);
$imageStmt->execute();
$recipe['images'] = $imageStmt->fetchAll();
return $recipe;
}
/**
* Create recipe with nested ingredients and instructions (transactional)
*
* @param array $recipeData Recipe main data
* @param array $ingredients Array of ingredient data
* @param array $instructions Array of instruction data
* @return int Created recipe ID
* @throws RuntimeException If creation fails
*/
public function createWithDetails(
array $recipeData,
array $ingredients = [],
array $instructions = []
): int {
try {
$this->db->beginTransaction();
// Insert main recipe
$recipeSql = "INSERT INTO recipe (
title, description, category, difficulty,
prep_time, cook_time, servings, author_id,
status, created_at, updated_at
) VALUES (
:title, :description, :category, :difficulty,
:prep_time, :cook_time, :servings, :author_id,
:status, NOW(), NOW()
)";
$recipeStmt = $this->db->prepare($recipeSql);
$recipeStmt->bindParam(':title', $recipeData['title'], PDO::PARAM_STR);
$recipeStmt->bindParam(':description', $recipeData['description'], PDO::PARAM_STR);
$recipeStmt->bindParam(':category', $recipeData['category'], PDO::PARAM_STR);
$recipeStmt->bindParam(':difficulty', $recipeData['difficulty'], PDO::PARAM_STR);
$recipeStmt->bindParam(':prep_time', $recipeData['prep_time'], PDO::PARAM_INT);
$recipeStmt->bindParam(':cook_time', $recipeData['cook_time'], PDO::PARAM_INT);
$recipeStmt->bindParam(':servings', $recipeData['servings'], PDO::PARAM_INT);
$recipeStmt->bindParam(':author_id', $recipeData['author_id'], PDO::PARAM_INT);
$recipeStmt->bindParam(':status', $recipeData['status'] ?? 'pending', PDO::PARAM_STR);
$recipeStmt->execute();
$recipeId = (int) $this->db->lastInsertId();
// Insert ingredients
if (!empty($ingredients)) {
$ingredientStmt = $this->db->prepare(
"INSERT INTO ingredient (
recipe_id, name, quantity, unit, sort_order, created_at, updated_at
) VALUES (
:recipe_id, :name, :quantity, :unit, :sort_order, NOW(), NOW()
)"
);
foreach ($ingredients as $index => $ingredient) {
$ingredientStmt->bindParam(':recipe_id', $recipeId, PDO::PARAM_INT);
$ingredientStmt->bindParam(':name', $ingredient['name'], PDO::PARAM_STR);
$ingredientStmt->bindParam(':quantity', $ingredient['quantity'], PDO::PARAM_STR);
$ingredientStmt->bindParam(':unit', $ingredient['unit'], PDO::PARAM_STR);
$ingredientStmt->bindParam(':sort_order', $index, PDO::PARAM_INT);
$ingredientStmt->execute();
}
}
// Insert instructions
if (!empty($instructions)) {
$instructionStmt = $this->db->prepare(
"INSERT INTO instruction (
recipe_id, step_number, instruction_text, created_at, updated_at
) VALUES (
:recipe_id, :step_number, :instruction_text, NOW(), NOW()
)"
);
foreach ($instructions as $index => $instruction) {
$instructionStmt->bindParam(':recipe_id', $recipeId, PDO::PARAM_INT);
$instructionStmt->bindParam(':step_number', $index + 1, PDO::PARAM_INT);
$instructionStmt->bindParam(':instruction_text', $instruction['text'], PDO::PARAM_STR);
$instructionStmt->execute();
}
}
$this->db->commit();
return $recipeId;
} catch (Exception $e) {
$this->db->rollBack();
error_log("Recipe creation error: " . $e->getMessage());
throw new RuntimeException("Failed to create recipe");
}
}
}
/**
* Transaction Manager
* Handles complex multi-step database operations
*/
class TransactionManager {
private PDO $db;
public function __construct() {
$this->db = Database::getInstance();
}
/**
* Execute a callback function within a transaction
*
* @param callable $callback Function to execute within transaction
* @return mixed Result of callback
* @throws Exception If callback fails, transaction is rolled back
*/
public function execute(callable $callback): mixed {
try {
$this->db->beginTransaction();
$result = $callback($this->db);
$this->db->commit();
return $result;
} catch (Exception $e) {
$this->db->rollBack();
error_log("Transaction error: " . $e->getMessage());
throw $e;
}
}
}
/**
* Usage Examples
*/
// Example 1: Simple user lookup
function exampleFindUser(): void {
$userRepo = new UserRepository();
$user = $userRepo->findByEmail('user@example.com');
if ($user !== null) {
echo "Found user: " . $user['username'];
}
}
// Example 2: Create user with password hashing
function exampleCreateUser(): void {
$userRepo = new UserRepository();
$userData = [
'username' => 'newuser',
'email' => 'newuser@example.com',
'password_hash' => password_hash('securePassword123!', PASSWORD_DEFAULT),
'first_name' => 'John',
'last_name' => 'Doe',
'role' => 'user',
'status' => 'active',
];
$userId = $userRepo->create($userData);
echo "Created user with ID: " . $userId;
}
// Example 3: Transactional recipe creation
function exampleCreateRecipe(): void {
$recipeRepo = new RecipeRepository();
$transactionManager = new TransactionManager();
$recipeData = [
'title' => 'Delicious Pasta',
'description' => 'A wonderful pasta recipe',
'category' => 'Dinner',
'difficulty' => 'Medium',
'prep_time' => 15,
'cook_time' => 30,
'servings' => 4,
'author_id' => 1,
'status' => 'pending',
];
$ingredients = [
['name' => 'Pasta', 'quantity' => '400g', 'unit' => ''],
['name' => 'Tomato Sauce', 'quantity' => '500ml', 'unit' => ''],
['name' => 'Parmesan', 'quantity' => '100g', 'unit' => 'grated'],
];
$instructions = [
['text' => 'Boil the pasta in salted water'],
['text' => 'Heat the tomato sauce in a pan'],
['text' => 'Drain pasta and mix with sauce'],
['text' => 'Serve with parmesan on top'],
];
try {
$recipeId = $recipeRepo->createWithDetails(
$recipeData,
$ingredients,
$instructions
);
echo "Recipe created with ID: " . $recipeId;
} catch (RuntimeException $e) {
echo "Failed to create recipe: " . $e->getMessage();
}
}
// Example 4: Search recipes with filters
function exampleSearchRecipes(): void {
$recipeRepo = new RecipeRepository();
$recipes = $recipeRepo->findPublishedWithStats([
'category' => 'Dinner',
'difficulty' => 'Medium',
'search' => 'pasta',
], limit: 10, offset: 0);
foreach ($recipes as $recipe) {
echo sprintf(
"%s by %s (Rating: %.1f)\n",
$recipe['title'],
$recipe['author_name'],
$recipe['avg_rating'] ?? 0
);
}
}
See repository root LICENSE and applicable upstream documentation licenses.PHP 8.4 API Patterns (2026)
Up-to-date PHP patterns for building secure, maintainable REST APIs with PDO, authentication, validation, and XAMPP/MySQL integration.
PHP Version Context
Current Stable Baseline
- PHP 8.4 (latest stable branch in 2026 context)
- Recommended minimum for new projects: PHP 8.2+
- For this project (XAMPP + MySQL + React frontend): PHP 8.0+ required, 8.2+ preferred
Core Language Features (PHP 8.x)
Constructor Property Promotion
<?php
class Recipe {
public function __construct(
public int $id,
public string $title,
public string $description,
public string $difficulty = 'Medium'
) {}
}Union and Intersection Types
<?php
declare(strict_types=1);
function normalizeValue(string|int|float|null $value): string {
return (string)($value ?? '');
}
interface JsonSerializableEntity extends JsonSerializable {}
function toJson(JsonSerializable&ArrayAccess $entity): string {
return json_encode($entity, JSON_THROW_ON_ERROR);
}Nullsafe Operator
<?php
$email = $user?->profile?->contact?->email ?? 'no-email@example.com';Match Expression
<?php
function mapStatusCodeToMessage(int $statusCode): string {
return match ($statusCode) {
200 => 'Success',
201 => 'Created',
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
500 => 'Internal Server Error',
default => 'Unknown Status',
};
}Readonly Properties
<?php
class ApiConfig {
public function __construct(
public readonly string $baseUrl,
public readonly string $apiVersion,
public readonly int $timeout
) {}
}API Design Patterns
Standard JSON Response Format
<?php
class JsonResponse {
public static function success(
mixed $data = null,
string $message = 'Success',
int $statusCode = 200
): never {
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'message' => $message,
'data' => $data,
'timestamp' => date(DATE_ATOM),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
exit;
}
public static function error(
string $message,
int $statusCode = 400,
array $errors = []
): never {
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => false,
'error' => $message,
'errors' => $errors,
'timestamp' => date(DATE_ATOM),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
exit;
}
}RESTful Controller Structure
<?php
declare(strict_types=1);
class RecipeController {
public function __construct(
private readonly RecipeService $recipeService,
private readonly AuthService $authService
) {}
// GET /api/recipes
public function index(): never {
try {
$filters = [
'category' => $_GET['category'] ?? null,
'difficulty' => $_GET['difficulty'] ?? null,
'search' => $_GET['search'] ?? null,
'limit' => (int)($_GET['limit'] ?? 20),
'offset' => (int)($_GET['offset'] ?? 0),
];
$recipes = $this->recipeService->getPublishedRecipes($filters);
JsonResponse::success($recipes);
} catch (ValidationException $e) {
JsonResponse::error($e->getMessage(), 422);
} catch (Throwable $e) {
error_log($e->getMessage());
JsonResponse::error('Failed to fetch recipes', 500);
}
}
// GET /api/recipes/{id}
public function show(int $id): never {
try {
$recipe = $this->recipeService->getRecipeById($id);
if ($recipe === null) {
JsonResponse::error('Recipe not found', 404);
}
JsonResponse::success($recipe);
} catch (Throwable $e) {
error_log($e->getMessage());
JsonResponse::error('Failed to fetch recipe', 500);
}
}
// POST /api/recipes
public function store(): never {
try {
$user = $this->authService->requireUser();
$payload = $this->getJsonPayload();
$recipeId = $this->recipeService->createRecipe(
authorId: $user['id'],
payload: $payload
);
JsonResponse::success([
'id' => $recipeId,
], 'Recipe created successfully', 201);
} catch (UnauthorizedException $e) {
JsonResponse::error($e->getMessage(), 401);
} catch (ValidationException $e) {
JsonResponse::error($e->getMessage(), 422, $e->getErrors());
} catch (Throwable $e) {
error_log($e->getMessage());
JsonResponse::error('Failed to create recipe', 500);
}
}
// PUT /api/recipes/{id}
public function update(int $id): never {
try {
$user = $this->authService->requireUser();
$payload = $this->getJsonPayload();
$updated = $this->recipeService->updateRecipe(
recipeId: $id,
userId: $user['id'],
payload: $payload
);
if (!$updated) {
JsonResponse::error('Recipe not found or no changes made', 404);
}
JsonResponse::success(null, 'Recipe updated successfully');
} catch (ForbiddenException $e) {
JsonResponse::error($e->getMessage(), 403);
} catch (ValidationException $e) {
JsonResponse::error($e->getMessage(), 422, $e->getErrors());
} catch (Throwable $e) {
error_log($e->getMessage());
JsonResponse::error('Failed to update recipe', 500);
}
}
// DELETE /api/recipes/{id}
public function destroy(int $id): never {
try {
$user = $this->authService->requireUser();
$deleted = $this->recipeService->deleteRecipe(
recipeId: $id,
userId: $user['id']
);
if (!$deleted) {
JsonResponse::error('Recipe not found', 404);
}
JsonResponse::success(null, 'Recipe deleted successfully');
} catch (ForbiddenException $e) {
JsonResponse::error($e->getMessage(), 403);
} catch (Throwable $e) {
error_log($e->getMessage());
JsonResponse::error('Failed to delete recipe', 500);
}
}
private function getJsonPayload(): array {
$rawInput = file_get_contents('php://input');
if ($rawInput === false || $rawInput === '') {
throw new ValidationException('Request body is required');
}
try {
return json_decode($rawInput, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new ValidationException('Invalid JSON payload');
}
}
}PDO Patterns and Security
Connection Factory
<?php
class PdoFactory {
public static function create(array $config): PDO {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$config['host'],
$config['port'] ?? 3306,
$config['database']
);
return new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
]);
}
}Safe Query Pattern
<?php
class RecipeRepository {
public function __construct(private readonly PDO $db) {}
public function findPublished(array $filters, int $limit = 20, int $offset = 0): array {
$sql = "
SELECT
r.id,
r.title,
r.description,
r.category,
r.difficulty,
r.prep_time,
r.cook_time,
r.servings,
r.created_at,
u.username AS author_name,
COUNT(DISTINCT rv.id) AS view_count,
COUNT(DISTINCT lr.id) AS like_count,
AVG(rev.rating) AS avg_rating
FROM recipe r
JOIN user u ON u.id = r.author_id
LEFT JOIN recipe_view rv ON rv.recipe_id = r.id
LEFT JOIN like_record lr ON lr.recipe_id = r.id
LEFT JOIN review rev ON rev.recipe_id = r.id
WHERE r.status = 'published'
";
$params = [];
if (!empty($filters['category'])) {
$sql .= " AND r.category = :category";
$params[':category'] = $filters['category'];
}
if (!empty($filters['difficulty'])) {
$sql .= " AND r.difficulty = :difficulty";
$params[':difficulty'] = $filters['difficulty'];
}
if (!empty($filters['search'])) {
$sql .= " AND (r.title LIKE :search OR r.description LIKE :search)";
$params[':search'] = '%' . $filters['search'] . '%';
}
$sql .= "
GROUP BY r.id
ORDER BY r.created_at DESC
LIMIT :limit OFFSET :offset
";
$stmt = $this->db->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, PDO::PARAM_STR);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
}Transaction Pattern
<?php
class RecipeService {
public function __construct(private readonly PDO $db) {}
public function createRecipeWithDetails(array $payload, int $authorId): int {
try {
$this->db->beginTransaction();
// 1. Insert recipe
$recipeStmt = $this->db->prepare(
"
INSERT INTO recipe (
title, description, category, difficulty,
prep_time, cook_time, servings,
author_id, status, created_at, updated_at
) VALUES (
:title, :description, :category, :difficulty,
:prep_time, :cook_time, :servings,
:author_id, 'pending', NOW(), NOW()
)
"
);
$recipeStmt->execute([
':title' => $payload['title'],
':description' => $payload['description'] ?? '',
':category' => $payload['category'] ?? 'Uncategorized',
':difficulty' => $payload['difficulty'] ?? 'Medium',
':prep_time' => (int)($payload['prep_time'] ?? 0),
':cook_time' => (int)($payload['cook_time'] ?? 0),
':servings' => (int)($payload['servings'] ?? 1),
':author_id' => $authorId,
]);
$recipeId = (int)$this->db->lastInsertId();
// 2. Insert ingredients
if (!empty($payload['ingredients']) && is_array($payload['ingredients'])) {
$ingredientStmt = $this->db->prepare(
"
INSERT INTO ingredient (
recipe_id, name, quantity, unit, sort_order, created_at, updated_at
) VALUES (
:recipe_id, :name, :quantity, :unit, :sort_order, NOW(), NOW()
)
"
);
foreach ($payload['ingredients'] as $index => $ingredient) {
$ingredientStmt->execute([
':recipe_id' => $recipeId,
':name' => $ingredient['name'],
':quantity' => $ingredient['quantity'] ?? '',
':unit' => $ingredient['unit'] ?? '',
':sort_order' => $index,
]);
}
}
// 3. Insert instructions
if (!empty($payload['instructions']) && is_array($payload['instructions'])) {
$instructionStmt = $this->db->prepare(
"
INSERT INTO instruction (
recipe_id, step_number, instruction_text, created_at, updated_at
) VALUES (
:recipe_id, :step_number, :instruction_text, NOW(), NOW()
)
"
);
foreach ($payload['instructions'] as $index => $instruction) {
$instructionStmt->execute([
':recipe_id' => $recipeId,
':step_number' => $index + 1,
':instruction_text' => $instruction['instruction_text'] ?? '',
]);
}
}
$this->db->commit();
return $recipeId;
} catch (Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
}Authentication Patterns
Password Hashing
<?php
class PasswordService {
public function hash(string $plainPassword): string {
return password_hash($plainPassword, PASSWORD_DEFAULT);
}
public function verify(string $plainPassword, string $hash): bool {
return password_verify($plainPassword, $hash);
}
public function needsRehash(string $hash): bool {
return password_needs_rehash($hash, PASSWORD_DEFAULT);
}
}Token-Based Auth (JWT-style pattern)
<?php
class AuthService {
public function __construct(
private readonly UserRepository $userRepository,
private readonly PasswordService $passwordService
) {}
public function login(string $email, string $password): array {
$user = $this->userRepository->findByEmail($email);
if ($user === null) {
throw new UnauthorizedException('Invalid credentials');
}
if (!$this->passwordService->verify($password, $user['password_hash'])) {
throw new UnauthorizedException('Invalid credentials');
}
if ($user['status'] !== 'active') {
throw new UnauthorizedException('Account is not active');
}
// Replace with actual JWT implementation if used
$token = base64_encode(json_encode([
'sub' => $user['id'],
'email' => $user['email'],
'role' => $user['role'],
'exp' => time() + (60 * 60 * 24),
]));
return [
'token' => $token,
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'username' => $user['username'],
'role' => $user['role'],
],
];
}
public function requireUser(): array {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.+)$/i', $header, $matches)) {
throw new UnauthorizedException('Missing bearer token');
}
$token = $matches[1];
$payload = json_decode(base64_decode($token), true);
if (!$payload || ($payload['exp'] ?? 0) < time()) {
throw new UnauthorizedException('Invalid or expired token');
}
return [
'id' => $payload['sub'],
'email' => $payload['email'],
'role' => $payload['role'],
];
}
public function requireAdmin(): array {
$user = $this->requireUser();
if ($user['role'] !== 'admin') {
throw new ForbiddenException('Admin access required');
}
return $user;
}
}Validation and Sanitization
Request Validation
<?php
class Validator {
public static function validateRecipePayload(array $payload): array {
$errors = [];
if (empty(trim($payload['title'] ?? ''))) {
$errors['title'][] = 'Title is required';
} elseif (mb_strlen($payload['title']) > 200) {
$errors['title'][] = 'Title must be at most 200 characters';
}
if (!empty($payload['difficulty'])) {
$allowed = ['Easy', 'Medium', 'Hard'];
if (!in_array($payload['difficulty'], $allowed, true)) {
$errors['difficulty'][] = 'Invalid difficulty value';
}
}
if (isset($payload['servings']) && ((int)$payload['servings'] < 1 || (int)$payload['servings'] > 100)) {
$errors['servings'][] = 'Servings must be between 1 and 100';
}
return $errors;
}
public static function sanitizeString(string $value): string {
return trim(filter_var($value, FILTER_SANITIZE_FULL_SPECIAL_CHARS));
}
public static function sanitizeEmail(string $value): string {
return trim(filter_var($value, FILTER_SANITIZE_EMAIL));
}
}CORS Middleware Pattern
<?php
class CorsMiddleware {
public static function handle(): void {
$allowedOrigins = [
'http://localhost:5173',
'http://127.0.0.1:5173',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: {$origin}");
}
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
}
}Error Handling Strategy
Global Exception Handler
<?php
set_exception_handler(function (Throwable $e): void {
error_log(sprintf(
'[%s] %s in %s:%d',
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
$statusCode = match (true) {
$e instanceof ValidationException => 422,
$e instanceof UnauthorizedException => 401,
$e instanceof ForbiddenException => 403,
$e instanceof NotFoundException => 404,
default => 500,
};
$message = $statusCode >= 500
? 'Internal server error'
: $e->getMessage();
JsonResponse::error($message, $statusCode);
});XAMPP-Specific Notes
Required PHP Extensions
pdopdo_mysqlopensslmbstringjson
Recommended php.ini Settings (Development)
display_errors = Onerror_reporting = E_ALLlog_errors = Ondate.timezone = Asia/Bangkok(or your timezone)
Apache Requirements
mod_rewriteenabled.htaccesssupport enabled (AllowOverride All)
References
Official
- PHP Documentation: https://www.php.net/docs.php
- PHP Migration Guides: https://www.php.net/manual/en/appendices.php
- PDO Manual: https://www.php.net/manual/en/book.pdo.php
Security
- OWASP PHP Security Cheat Sheet: https://cheatsheetseries.owasp.org/
- PHP Security Guide: https://www.php.net/manual/en/security.php
Standards
- PSR Standards: https://www.php-fig.org/psr/
- PSR-12 Coding Style: https://www.php-fig.org/psr/psr-12/
# XAMPP PHP Environment Setup Script
# PowerShell script to configure XAMPP for PHP development
param(
[string]$XamppPath = "C:\xampp",
[string]$ProjectPath = ""
)
Write-Host "XAMPP PHP Environment Setup" -ForegroundColor Cyan
Write-Host "================================" -ForegroundColor Cyan
# Check if XAMPP is installed
if (-not (Test-Path $XamppPath)) {
Write-Host "ERROR: XAMPP not found at $XamppPath" -ForegroundColor Red
Write-Host "Please install XAMPP from https://www.apachefriends.org/" -ForegroundColor Yellow
exit 1
}
$PhpIniPath = "$XamppPath\php\php.ini"
$HttpdConfPath = "$XamppPath\apache\conf\httpd.conf"
# Backup original files
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
Copy-Item $PhpIniPath "$PhpIniPath.bak_$Timestamp" -Force
Copy-Item $HttpdConfPath "$HttpdConfPath.bak_$Timestamp" -Force
Write-Host "Backed up original configuration files" -ForegroundColor Green
# Configure PHP settings
Write-Host "`nConfiguring PHP settings..." -ForegroundColor Yellow
$PhpIniContent = Get-Content $PhpIniPath -Raw
# Enable error reporting for development
$PhpIniContent = $PhpIniContent -replace 'error_reporting = .*', 'error_reporting = E_ALL'
$PhpIniContent = $PhpIniContent -replace 'display_errors = .*', 'display_errors = On'
$PhpIniContent = $PhpIniContent -replace 'display_startup_errors = .*', 'display_startup_errors = On'
# Increase upload limits
$PhpIniContent = $PhpIniContent -replace 'upload_max_filesize = .*', 'upload_max_filesize = 10M'
$PhpIniContent = $PhpIniContent -replace 'post_max_size = .*', 'post_max_size = 10M'
# Set timezone (modify as needed)
$PhpIniContent = $PhpIniContent -replace ';date.timezone =.*', "date.timezone = `"UTC`""
# Ensure PDO extensions are enabled
if ($PhpIniContent -notmatch 'extension=pdo_mysql') {
$PhpIniContent = $PhpIniContent -replace '(;)?extension=pdo_mysql', 'extension=pdo_mysql'
}
# Save PHP configuration
$PhpIniContent | Set-Content $PhpIniPath -Encoding UTF8
Write-Host "PHP configuration updated" -ForegroundColor Green
# Configure Apache for project
if ($ProjectPath -and (Test-Path $ProjectPath)) {
Write-Host "`nConfiguring Apache virtual host..." -ForegroundColor Yellow
$ProjectName = Split-Path $ProjectPath -Leaf
$VhostConfig = @"
<VirtualHost *:80>
ServerName $ProjectName.local
DocumentRoot "$ProjectPath"
<Directory "$ProjectPath">
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog "logs/${ProjectName}_error.log"
CustomLog "logs/${ProjectName}_access.log" common
</VirtualHost>
"@
$HttpdVhostPath = "$XamppPath\apache\conf\extra\httpd-vhosts.conf"
# Backup vhosts file
Copy-Item $HttpdVhostPath "$HttpdVhostPath.bak_$Timestamp" -Force
# Add virtual host
$VhostConfig | Add-Content $HttpdVhostPath
Write-Host "Virtual host configured: http://$ProjectName.local" -ForegroundColor Green
Write-Host "Add '127.0.0.1 $ProjectName.local' to C:\Windows\System32\drivers\etc\hosts" -ForegroundColor Yellow
}
# Start Apache and MySQL
Write-Host "`nStarting XAMPP services..." -ForegroundColor Yellow
$XamppControl = "$XamppPath\xampp_control.exe"
if (Test-Path $XamppControl) {
Start-Process $XamppControl
Write-Host "XAMPP Control Panel opened" -ForegroundColor Green
} else {
Write-Host "Starting Apache service..." -ForegroundColor Yellow
Start-Service Apache2 -ErrorAction SilentlyContinue
Write-Host "Starting MySQL service..." -ForegroundColor Yellow
Start-Service MySQL -ErrorAction SilentlyContinue
}
Write-Host "`nSetup complete!" -ForegroundColor Green
Write-Host "PHP version:" -ForegroundColor Cyan
& "$XamppPath\php\php.exe" -v
Write-Host "`nTo use:" -ForegroundColor Cyan
Write-Host " 1. Place your PHP files in htdocs folder or use virtual host" -ForegroundColor White
Write-Host " 2. Access via http://localhost/your-project or http://your-project.local" -ForegroundColor White
Write-Host " 3. MySQL: username 'root', password (empty by default)" -ForegroundColor White
Related skills
FAQ
What does php-development do?
php-development is a Claude Code skill for ai & agent building.
When should I use php-development?
When you need to helps with ai & agent building tasks., or when php-development is a claude code skill for ai & agent building.
What are the main capabilities?
php-development; AI & Agent Building; AI-coding skill.