| @@ -0,0 +1,10 @@ | |||||
| { | |||||
| "permissions": { | |||||
| "allow": [ | |||||
| "Bash(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force)", | |||||
| "Bash(Select-Object Mode, Name)", | |||||
| "Bash(Format-Table -AutoSize)", | |||||
| "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force | Where-Object {$_.Name -match '^[A-Z]'} | Select-Object Mode, Name)" | |||||
| ] | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| /vendor/ | |||||
| .env | |||||
| .env.local | |||||
| .env.* | |||||
| *.log | |||||
| .DS_Store | |||||
| .idea/ | |||||
| .vscode/ | |||||
| /public/uploads/ | |||||
| /storage/cache/ | |||||
| /storage/logs/ | |||||
| /database/app.sqlite | |||||
| /database/*.sqlite | |||||
| /database/*.sqlite-shm | |||||
| /database/*.sqlite-wal | |||||
| /database/*.sqlite-journal | |||||
| .phpunit.result.cache | |||||
| Thumbs.db | |||||
| docker-compose.yml | |||||
| Dockerfile | |||||
| @@ -0,0 +1,907 @@ | |||||
| # AGENT.md — PHP Coding Standard | |||||
| 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/ | |||||
| --- | |||||
| ## 1. Core Philosophy | |||||
| Write PHP that is: | |||||
| - **Readable** before clever. | |||||
| - **Secure by default**. | |||||
| - **Consistent with community standards**. | |||||
| - **Easy to test, debug, and refactor**. | |||||
| - **Separated by responsibility**: routing, controllers, services, models, persistence, templates, and configuration should not be mixed together. | |||||
| PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style. | |||||
| --- | |||||
| ## 2. PHP Version Standard | |||||
| Use the current stable PHP version supported by the project. | |||||
| Default expectation: | |||||
| ```text | |||||
| 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: | |||||
| ```json | |||||
| { | |||||
| "require": { | |||||
| "php": ">=8.2" | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 3. Coding Style | |||||
| Follow recognized PHP standards unless the repository already defines stricter rules. | |||||
| Preferred standards: | |||||
| - **PSR-1**: Basic Coding Standard | |||||
| - **PSR-12**: Extended Coding Style | |||||
| - **PSR-4**: Autoloading | |||||
| Use automated tooling rather than manual formatting arguments. | |||||
| Recommended tools: | |||||
| ```bash | |||||
| composer require --dev squizlabs/php_codesniffer | |||||
| composer require --dev friendsofphp/php-cs-fixer | |||||
| ``` | |||||
| Example checks: | |||||
| ```bash | |||||
| vendor/bin/phpcs --standard=PSR12 src tests | |||||
| vendor/bin/php-cs-fixer fix --dry-run --diff | |||||
| ``` | |||||
| Example fix: | |||||
| ```bash | |||||
| vendor/bin/php-cs-fixer fix | |||||
| ``` | |||||
| ### Naming Rules | |||||
| Use English names for code symbols and infrastructure. | |||||
| Use: | |||||
| ```php | |||||
| class InvoiceRepository | |||||
| { | |||||
| public function findByCustomerId(int $customerId): array | |||||
| { | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Avoid unclear abbreviations: | |||||
| ```php | |||||
| class InvRepo | |||||
| { | |||||
| public function fbcid($cid) | |||||
| { | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| ### Formatting Rules | |||||
| - Use `<?php` tags. Do not use short open tags. | |||||
| - Use strict types at the top of new PHP files when practical: | |||||
| ```php | |||||
| declare(strict_types=1); | |||||
| ``` | |||||
| - One class per file. | |||||
| - Match namespaces to directory structure. | |||||
| - Keep functions and methods small and focused. | |||||
| - Prefer explicit visibility: `public`, `protected`, or `private`. | |||||
| - Avoid global state unless required by the framework or legacy integration. | |||||
| --- | |||||
| ## 4. Project Structure | |||||
| Prefer a predictable structure. | |||||
| Example: | |||||
| ```text | |||||
| 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. | |||||
| - Do not expose `src/`, `config/`, `tests/`, `vendor/`, or `.env` files through the web server. | |||||
| - Put application code under `src/`. | |||||
| - Put generated cache/log files under `var/` or another ignored runtime directory. | |||||
| - Keep secrets outside the web root. | |||||
| --- | |||||
| ## 5. Namespaces and Autoloading | |||||
| All new application classes must use namespaces. | |||||
| Use PSR-4 autoloading through Composer. | |||||
| Example `composer.json`: | |||||
| ```json | |||||
| { | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "App\\": "src/" | |||||
| } | |||||
| }, | |||||
| "autoload-dev": { | |||||
| "psr-4": { | |||||
| "Tests\\": "tests/" | |||||
| } | |||||
| } | |||||
| } | |||||
| ``` | |||||
| After changing autoload rules, run: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Example class: | |||||
| ```php | |||||
| <?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')); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 6. Dependency Management | |||||
| Use Composer for PHP dependencies. | |||||
| Rules: | |||||
| - Add packages with `composer require` or `composer require --dev`. | |||||
| - Commit `composer.json` and `composer.lock` for applications. | |||||
| - Do not manually copy vendor libraries into the project. | |||||
| - Do not edit files under `vendor/`. | |||||
| - Prefer maintained packages with clear documentation, tests, and recent releases. | |||||
| - Remove unused packages. | |||||
| Commands: | |||||
| ```bash | |||||
| composer install | |||||
| composer update vendor/package | |||||
| composer audit | |||||
| composer validate | |||||
| ``` | |||||
| Use `composer update` intentionally. Do not casually update every dependency in unrelated work. | |||||
| --- | |||||
| ## 7. Object-Oriented Design | |||||
| Prefer clear object-oriented code for domain and application logic. | |||||
| Use classes for cohesive behavior: | |||||
| ```php | |||||
| 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: | |||||
| - Keep controllers thin. | |||||
| - Put business rules in services or domain objects. | |||||
| - Put persistence logic in repositories or data access classes. | |||||
| - Use interfaces when multiple implementations are expected or when it improves testing. | |||||
| - Avoid huge “utility” classes. | |||||
| - Avoid magic methods unless they provide clear framework integration or a documented benefit. | |||||
| --- | |||||
| ## 8. Dependency Injection | |||||
| Prefer dependency injection over creating dependencies inside classes. | |||||
| Good: | |||||
| ```php | |||||
| 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: | |||||
| ```php | |||||
| final class RegisterUser | |||||
| { | |||||
| public function handle(string $email, string $plainPassword): void | |||||
| { | |||||
| $users = new UserRepository(); | |||||
| $passwords = new PasswordHasher(); | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Constructor injection is preferred for required dependencies. | |||||
| - Do not use service locators casually. | |||||
| - Do not hide dependencies in global variables. | |||||
| - Keep dependency containers at application boundaries, not inside domain logic. | |||||
| --- | |||||
| ## 9. Database Access | |||||
| Use PDO or a well-maintained database abstraction layer/ORM. | |||||
| Never concatenate untrusted input into SQL. | |||||
| Bad: | |||||
| ```php | |||||
| $sql = "SELECT * FROM users WHERE id = " . $_GET['id']; | |||||
| ``` | |||||
| Good: | |||||
| ```php | |||||
| $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: | |||||
| - Use prepared statements and bound parameters. | |||||
| - Validate input before using it in writes. | |||||
| - Keep SQL out of templates. | |||||
| - Keep database access out of controllers where practical. | |||||
| - Use transactions when multiple writes must succeed or fail together. | |||||
| - Do not rely only on client-side validation. | |||||
| - Do not expose raw database errors to users. | |||||
| Transaction example: | |||||
| ```php | |||||
| $pdo->beginTransaction(); | |||||
| try { | |||||
| $orders->create($order); | |||||
| $auditLog->record('order.created', $order->id()); | |||||
| $pdo->commit(); | |||||
| } catch (Throwable $e) { | |||||
| $pdo->rollBack(); | |||||
| throw $e; | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 10. Input Validation and Output Escaping | |||||
| Treat all external data as untrusted. | |||||
| Untrusted data includes: | |||||
| - `$_GET` | |||||
| - `$_POST` | |||||
| - `$_REQUEST` | |||||
| - `$_COOKIE` | |||||
| - `$_SERVER` | |||||
| - uploaded files | |||||
| - request bodies | |||||
| - session values | |||||
| - database values originally supplied by users | |||||
| - third-party API responses | |||||
| ### Validate on Input | |||||
| Example: | |||||
| ```php | |||||
| $email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); | |||||
| if ($email === false || $email === null) { | |||||
| throw new InvalidArgumentException('A valid email address is required.'); | |||||
| } | |||||
| ``` | |||||
| ### Escape on Output | |||||
| For HTML output: | |||||
| ```php | |||||
| function e(string $value): string | |||||
| { | |||||
| return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| ``` | |||||
| Usage: | |||||
| ```php | |||||
| <p><?= e($user->name()) ?></p> | |||||
| ``` | |||||
| Rules: | |||||
| - Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell. | |||||
| - Do not use the same escaping function for every context. | |||||
| - Prefer template engines with automatic escaping when appropriate. | |||||
| - Avoid allowing raw HTML from users. If required, sanitize with a proven whitelist sanitizer. | |||||
| - Use `escapeshellarg()` when passing controlled values to shell commands, and avoid shell execution when possible. | |||||
| - Never trust file paths supplied by users. Reject path traversal values such as `../`, `/`, `\`, and null bytes. | |||||
| --- | |||||
| ## 11. Passwords and Authentication | |||||
| Never store plain-text passwords. | |||||
| Use PHP’s password API: | |||||
| ```php | |||||
| $hash = password_hash($plainPassword, PASSWORD_DEFAULT); | |||||
| if (! password_verify($plainPassword, $hash)) { | |||||
| throw new RuntimeException('Invalid credentials.'); | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Use `password_hash()` for new password hashes. | |||||
| - Use `password_verify()` for login checks. | |||||
| - Use `password_needs_rehash()` when algorithm/cost settings change. | |||||
| - Do not create your own password hashing algorithm. | |||||
| - Do not use general-purpose hashes like `md5`, `sha1`, or raw `sha256` for passwords. | |||||
| - Rate-limit login attempts. | |||||
| - Regenerate session IDs after login. | |||||
| - Use secure, HTTP-only, SameSite cookies for sessions. | |||||
| --- | |||||
| ## 12. Serialization and Data Exchange | |||||
| Do not call `unserialize()` on untrusted data. | |||||
| Prefer JSON for data exchange: | |||||
| ```php | |||||
| $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); | |||||
| $json = json_encode($data, JSON_THROW_ON_ERROR); | |||||
| ``` | |||||
| Rules: | |||||
| - Use `JSON_THROW_ON_ERROR` for new code. | |||||
| - Validate decoded data before using it. | |||||
| - Avoid PHP serialization for data that crosses trust boundaries. | |||||
| --- | |||||
| ## 13. Configuration and Secrets | |||||
| Rules: | |||||
| - Keep secrets out of source control. | |||||
| - Do not commit passwords, API keys, private keys, tokens, or production DSNs. | |||||
| - Store configuration outside the public web root. | |||||
| - Use environment variables or ignored local config files for secrets. | |||||
| - Provide a safe example file such as `.env.example`. | |||||
| Example `.gitignore` entries: | |||||
| ```text | |||||
| .env | |||||
| .env.local | |||||
| /config/local.php | |||||
| /var/cache/ | |||||
| /var/log/ | |||||
| /vendor/ | |||||
| ``` | |||||
| Example `.env.example`: | |||||
| ```text | |||||
| APP_ENV=local | |||||
| APP_DEBUG=true | |||||
| DATABASE_URL=mysql://user:password@localhost:3306/app | |||||
| ``` | |||||
| --- | |||||
| ## 14. Error Handling and Logging | |||||
| Use exceptions for exceptional failure paths. | |||||
| Development: | |||||
| - Show errors locally. | |||||
| - Log errors. | |||||
| - Use Xdebug when debugging complex issues. | |||||
| Production: | |||||
| - Do not display errors to users. | |||||
| - Log errors to a secure log destination. | |||||
| - Return safe, generic error messages. | |||||
| - Preserve enough context in logs for troubleshooting. | |||||
| Do not leak: | |||||
| - stack traces to users | |||||
| - SQL statements with secrets | |||||
| - environment variables | |||||
| - full filesystem paths | |||||
| - tokens or passwords | |||||
| Example: | |||||
| ```php | |||||
| 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.'; | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 15. Templates and Views | |||||
| Keep presentation separate from business logic. | |||||
| Rules: | |||||
| - Do not query the database from templates. | |||||
| - Do not place business rules in templates. | |||||
| - Escape output by default. | |||||
| - Prefer simple view models or arrays passed into templates. | |||||
| - Use a template engine with automatic escaping when it fits the project. | |||||
| Plain PHP template example: | |||||
| ```php | |||||
| <h1><?= e($pageTitle) ?></h1> | |||||
| <ul> | |||||
| <?php foreach ($users as $user): ?> | |||||
| <li><?= e($user->name()) ?></li> | |||||
| <?php endforeach; ?> | |||||
| </ul> | |||||
| ``` | |||||
| --- | |||||
| ## 16. HTTP and Web Application Rules | |||||
| Rules: | |||||
| - Use the front controller pattern where appropriate. | |||||
| - Keep routing separate from business logic. | |||||
| - Validate request methods. | |||||
| - Use CSRF protection for state-changing forms. | |||||
| - Use proper HTTP status codes. | |||||
| - Redirect after successful POST to avoid duplicate form submission. | |||||
| - Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy. | |||||
| Example POST guard: | |||||
| ```php | |||||
| if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | |||||
| http_response_code(405); | |||||
| exit('Method Not Allowed'); | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## 17. Security Checklist | |||||
| Before completing any feature, verify: | |||||
| - [ ] All external input is validated. | |||||
| - [ ] All output is escaped for the correct context. | |||||
| - [ ] SQL uses prepared statements or safe query builders. | |||||
| - [ ] Authentication and authorization are checked server-side. | |||||
| - [ ] Secrets are not committed. | |||||
| - [ ] Errors are not exposed in production responses. | |||||
| - [ ] File uploads validate size, extension, MIME type, and storage path. | |||||
| - [ ] Passwords use `password_hash()` and `password_verify()`. | |||||
| - [ ] CSRF protection exists for state-changing requests. | |||||
| - [ ] Dangerous functions are avoided or justified: `eval`, `exec`, `shell_exec`, `system`, `passthru`, `unserialize`. | |||||
| - [ ] Dependencies have no known vulnerabilities according to `composer audit`. | |||||
| --- | |||||
| ## 18. Testing Standard | |||||
| Automated tests are expected for new behavior. | |||||
| Preferred tools: | |||||
| - PHPUnit | |||||
| - Pest, if the project already uses it | |||||
| Rules: | |||||
| - Add or update tests with every behavior change. | |||||
| - Cover success paths and failure paths. | |||||
| - Unit-test business logic. | |||||
| - Integration-test database and framework wiring where useful. | |||||
| - Functional-test important user flows. | |||||
| - Avoid relying on `var_dump()` or manual browser testing as the only verification. | |||||
| Example PHPUnit test: | |||||
| ```php | |||||
| 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: | |||||
| ```bash | |||||
| vendor/bin/phpunit | |||||
| ``` | |||||
| --- | |||||
| ## 19. Static Analysis and Quality Gates | |||||
| Use static analysis when available. | |||||
| Recommended tools: | |||||
| ```bash | |||||
| composer require --dev phpstan/phpstan | |||||
| composer require --dev vimeo/psalm | |||||
| ``` | |||||
| Common quality commands: | |||||
| ```bash | |||||
| 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. | |||||
| --- | |||||
| ## 20. Documentation | |||||
| Use PHPDoc where it adds clarity, especially for arrays, generics-like structures, complex return values, and public APIs. | |||||
| Good: | |||||
| ```php | |||||
| /** | |||||
| * @return list<Customer> | |||||
| */ | |||||
| public function findActiveCustomers(): array | |||||
| { | |||||
| // ... | |||||
| } | |||||
| ``` | |||||
| Avoid noisy comments that repeat the code: | |||||
| ```php | |||||
| // Increment i by one. | |||||
| $i++; | |||||
| ``` | |||||
| Rules: | |||||
| - Explain why, not just what. | |||||
| - Document non-obvious tradeoffs. | |||||
| - Keep README setup instructions current. | |||||
| - Update examples when behavior changes. | |||||
| --- | |||||
| ## 21. Performance and Caching | |||||
| Rules: | |||||
| - Measure before optimizing. | |||||
| - Avoid unnecessary database queries in loops. | |||||
| - Use pagination for large result sets. | |||||
| - Cache expensive reads where appropriate. | |||||
| - Use OPcache in production. | |||||
| - Do not cache user-specific sensitive data in shared caches without a clear key strategy. | |||||
| --- | |||||
| ## 22. Agent Workflow | |||||
| When modifying this codebase, the AI agent must: | |||||
| 1. Inspect existing project conventions before adding new patterns. | |||||
| 2. Prefer small, focused changes. | |||||
| 3. Preserve public behavior unless explicitly asked to change it. | |||||
| 4. Add or update tests when behavior changes. | |||||
| 5. Run relevant checks when possible. | |||||
| 6. Explain any checks that could not be run. | |||||
| 7. Avoid introducing new dependencies unless they solve a clear problem. | |||||
| 8. Never place secrets in code, tests, fixtures, logs, or documentation. | |||||
| 9. Keep generated code consistent with this file. | |||||
| 10. Leave the repository better organized than it was found. | |||||
| --- | |||||
| ## 23. Pull Request / Review Checklist | |||||
| Before considering work complete: | |||||
| - [ ] Code follows PSR-12 or project-specific style. | |||||
| - [ ] Namespaces and autoloading are correct. | |||||
| - [ ] Composer files are valid. | |||||
| - [ ] No unrelated dependency updates were introduced. | |||||
| - [ ] New behavior is tested. | |||||
| - [ ] Existing tests pass. | |||||
| - [ ] SQL is parameterized. | |||||
| - [ ] User input is validated. | |||||
| - [ ] Output is escaped. | |||||
| - [ ] No secrets are committed. | |||||
| - [ ] Errors are handled safely. | |||||
| - [ ] Documentation was updated where needed. | |||||
| --- | |||||
| ## 24. Legacy PHP Exception Policy | |||||
| If this project contains legacy PHP: | |||||
| - Do not rewrite large areas without approval. | |||||
| - Add tests around legacy behavior before refactoring. | |||||
| - Improve safety incrementally. | |||||
| - Replace deprecated patterns as touched. | |||||
| - Avoid mixing modernization with unrelated feature work. | |||||
| - Document any compatibility constraints. | |||||
| Legacy code should still move toward: | |||||
| - Composer autoloading | |||||
| - Namespaces | |||||
| - PDO/prepared statements | |||||
| - Centralized configuration | |||||
| - Automated tests | |||||
| - Safer error handling | |||||
| --- | |||||
| ## 25. Non-Negotiable Rules | |||||
| The agent must not: | |||||
| - Commit secrets. | |||||
| - Build SQL using untrusted string concatenation. | |||||
| - Store plain-text passwords. | |||||
| - Use `md5`, `sha1`, or raw fast hashes for passwords. | |||||
| - Display production errors to users. | |||||
| - `unserialize()` untrusted data. | |||||
| - Put database queries in templates. | |||||
| - Edit files under `vendor/`. | |||||
| - Add dependencies without a clear reason. | |||||
| - Ignore failing tests or quality checks without explanation. | |||||
| --- | |||||
| ## 26. Recommended Composer Scripts | |||||
| A project may include scripts like this: | |||||
| ```json | |||||
| { | |||||
| "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: | |||||
| ```bash | |||||
| composer quality | |||||
| ``` | |||||
| --- | |||||
| ## 27. Final Instruction to Coding Agents | |||||
| When in doubt, choose the boring, obvious, secure PHP solution: | |||||
| - Composer-managed dependencies | |||||
| - PSR-style code | |||||
| - Namespaced classes | |||||
| - Dependency injection | |||||
| - PDO prepared statements | |||||
| - Escaped output | |||||
| - Tested behavior | |||||
| - Clear errors and logs | |||||
| - No secrets in source control | |||||
| ## Project Overview | |||||
| This project is a small PHP MVC framework called MindVisionCode PHP. | |||||
| It is intentionally inspired by a Classic ASP MVC framework style: | |||||
| - Central dispatcher | |||||
| - Controllers and actions | |||||
| - ViewModels | |||||
| - Repository classes | |||||
| - Simple validation | |||||
| - Database migrations | |||||
| - Small, readable files | |||||
| - Minimal dependencies | |||||
| Do not turn this into Laravel, Symfony, Slim, or another large framework. | |||||
| ## Tech Stack | |||||
| - PHP 8.2+ | |||||
| - Composer | |||||
| - PSR-4 autoloading | |||||
| - PDO | |||||
| - PHP views | |||||
| - Optional SQLite/MySQL/SQL Server through PDO | |||||
| ## Development Commands | |||||
| Install dependencies: | |||||
| ```bash | |||||
| composer install | |||||
| ``` | |||||
| Regenerate autoload files: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Run local server: | |||||
| ```bash | |||||
| php -S localhost:8000 -t public | |||||
| ``` | |||||
| Run basic tests: | |||||
| ```bash | |||||
| php tests/run.php | |||||
| ``` | |||||
| ## Coding Rules | |||||
| - Keep code simple and readable. | |||||
| - Prefer small classes. | |||||
| - Use typed properties and return types where practical. | |||||
| - Avoid hidden magic. | |||||
| - Do not add dependencies without a clear reason. | |||||
| - Preserve the framework style. | |||||
| - Explain any architectural changes. | |||||
| ## Request Flow | |||||
| Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response | |||||
| @@ -0,0 +1,32 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\ViewModels\HomeIndexViewModel; | |||||
| use Core\Controller; | |||||
| class HomeController extends Controller | |||||
| { | |||||
| public function index() | |||||
| { | |||||
| $model = new HomeIndexViewModel(); | |||||
| $model->title = 'MindVisionCode PHP'; | |||||
| $model->eyebrow = 'Small MVC framework'; | |||||
| $model->message = 'A lightweight PHP MVC starter with a central dispatcher, clean controllers, SQLite-backed repositories, and readable conventions.'; | |||||
| $model->routeExample = '/users/123'; | |||||
| return $this->view('home.index', [ | |||||
| 'model' => $model, | |||||
| 'pageTitle' => $model->title, | |||||
| ]); | |||||
| } | |||||
| public function user(string $id) | |||||
| { | |||||
| return $this->json([ | |||||
| 'userId' => $id, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class User | |||||
| { | |||||
| public int|string|null $id = null; | |||||
| public string $name = ''; | |||||
| public string $email = ''; | |||||
| } | |||||
| @@ -0,0 +1,21 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use Core\Repository; | |||||
| class UserRepository extends Repository | |||||
| { | |||||
| protected string $table = 'users'; | |||||
| protected string $primaryKey = 'id'; | |||||
| public function findByEmail(string $email): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| 'SELECT * FROM users WHERE email = :email', | |||||
| ['email' => $email] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\ViewModels; | |||||
| class HomeIndexViewModel | |||||
| { | |||||
| public string $title = ''; | |||||
| public string $eyebrow = ''; | |||||
| public string $message = ''; | |||||
| public string $routeExample = ''; | |||||
| } | |||||
| @@ -0,0 +1,39 @@ | |||||
| <section class="hero"> | |||||
| <div class="hero-copy"> | |||||
| <span class="eyebrow"><?= e($model->eyebrow) ?></span> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p class="hero-text"><?= e($model->message) ?></p> | |||||
| <div class="hero-actions"> | |||||
| <a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Employee Form</a> | |||||
| <a class="button button-secondary" href="#framework-highlights">See Highlights</a> | |||||
| </div> | |||||
| </div> | |||||
| <aside class="hero-panel" aria-label="Framework route example"> | |||||
| <p class="panel-label">Request Flow</p> | |||||
| <code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code> | |||||
| <div class="route-callout"> | |||||
| <span>Employee entry page</span> | |||||
| <a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a> | |||||
| </div> | |||||
| </aside> | |||||
| </section> | |||||
| <section class="feature-grid" id="framework-highlights"> | |||||
| <article class="feature-card"> | |||||
| <h2>Readable by design</h2> | |||||
| <p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>Classic MVC feel</h2> | |||||
| <p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>SQLite ready</h2> | |||||
| <p>Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.</p> | |||||
| </article> | |||||
| </section> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require __DIR__ . '/../partials/header.php'; | |||||
| ?> | |||||
| <main class="page-content"> | |||||
| <div class="container"> | |||||
| <?= $content ?> | |||||
| </div> | |||||
| </main> | |||||
| <?php require __DIR__ . '/../partials/footer.php'; ?> | |||||
| @@ -0,0 +1,9 @@ | |||||
| <footer class="site-footer"> | |||||
| <div class="container footer-inner"> | |||||
| <p>MindVisionCode PHP keeps the framework small, readable, and ready for real features.</p> | |||||
| <p>© <?= e((string) date('Y')) ?> MindVisionCode</p> | |||||
| </div> | |||||
| </footer> | |||||
| </div> | |||||
| </body> | |||||
| </html> | |||||
| @@ -0,0 +1,42 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| $navigationItems = [ | |||||
| ['label' => 'Home', 'href' => '/'], | |||||
| ['label' => 'Example JSON', 'href' => '/users/123'], | |||||
| ]; | |||||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |||||
| $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | |||||
| ?> | |||||
| <!DOCTYPE html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <meta charset="UTF-8"> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| <title><?= e($pageTitle ?? 'MindVisionCode PHP') ?></title> | |||||
| <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | |||||
| </head> | |||||
| <body> | |||||
| <div class="page-shell"> | |||||
| <header class="site-header"> | |||||
| <div class="container header-inner"> | |||||
| <a class="brand" href="/"> | |||||
| <span class="brand-mark">MV</span> | |||||
| <span class="brand-copy"> | |||||
| <strong>MindVisionCode</strong> | |||||
| <small>PHP MVC</small> | |||||
| </span> | |||||
| </a> | |||||
| <nav class="site-nav" aria-label="Primary navigation"> | |||||
| <?php foreach ($navigationItems as $item): ?> | |||||
| <?php $isActive = $currentPath === $item['href']; ?> | |||||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | |||||
| <?= e($item['label']) ?> | |||||
| </a> | |||||
| <?php endforeach; ?> | |||||
| </nav> | |||||
| </div> | |||||
| </header> | |||||
| @@ -0,0 +1,22 @@ | |||||
| { | |||||
| "name": "kci/mindvisioncode", | |||||
| "description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.", | |||||
| "type": "project", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "App\\": "app/", | |||||
| "Core\\": "core/" | |||||
| }, | |||||
| "files": [ | |||||
| "core/helpers.php" | |||||
| ] | |||||
| }, | |||||
| "scripts": { | |||||
| "migrate": "php scripts/migrate.php up", | |||||
| "migrate:down": "php scripts/migrate.php down", | |||||
| "migrate:status": "php scripts/migrate.php status", | |||||
| "migrate:fresh": "php scripts/migrate.php fresh", | |||||
| "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" | |||||
| }, | |||||
| "require": {} | |||||
| } | |||||
| @@ -0,0 +1,18 @@ | |||||
| { | |||||
| "_readme": [ | |||||
| "This file locks the dependencies of your project to a known state", | |||||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||||
| "This file is @generated automatically" | |||||
| ], | |||||
| "content-hash": "a9e5ff0daf78f24b652c32b38b47d81b", | |||||
| "packages": [], | |||||
| "packages-dev": [], | |||||
| "aliases": [], | |||||
| "minimum-stability": "stable", | |||||
| "stability-flags": {}, | |||||
| "prefer-stable": false, | |||||
| "prefer-lowest": false, | |||||
| "platform": {}, | |||||
| "platform-dev": {}, | |||||
| "plugin-api-version": "2.9.0" | |||||
| } | |||||
| @@ -0,0 +1,11 @@ | |||||
| <?php | |||||
| return [ | |||||
| 'dsn' => 'sqlite:' . __DIR__ . '/../database/app.sqlite', | |||||
| 'username' => null, | |||||
| 'password' => null, | |||||
| 'options' => [ | |||||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||||
| ], | |||||
| ]; | |||||
| @@ -0,0 +1,57 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use Exception; | |||||
| use ReflectionFunction; | |||||
| use ReflectionMethod; | |||||
| class App | |||||
| { | |||||
| protected array $bindings = []; | |||||
| public function bind(string $name, mixed $value): void | |||||
| { | |||||
| $this->bindings[$name] = $value; | |||||
| } | |||||
| public function get(string $name): mixed | |||||
| { | |||||
| return $this->bindings[$name] ?? null; | |||||
| } | |||||
| public function call(callable|array|string $handler, array $parameters = []): mixed | |||||
| { | |||||
| if (is_array($handler)) { | |||||
| return $this->callMethod($handler, $parameters); | |||||
| } | |||||
| if (is_callable($handler)) { | |||||
| return $this->callFunction($handler, $parameters); | |||||
| } | |||||
| throw new Exception('Invalid handler.'); | |||||
| } | |||||
| protected function callFunction(callable $handler, array $parameters): mixed | |||||
| { | |||||
| $reflection = new ReflectionFunction($handler); | |||||
| return $reflection->invokeArgs(array_values($parameters)); | |||||
| } | |||||
| protected function callMethod(array $handler, array $parameters): mixed | |||||
| { | |||||
| [$class, $method] = $handler; | |||||
| if (is_string($class)) { | |||||
| $class = new $class(); | |||||
| } | |||||
| $reflection = new ReflectionMethod($class, $method); | |||||
| return $reflection->invokeArgs($class, array_values($parameters)); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Controller | |||||
| { | |||||
| protected function view(string $view, array $data = []): Response | |||||
| { | |||||
| return View::render($view, $data); | |||||
| } | |||||
| protected function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||||
| { | |||||
| return View::fragment($view, $data, $status, $headers); | |||||
| } | |||||
| protected function redirect(string $url): Response | |||||
| { | |||||
| return Response::redirect($url); | |||||
| } | |||||
| protected function json(array $data): Response | |||||
| { | |||||
| return Response::json($data); | |||||
| } | |||||
| protected function requirePost(Request $request): void | |||||
| { | |||||
| if ($request->method() !== 'POST') { | |||||
| throw new \Exception('This action requires POST.'); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use PDO; | |||||
| class Database | |||||
| { | |||||
| protected PDO $pdo; | |||||
| public function __construct(array $config) | |||||
| { | |||||
| $this->pdo = new PDO( | |||||
| $config['dsn'], | |||||
| $config['username'] ?? null, | |||||
| $config['password'] ?? null, | |||||
| $config['options'] ?? [] | |||||
| ); | |||||
| } | |||||
| public function pdo(): PDO | |||||
| { | |||||
| return $this->pdo; | |||||
| } | |||||
| public function query(string $sql, array $parameters = []): array | |||||
| { | |||||
| $statement = $this->pdo->prepare($sql); | |||||
| $statement->execute($parameters); | |||||
| return $statement->fetchAll(PDO::FETCH_ASSOC); | |||||
| } | |||||
| public function first(string $sql, array $parameters = []): ?array | |||||
| { | |||||
| $rows = $this->query($sql, $parameters); | |||||
| return $rows[0] ?? null; | |||||
| } | |||||
| public function execute(string $sql, array $parameters = []): bool | |||||
| { | |||||
| $statement = $this->pdo->prepare($sql); | |||||
| return $statement->execute($parameters); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,53 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use Throwable; | |||||
| class Dispatcher | |||||
| { | |||||
| protected Router $router; | |||||
| protected App $app; | |||||
| public function __construct(Router $router, App $app) | |||||
| { | |||||
| $this->router = $router; | |||||
| $this->app = $app; | |||||
| } | |||||
| public function dispatch(Request $request): Response | |||||
| { | |||||
| Request::setCurrent($request); | |||||
| try { | |||||
| $route = $this->router->match($request->method(), $request->path()); | |||||
| if (!$route) { | |||||
| return Response::notFound('Page not found.'); | |||||
| } | |||||
| $result = $route->dispatch($this->app); | |||||
| return $this->normalizeResponse($result); | |||||
| } catch (Throwable $e) { | |||||
| return Response::serverError($e->getMessage()); | |||||
| } finally { | |||||
| Request::clearCurrent(); | |||||
| } | |||||
| } | |||||
| protected function normalizeResponse(mixed $result): Response | |||||
| { | |||||
| if ($result instanceof Response) { | |||||
| return $result; | |||||
| } | |||||
| if (is_array($result)) { | |||||
| return Response::json($result); | |||||
| } | |||||
| return new Response((string) $result); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Migration | |||||
| { | |||||
| abstract public function up(Database $database): void; | |||||
| abstract public function down(Database $database): void; | |||||
| } | |||||
| @@ -0,0 +1,289 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class MigrationManager | |||||
| { | |||||
| protected Database $database; | |||||
| protected string $path; | |||||
| public function __construct(Database $database, string $path) | |||||
| { | |||||
| $this->database = $database; | |||||
| $this->path = rtrim($path, '/'); | |||||
| } | |||||
| public function ensureTable(): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)' | |||||
| ); | |||||
| $this->database->execute( | |||||
| 'DELETE FROM migrations | |||||
| WHERE id NOT IN ( | |||||
| SELECT MIN(id) | |||||
| FROM migrations | |||||
| GROUP BY migration | |||||
| )' | |||||
| ); | |||||
| $this->database->execute( | |||||
| 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' | |||||
| ); | |||||
| $files = array_map('basename', $this->migrationFiles()); | |||||
| if ($files === []) { | |||||
| $this->database->execute('DELETE FROM migrations'); | |||||
| return; | |||||
| } | |||||
| $placeholders = []; | |||||
| $parameters = []; | |||||
| foreach ($files as $index => $file) { | |||||
| $placeholder = 'migration_' . $index; | |||||
| $placeholders[] = ':' . $placeholder; | |||||
| $parameters[$placeholder] = $file; | |||||
| } | |||||
| $this->database->execute( | |||||
| 'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')', | |||||
| $parameters | |||||
| ); | |||||
| } | |||||
| public function status(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC'); | |||||
| $ranByName = []; | |||||
| foreach ($ran as $row) { | |||||
| $ranByName[$row['migration']] = $row['ran_at']; | |||||
| } | |||||
| $files = $this->migrationFiles(); | |||||
| $status = []; | |||||
| foreach ($files as $file) { | |||||
| $name = basename($file); | |||||
| $status[] = [ | |||||
| 'migration' => $name, | |||||
| 'ran' => array_key_exists($name, $ranByName), | |||||
| 'ran_at' => $ranByName[$name] ?? null, | |||||
| ]; | |||||
| } | |||||
| return $status; | |||||
| } | |||||
| public function runPending(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $ran = $this->database->query('SELECT migration FROM migrations'); | |||||
| $ranNames = array_column($ran, 'migration'); | |||||
| $files = $this->migrationFiles(); | |||||
| $ranMigrations = []; | |||||
| foreach ($files as $file) { | |||||
| $name = basename($file); | |||||
| if (in_array($name, $ranNames, true)) { | |||||
| continue; | |||||
| } | |||||
| $migration = $this->loadMigration($file); | |||||
| $this->database->pdo()->beginTransaction(); | |||||
| try { | |||||
| $migration->up($this->database); | |||||
| $this->database->execute( | |||||
| 'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)', | |||||
| ['migration' => $name] | |||||
| ); | |||||
| $this->database->pdo()->commit(); | |||||
| $ranMigrations[] = $name; | |||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| } | |||||
| return $ranMigrations; | |||||
| } | |||||
| public function rollback(int $steps = 1): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $steps = max(1, $steps); | |||||
| $applied = $this->database->query( | |||||
| "SELECT MAX(id) AS id, migration | |||||
| FROM migrations | |||||
| GROUP BY migration | |||||
| ORDER BY id DESC | |||||
| LIMIT {$steps}" | |||||
| ); | |||||
| $rolledBack = []; | |||||
| foreach ($applied as $row) { | |||||
| $file = $this->path . '/' . $row['migration']; | |||||
| if (!file_exists($file)) { | |||||
| throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}"); | |||||
| } | |||||
| $migration = $this->loadMigration($file); | |||||
| $this->database->pdo()->beginTransaction(); | |||||
| try { | |||||
| $migration->down($this->database); | |||||
| $this->database->execute( | |||||
| 'DELETE FROM migrations WHERE id = :id', | |||||
| ['id' => $row['id']] | |||||
| ); | |||||
| $this->database->pdo()->commit(); | |||||
| $rolledBack[] = $row['migration']; | |||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| } | |||||
| return $rolledBack; | |||||
| } | |||||
| public function fresh(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $files = array_reverse($this->migrationFiles()); | |||||
| $rolledBack = []; | |||||
| foreach ($files as $file) { | |||||
| $migration = $this->loadMigration($file); | |||||
| $name = basename($file); | |||||
| $this->database->pdo()->beginTransaction(); | |||||
| try { | |||||
| $migration->down($this->database); | |||||
| $this->database->pdo()->commit(); | |||||
| $rolledBack[] = $name; | |||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| } | |||||
| $this->database->execute('DELETE FROM migrations'); | |||||
| $ran = $this->runPending(); | |||||
| return [ | |||||
| 'rolled_back' => $rolledBack, | |||||
| 'migrated' => $ran, | |||||
| ]; | |||||
| } | |||||
| public function make(string $name): string | |||||
| { | |||||
| $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_'); | |||||
| if ($slug === '') { | |||||
| throw new \InvalidArgumentException('Migration name must contain letters or numbers.'); | |||||
| } | |||||
| if (!is_dir($this->path)) { | |||||
| mkdir($this->path, 0777, true); | |||||
| } | |||||
| $timestamp = date('Ymd_His'); | |||||
| $filename = $timestamp . '_' . $slug . '.php'; | |||||
| $path = $this->path . '/' . $filename; | |||||
| if (file_exists($path)) { | |||||
| throw new \RuntimeException("Migration already exists: {$filename}"); | |||||
| } | |||||
| $template = <<<PHP | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database \$database): void | |||||
| { | |||||
| // Write the forward migration here. | |||||
| } | |||||
| public function down(Database \$database): void | |||||
| { | |||||
| // Write the rollback migration here. | |||||
| } | |||||
| }; | |||||
| PHP; | |||||
| file_put_contents($path, $template . PHP_EOL); | |||||
| return $path; | |||||
| } | |||||
| private function migrationFiles(): array | |||||
| { | |||||
| $files = glob($this->path . '/*.php') ?: []; | |||||
| sort($files); | |||||
| return $files; | |||||
| } | |||||
| private function loadMigration(string $file): Migration | |||||
| { | |||||
| $migration = require $file; | |||||
| if ($migration instanceof Migration) { | |||||
| return $migration; | |||||
| } | |||||
| if (is_callable($migration)) { | |||||
| return new class ($migration, basename($file)) extends Migration | |||||
| { | |||||
| private $callback; | |||||
| private string $name; | |||||
| public function __construct(callable $callback, string $name) | |||||
| { | |||||
| $this->callback = $callback; | |||||
| $this->name = $name; | |||||
| } | |||||
| public function up(Database $database): void | |||||
| { | |||||
| ($this->callback)($database); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method."); | |||||
| } | |||||
| }; | |||||
| } | |||||
| throw new \RuntimeException('Migration files must return a Migration instance.'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Repository | |||||
| { | |||||
| protected Database $database; | |||||
| protected string $table; | |||||
| protected string $primaryKey = 'id'; | |||||
| public function __construct(Database $database) | |||||
| { | |||||
| $this->database = $database; | |||||
| } | |||||
| public function find(int|string $id): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| public function all(): array | |||||
| { | |||||
| return $this->database->query("SELECT * FROM {$this->table}"); | |||||
| } | |||||
| public function delete(int|string $id): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| "DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,70 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Request | |||||
| { | |||||
| protected static ?self $current = null; | |||||
| protected string $method; | |||||
| protected string $uri; | |||||
| protected array $get; | |||||
| protected array $post; | |||||
| protected array $server; | |||||
| public function __construct(array $get, array $post, array $server) | |||||
| { | |||||
| $this->get = $get; | |||||
| $this->post = $post; | |||||
| $this->server = $server; | |||||
| $this->method = $server['REQUEST_METHOD'] ?? 'GET'; | |||||
| $this->uri = $server['REQUEST_URI'] ?? '/'; | |||||
| } | |||||
| public static function capture(): self | |||||
| { | |||||
| if (self::$current instanceof self) { | |||||
| return self::$current; | |||||
| } | |||||
| return new self($_GET, $_POST, $_SERVER); | |||||
| } | |||||
| public static function setCurrent(self $request): void | |||||
| { | |||||
| self::$current = $request; | |||||
| } | |||||
| public static function clearCurrent(): void | |||||
| { | |||||
| self::$current = null; | |||||
| } | |||||
| public function method(): string | |||||
| { | |||||
| return strtoupper($this->method); | |||||
| } | |||||
| public function path(): string | |||||
| { | |||||
| $path = parse_url($this->uri, PHP_URL_PATH); | |||||
| return $path ?: '/'; | |||||
| } | |||||
| public function input(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $this->post[$key] ?? $this->get[$key] ?? $default; | |||||
| } | |||||
| public function server(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $this->server[$key] ?? $default; | |||||
| } | |||||
| public function all(): array | |||||
| { | |||||
| return array_merge($this->get, $this->post); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,64 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Response | |||||
| { | |||||
| protected string $content; | |||||
| protected int $status; | |||||
| protected array $headers; | |||||
| public function __construct(string $content = '', int $status = 200, array $headers = []) | |||||
| { | |||||
| $this->content = $content; | |||||
| $this->status = $status; | |||||
| $this->headers = $headers; | |||||
| } | |||||
| public static function json(array $data, int $status = 200): self | |||||
| { | |||||
| return new self( | |||||
| json_encode($data, JSON_PRETTY_PRINT), | |||||
| $status, | |||||
| ['Content-Type' => 'application/json'] | |||||
| ); | |||||
| } | |||||
| public static function redirect(string $url): self | |||||
| { | |||||
| return new self('', 302, ['Location' => $url]); | |||||
| } | |||||
| public static function notFound(string $message = 'Not found'): self | |||||
| { | |||||
| return new self($message, 404); | |||||
| } | |||||
| public static function serverError(string $message = 'Server error'): self | |||||
| { | |||||
| return new self($message, 500); | |||||
| } | |||||
| public function send(): void | |||||
| { | |||||
| http_response_code($this->status); | |||||
| foreach ($this->headers as $name => $value) { | |||||
| header($name . ': ' . $value); | |||||
| } | |||||
| echo $this->content; | |||||
| } | |||||
| public function content(): string | |||||
| { | |||||
| return $this->content; | |||||
| } | |||||
| public function status(): int | |||||
| { | |||||
| return $this->status; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,50 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Route | |||||
| { | |||||
| protected string $method; | |||||
| protected string $path; | |||||
| protected mixed $handler; | |||||
| protected array $parameters = []; | |||||
| public function __construct(string $method, string $path, mixed $handler) | |||||
| { | |||||
| $this->method = strtoupper($method); | |||||
| $this->path = $path; | |||||
| $this->handler = $handler; | |||||
| } | |||||
| public function matches(string $method, string $path): bool | |||||
| { | |||||
| if (strtoupper($method) !== $this->method) { | |||||
| return false; | |||||
| } | |||||
| $routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path); | |||||
| $routePattern = '#^' . $routePattern . '$#'; | |||||
| if (!preg_match($routePattern, $path, $matches)) { | |||||
| return false; | |||||
| } | |||||
| array_shift($matches); | |||||
| preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names); | |||||
| $this->parameters = []; | |||||
| foreach ($names[1] as $index => $name) { | |||||
| $this->parameters[$name] = $matches[$index] ?? null; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| public function dispatch(App $app): mixed | |||||
| { | |||||
| return $app->call($this->handler, $this->parameters); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,39 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Router | |||||
| { | |||||
| protected array $routes = []; | |||||
| public function get(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('GET', $path, $handler); | |||||
| } | |||||
| public function post(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('POST', $path, $handler); | |||||
| } | |||||
| public function add(string $method, string $path, callable|array|string $handler): Route | |||||
| { | |||||
| $route = new Route($method, $path, $handler); | |||||
| $this->routes[] = $route; | |||||
| return $route; | |||||
| } | |||||
| public function match(string $method, string $path): ?Route | |||||
| { | |||||
| foreach ($this->routes as $route) { | |||||
| if ($route->matches($method, $path)) { | |||||
| return $route; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Validator | |||||
| { | |||||
| protected array $errors = []; | |||||
| public function required(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if ($value === null || trim((string) $value) === '') { | |||||
| $this->errors[$field][] = $message ?: "{$field} is required."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function maxLength(string $field, mixed $value, int $max, string $message = ''): self | |||||
| { | |||||
| if (strlen((string) $value) > $max) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be {$max} characters or fewer."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function numeric(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if (!is_numeric($value)) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be numeric."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function passes(): bool | |||||
| { | |||||
| return empty($this->errors); | |||||
| } | |||||
| public function fails(): bool | |||||
| { | |||||
| return !$this->passes(); | |||||
| } | |||||
| public function errors(): array | |||||
| { | |||||
| return $this->errors; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class View | |||||
| { | |||||
| public static function render(string $view, array $data = []): Response | |||||
| { | |||||
| $content = self::renderContent($view, $data); | |||||
| $layoutPath = __DIR__ . '/../app/Views/layouts/app.php'; | |||||
| if (!file_exists($layoutPath)) { | |||||
| return new Response($content); | |||||
| } | |||||
| $pageTitle = self::resolvePageTitle($data); | |||||
| extract($data, EXTR_SKIP); | |||||
| ob_start(); | |||||
| require $layoutPath; | |||||
| $content = ob_get_clean(); | |||||
| return new Response($content); | |||||
| } | |||||
| public static function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||||
| { | |||||
| return new Response(self::renderContent($view, $data), $status, $headers); | |||||
| } | |||||
| private static function renderContent(string $view, array $data): string | |||||
| { | |||||
| $path = __DIR__ . '/../app/Views/' . str_replace('.', '/', $view) . '.php'; | |||||
| if (!file_exists($path)) { | |||||
| throw new \Exception("View not found: {$view}"); | |||||
| } | |||||
| extract($data, EXTR_SKIP); | |||||
| ob_start(); | |||||
| require $path; | |||||
| return (string) ob_get_clean(); | |||||
| } | |||||
| private static function resolvePageTitle(array $data): string | |||||
| { | |||||
| if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { | |||||
| return $data['pageTitle']; | |||||
| } | |||||
| if ( | |||||
| isset($data['model']) && | |||||
| is_object($data['model']) && | |||||
| property_exists($data['model'], 'title') && | |||||
| is_string($data['model']->title) && | |||||
| trim($data['model']->title) !== '' | |||||
| ) { | |||||
| return $data['model']->title; | |||||
| } | |||||
| return 'MindVisionCode PHP'; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,134 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\App; | |||||
| use Core\Database; | |||||
| use Core\MigrationManager; | |||||
| use Core\Response; | |||||
| use Core\View; | |||||
| function app(): App | |||||
| { | |||||
| static $app = null; | |||||
| if ($app === null) { | |||||
| $app = new App(); | |||||
| } | |||||
| return $app; | |||||
| } | |||||
| function view(string $view, array $data = []): Response | |||||
| { | |||||
| return View::render($view, $data); | |||||
| } | |||||
| function redirect(string $url): Response | |||||
| { | |||||
| return Response::redirect($url); | |||||
| } | |||||
| function database(): Database | |||||
| { | |||||
| static $database = null; | |||||
| if ($database === null) { | |||||
| /** @var array<string, mixed> $config */ | |||||
| $config = require __DIR__ . '/../config/database.php'; | |||||
| prepareSqliteDatabase($config['dsn'] ?? ''); | |||||
| $database = new Database($config); | |||||
| } | |||||
| return $database; | |||||
| } | |||||
| function migration_manager(): MigrationManager | |||||
| { | |||||
| static $migrationManager = null; | |||||
| if ($migrationManager === null) { | |||||
| $migrationManager = new MigrationManager(database(), __DIR__ . '/../database/migrations'); | |||||
| } | |||||
| return $migrationManager; | |||||
| } | |||||
| function ensureSessionStarted(): void | |||||
| { | |||||
| if (session_status() === PHP_SESSION_NONE) { | |||||
| session_start(); | |||||
| } | |||||
| } | |||||
| function prepareSqliteDatabase(string $dsn): void | |||||
| { | |||||
| if (!str_starts_with($dsn, 'sqlite:')) { | |||||
| return; | |||||
| } | |||||
| $path = substr($dsn, 7); | |||||
| if ($path === false || $path === '') { | |||||
| return; | |||||
| } | |||||
| $directory = dirname($path); | |||||
| if (!is_dir($directory)) { | |||||
| mkdir($directory, 0777, true); | |||||
| } | |||||
| if (!is_writable($directory)) { | |||||
| @chmod($directory, 0777); | |||||
| } | |||||
| if (!file_exists($path)) { | |||||
| touch($path); | |||||
| } | |||||
| if (!is_writable($path)) { | |||||
| @chmod($path, 0666); | |||||
| } | |||||
| } | |||||
| function e(?string $value): string | |||||
| { | |||||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| function asset(string $path): string | |||||
| { | |||||
| return '/' . ltrim($path, '/'); | |||||
| } | |||||
| function csrf_token(): string | |||||
| { | |||||
| ensureSessionStarted(); | |||||
| if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) { | |||||
| $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); | |||||
| } | |||||
| return $_SESSION['_csrf_token']; | |||||
| } | |||||
| function csrf_field(): string | |||||
| { | |||||
| return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">'; | |||||
| } | |||||
| function verify_csrf_token(?string $token): bool | |||||
| { | |||||
| ensureSessionStarted(); | |||||
| if (!is_string($token) || $token === '') { | |||||
| return false; | |||||
| } | |||||
| $sessionToken = $_SESSION['_csrf_token'] ?? null; | |||||
| return is_string($sessionToken) && hash_equals($sessionToken, $token); | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| <VirtualHost *:80> | |||||
| DocumentRoot /var/www/html/public | |||||
| <Directory /var/www/html/public> | |||||
| Options Indexes FollowSymLinks | |||||
| AllowOverride All | |||||
| Require all granted | |||||
| </Directory> | |||||
| ErrorLog ${APACHE_LOG_DIR}/error.log | |||||
| CustomLog ${APACHE_LOG_DIR}/access.log combined | |||||
| </VirtualHost> | |||||
| @@ -0,0 +1,78 @@ | |||||
| # MindVisionCode PHP | |||||
| A small PHP MVC framework inspired by a Classic ASP MVC framework. | |||||
| ## Run | |||||
| ```bash | |||||
| composer install | |||||
| php scripts/migrate.php up | |||||
| php -S localhost:8000 -t public | |||||
| ``` | |||||
| Open: | |||||
| ```text | |||||
| http://localhost:8000 | |||||
| ``` | |||||
| Try: | |||||
| ```text | |||||
| http://localhost:8000/users/123 | |||||
| ``` | |||||
| Employee form: | |||||
| ```text | |||||
| http://localhost:8000/employees | |||||
| ``` | |||||
| ## Request Flow | |||||
| Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response | |||||
| ## Main Folders | |||||
| - `core/` framework classes | |||||
| - `app/Controllers/` application controllers | |||||
| - `app/ViewModels/` view model classes | |||||
| - `app/Repositories/` data access classes | |||||
| - `app/Views/` PHP templates | |||||
| - `routes/web.php` route definitions | |||||
| - `database/migrations/` migrations | |||||
| - `scripts/` runnable PHP CLI scripts | |||||
| ## SQLite | |||||
| The default database is SQLite and points to: | |||||
| ```text | |||||
| database/app.sqlite | |||||
| ``` | |||||
| The database file is created automatically when the app first needs it. | |||||
| Run migrations from the PHP CLI: | |||||
| ```bash | |||||
| php scripts/migrate.php up | |||||
| php scripts/migrate.php down | |||||
| php scripts/migrate.php status | |||||
| php scripts/migrate.php make create_projects_table | |||||
| php scripts/migrate.php fresh | |||||
| php scripts/migrate.php fresh --seed | |||||
| php scripts/seed_employees.php 1000 | |||||
| ``` | |||||
| ## Frontend Libraries | |||||
| The employee directory page uses: | |||||
| - `htmx` for fragment-based form and summary updates | |||||
| - `Alpine.js` for lightweight page state | |||||
| - `Tabulator` for the interactive employee table | |||||
| ## Flow chart | |||||
| See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. | |||||
| @@ -0,0 +1,80 @@ | |||||
| # Request / Response Flow | |||||
| This chart shows how a browser request moves through the MindVisionCode framework and how a response is built and returned. | |||||
| ```text | |||||
| Browser Request | |||||
| | | |||||
| v | |||||
| public/index.php | |||||
| |-- loads autoload.php / vendor autoload | |||||
| |-- starts the session | |||||
| |-- creates App + Router | |||||
| |-- loads routes/web.php | |||||
| | | |||||
| v | |||||
| Request::capture() | |||||
| | | |||||
| v | |||||
| Dispatcher::dispatch() | |||||
| | | |||||
| +--> no route matched ----> Response::notFound() | |||||
| | | |||||
| +--> route matched -------> Route::dispatch() | |||||
| | | |||||
| v | |||||
| App::call() | |||||
| | | |||||
| +--> controller method | |||||
| | | | |||||
| | v | |||||
| | Controller action | |||||
| | | | |||||
| | +--> repository / service / view model | |||||
| | +--> Database::query() / execute() | |||||
| | +--> view() / json() / redirect() | |||||
| | | |||||
| +--> closure route | |||||
| | | |||||
| v | |||||
| direct response data | |||||
| Dispatcher::normalizeResponse() | |||||
| | | |||||
| +--> Response object --------> Response::send() | |||||
| +--> array ------------------> Response::json() --> Response::send() | |||||
| +--> string -----------------> Response::send() | |||||
| Final result: | |||||
| Browser receives HTML, JSON, or a redirect | |||||
| ``` | |||||
| ## Response building paths | |||||
| ### View response | |||||
| ```text | |||||
| Controller -> view() -> View::render() -> template -> layout -> Response | |||||
| ``` | |||||
| ### JSON response | |||||
| ```text | |||||
| Controller -> json() -> Response::json() -> Response::send() | |||||
| ``` | |||||
| ### Redirect response | |||||
| ```text | |||||
| Controller -> redirect() -> Response::redirect() -> Response::send() | |||||
| ``` | |||||
| ## Key classes | |||||
| - `public/index.php` bootstraps the app | |||||
| - `Core\Dispatcher` matches routes and handles errors | |||||
| - `Core\Route` extracts route parameters | |||||
| - `Core\App` invokes controller methods or closures | |||||
| - `Core\Controller` gives actions helper methods | |||||
| - `Core\View` renders templates into a layout | |||||
| - `Core\Response` sends the final output | |||||
| @@ -0,0 +1,5 @@ | |||||
| RewriteEngine On | |||||
| RewriteCond %{REQUEST_FILENAME} !-f | |||||
| RewriteCond %{REQUEST_FILENAME} !-d | |||||
| RewriteRule ^ index.php [QSA,L] | |||||
| @@ -0,0 +1,468 @@ | |||||
| :root { | |||||
| --page-background: #f4efe7; | |||||
| --surface: rgba(255, 252, 247, 0.88); | |||||
| --surface-strong: #fffdf8; | |||||
| --surface-border: rgba(26, 72, 64, 0.12); | |||||
| --text-primary: #143631; | |||||
| --text-secondary: #4f655f; | |||||
| --accent: #1d7a6d; | |||||
| --accent-strong: #135c52; | |||||
| --accent-soft: #daf1ec; | |||||
| --highlight: #ef7c4d; | |||||
| --shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1); | |||||
| --shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| * { | |||||
| box-sizing: border-box; | |||||
| } | |||||
| html { | |||||
| scroll-behavior: smooth; | |||||
| } | |||||
| body { | |||||
| margin: 0; | |||||
| min-height: 100vh; | |||||
| font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif; | |||||
| color: var(--text-primary); | |||||
| background: | |||||
| radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%), | |||||
| radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%), | |||||
| linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%); | |||||
| } | |||||
| a { | |||||
| color: inherit; | |||||
| } | |||||
| code { | |||||
| font-family: Consolas, "Courier New", monospace; | |||||
| } | |||||
| .page-shell { | |||||
| min-height: 100vh; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| .container { | |||||
| width: min(1120px, calc(100% - 2rem)); | |||||
| margin: 0 auto; | |||||
| } | |||||
| .site-header { | |||||
| position: sticky; | |||||
| top: 0; | |||||
| z-index: 20; | |||||
| backdrop-filter: blur(14px); | |||||
| background: rgba(248, 242, 232, 0.78); | |||||
| border-bottom: 1px solid rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| .header-inner { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| padding: 1rem 0; | |||||
| } | |||||
| .brand { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| gap: 0.85rem; | |||||
| text-decoration: none; | |||||
| } | |||||
| .brand-mark { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| width: 2.75rem; | |||||
| height: 2.75rem; | |||||
| border-radius: 0.95rem; | |||||
| background: linear-gradient(135deg, var(--accent), var(--highlight)); | |||||
| color: #fff; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.08em; | |||||
| box-shadow: var(--shadow-soft); | |||||
| } | |||||
| .brand-copy { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| line-height: 1.1; | |||||
| } | |||||
| .brand-copy strong { | |||||
| font-size: 1rem; | |||||
| } | |||||
| .brand-copy small { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.75rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.14em; | |||||
| } | |||||
| .site-nav { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.6rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .nav-link { | |||||
| text-decoration: none; | |||||
| color: var(--text-secondary); | |||||
| font-weight: 600; | |||||
| padding: 0.7rem 1rem; | |||||
| border-radius: 999px; | |||||
| transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; | |||||
| } | |||||
| .nav-link:hover, | |||||
| .nav-link:focus-visible, | |||||
| .nav-link.is-active { | |||||
| color: var(--accent-strong); | |||||
| background: rgba(29, 122, 109, 0.12); | |||||
| transform: translateY(-1px); | |||||
| } | |||||
| .page-content { | |||||
| flex: 1; | |||||
| padding: 3.5rem 0 4rem; | |||||
| } | |||||
| .content-stack { | |||||
| display: grid; | |||||
| gap: 1.5rem; | |||||
| } | |||||
| .section-heading { | |||||
| max-width: 46rem; | |||||
| } | |||||
| .section-heading h1 { | |||||
| margin: 0.3rem 0 0.8rem; | |||||
| font-size: clamp(2.4rem, 5vw, 4rem); | |||||
| line-height: 1; | |||||
| letter-spacing: -0.04em; | |||||
| } | |||||
| .section-heading p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.8; | |||||
| font-size: 1.05rem; | |||||
| } | |||||
| .hero { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr); | |||||
| gap: 1.5rem; | |||||
| align-items: stretch; | |||||
| } | |||||
| .hero-copy, | |||||
| .hero-panel, | |||||
| .feature-card, | |||||
| .section-panel, | |||||
| .alert, | |||||
| .empty-state { | |||||
| background: var(--surface); | |||||
| border: 1px solid var(--surface-border); | |||||
| box-shadow: var(--shadow-card); | |||||
| } | |||||
| .hero-copy { | |||||
| padding: 3rem; | |||||
| border-radius: 2rem; | |||||
| } | |||||
| .eyebrow { | |||||
| display: inline-block; | |||||
| margin-bottom: 1rem; | |||||
| padding: 0.4rem 0.75rem; | |||||
| border-radius: 999px; | |||||
| background: var(--accent-soft); | |||||
| color: var(--accent-strong); | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.14em; | |||||
| } | |||||
| .hero h1 { | |||||
| margin: 0; | |||||
| font-size: clamp(2.8rem, 6vw, 4.8rem); | |||||
| line-height: 0.98; | |||||
| letter-spacing: -0.04em; | |||||
| } | |||||
| .hero-text { | |||||
| max-width: 44rem; | |||||
| margin: 1.25rem 0 0; | |||||
| font-size: 1.12rem; | |||||
| line-height: 1.8; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .hero-actions { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 0.85rem; | |||||
| margin-top: 2rem; | |||||
| } | |||||
| .button { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| padding: 0.9rem 1.35rem; | |||||
| border-radius: 999px; | |||||
| text-decoration: none; | |||||
| font-weight: 700; | |||||
| } | |||||
| .button-primary { | |||||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||||
| color: #fff; | |||||
| box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25); | |||||
| } | |||||
| .button-secondary { | |||||
| background: rgba(29, 122, 109, 0.08); | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .hero-panel { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| justify-content: space-between; | |||||
| padding: 2rem; | |||||
| border-radius: 1.8rem; | |||||
| } | |||||
| .panel-label { | |||||
| margin: 0 0 1rem; | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.16em; | |||||
| text-transform: uppercase; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .hero-panel code { | |||||
| display: block; | |||||
| padding: 1rem 1.1rem; | |||||
| border-radius: 1.2rem; | |||||
| background: #173d37; | |||||
| color: #eefbf6; | |||||
| line-height: 1.7; | |||||
| white-space: normal; | |||||
| } | |||||
| .route-callout { | |||||
| margin-top: 1.5rem; | |||||
| padding: 1rem 1.1rem; | |||||
| border-radius: 1.2rem; | |||||
| background: var(--surface-strong); | |||||
| } | |||||
| .route-callout span { | |||||
| display: block; | |||||
| margin-bottom: 0.45rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.92rem; | |||||
| } | |||||
| .route-callout a { | |||||
| color: var(--highlight); | |||||
| font-weight: 700; | |||||
| text-decoration: none; | |||||
| } | |||||
| .feature-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||||
| gap: 1.25rem; | |||||
| margin-top: 1.5rem; | |||||
| } | |||||
| .feature-card { | |||||
| padding: 1.75rem; | |||||
| border-radius: 1.6rem; | |||||
| } | |||||
| .feature-card h2 { | |||||
| margin-top: 0; | |||||
| margin-bottom: 0.8rem; | |||||
| font-size: 1.25rem; | |||||
| } | |||||
| .feature-card p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .section-panel { | |||||
| padding: 1.75rem; | |||||
| border-radius: 1.8rem; | |||||
| } | |||||
| .panel-header { | |||||
| margin-bottom: 1.5rem; | |||||
| } | |||||
| .panel-header h2 { | |||||
| margin: 0 0 0.45rem; | |||||
| font-size: 1.45rem; | |||||
| } | |||||
| .panel-header p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .form-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||||
| gap: 1rem; | |||||
| } | |||||
| .field { | |||||
| display: grid; | |||||
| gap: 0.45rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .field span { | |||||
| font-size: 0.96rem; | |||||
| } | |||||
| .input { | |||||
| width: 100%; | |||||
| padding: 0.95rem 1rem; | |||||
| border: 1px solid rgba(20, 54, 49, 0.16); | |||||
| border-radius: 1rem; | |||||
| background: rgba(255, 255, 255, 0.92); | |||||
| color: var(--text-primary); | |||||
| font: inherit; | |||||
| } | |||||
| .input:focus { | |||||
| outline: 2px solid rgba(29, 122, 109, 0.22); | |||||
| border-color: rgba(29, 122, 109, 0.45); | |||||
| } | |||||
| .field-error { | |||||
| color: #a43d1f; | |||||
| font-size: 0.88rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .form-actions { | |||||
| display: flex; | |||||
| justify-content: flex-start; | |||||
| align-items: center; | |||||
| gap: 0.85rem; | |||||
| } | |||||
| .button { | |||||
| border: 0; | |||||
| cursor: pointer; | |||||
| } | |||||
| .alert, | |||||
| .empty-state { | |||||
| padding: 1rem 1.15rem; | |||||
| border-radius: 1.2rem; | |||||
| } | |||||
| .alert-success { | |||||
| background: rgba(218, 241, 236, 0.92); | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .alert-error { | |||||
| background: rgba(239, 124, 77, 0.14); | |||||
| color: #8f3518; | |||||
| } | |||||
| .empty-state p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .empty-state p + p { | |||||
| margin-top: 0.45rem; | |||||
| } | |||||
| .site-footer { | |||||
| margin-top: auto; | |||||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||||
| background: rgba(255, 252, 247, 0.72); | |||||
| } | |||||
| .footer-inner { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| padding: 1.25rem 0 2rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.95rem; | |||||
| } | |||||
| .footer-inner p { | |||||
| margin: 0; | |||||
| } | |||||
| @media (max-width: 860px) { | |||||
| .header-inner, | |||||
| .footer-inner { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| } | |||||
| .hero, | |||||
| .feature-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .hero-copy, | |||||
| .hero-panel { | |||||
| padding: 2rem; | |||||
| } | |||||
| .form-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .page-content { | |||||
| padding-top: 2rem; | |||||
| } | |||||
| } | |||||
| @media (max-width: 560px) { | |||||
| .container { | |||||
| width: min(100% - 1.25rem, 1120px); | |||||
| } | |||||
| .site-nav { | |||||
| width: 100%; | |||||
| } | |||||
| .nav-link { | |||||
| width: 100%; | |||||
| text-align: center; | |||||
| } | |||||
| .hero h1 { | |||||
| font-size: 2.5rem; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,23 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| use Core\App; | |||||
| use Core\Dispatcher; | |||||
| use Core\Request; | |||||
| use Core\Router; | |||||
| ensureSessionStarted(); | |||||
| $app = new App(); | |||||
| $router = new Router(); | |||||
| require_once __DIR__ . '/../routes/web.php'; | |||||
| $dispatcher = new Dispatcher($router, $app); | |||||
| $request = Request::capture(); | |||||
| $response = $dispatcher->dispatch($request); | |||||
| $response->send(); | |||||
| @@ -0,0 +1,8 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use App\Controllers\HomeController; | |||||
| $router->get('/', [HomeController::class, 'index']); | |||||
| $router->get('/users/{id}', [HomeController::class, 'user']); | |||||
| @@ -0,0 +1,18 @@ | |||||
| # Scripts | |||||
| This directory holds project PHP scripts that are meant to be run from the command line. | |||||
| Examples: | |||||
| ```bash | |||||
| php scripts/migrate.php up | |||||
| php scripts/migrate.php status | |||||
| php scripts/migrate.php fresh --seed | |||||
| php scripts/seed_employees.php 1000 | |||||
| ``` | |||||
| Guidelines: | |||||
| - Put CLI-only PHP entrypoints here. | |||||
| - Keep reusable logic in `core/`, `app/`, or `database/`. | |||||
| - Let scripts stay thin and call into application classes or helper functions. | |||||
| @@ -0,0 +1,100 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| $command = $argv[1] ?? 'help'; | |||||
| $options = array_slice($argv, 2); | |||||
| $manager = migration_manager(); | |||||
| try { | |||||
| switch ($command) { | |||||
| case 'up': | |||||
| $ran = $manager->runPending(); | |||||
| if ($ran === []) { | |||||
| echo "No pending migrations." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($ran as $migration) { | |||||
| echo "Migrated: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL; | |||||
| exit(0); | |||||
| case 'down': | |||||
| $steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1; | |||||
| $rolledBack = $manager->rollback($steps); | |||||
| if ($rolledBack === []) { | |||||
| echo "No applied migrations to roll back." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($rolledBack as $migration) { | |||||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL; | |||||
| exit(0); | |||||
| case 'status': | |||||
| $status = $manager->status(); | |||||
| if ($status === []) { | |||||
| echo "No migration files found." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($status as $row) { | |||||
| $state = $row['ran'] ? 'up' : 'pending'; | |||||
| $ranAt = $row['ran_at'] ?? '-'; | |||||
| echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL; | |||||
| } | |||||
| exit(0); | |||||
| case 'make': | |||||
| case 'create': | |||||
| $name = $argv[2] ?? ''; | |||||
| if ($name === '') { | |||||
| throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table'); | |||||
| } | |||||
| $path = $manager->make($name); | |||||
| echo "Created migration: {$path}" . PHP_EOL; | |||||
| exit(0); | |||||
| case 'fresh': | |||||
| $result = $manager->fresh(); | |||||
| foreach ($result['rolled_back'] as $migration) { | |||||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||||
| } | |||||
| foreach ($result['migrated'] as $migration) { | |||||
| echo "Migrated: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo "Fresh migration run complete." . PHP_EOL; | |||||
| exit(0); | |||||
| case 'help': | |||||
| default: | |||||
| echo "Migration CLI" . PHP_EOL; | |||||
| echo "Usage:" . PHP_EOL; | |||||
| echo " php scripts/migrate.php up" . PHP_EOL; | |||||
| echo " php scripts/migrate.php down [steps]" . PHP_EOL; | |||||
| echo " php scripts/migrate.php status" . PHP_EOL; | |||||
| echo " php scripts/migrate.php make <name>" . PHP_EOL; | |||||
| echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| } catch (Throwable $exception) { | |||||
| fwrite(STDERR, $exception->getMessage() . PHP_EOL); | |||||
| exit(1); | |||||
| } | |||||
| @@ -0,0 +1,113 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| use Core\App; | |||||
| use Core\Database; | |||||
| use Core\Dispatcher; | |||||
| use Core\MigrationManager; | |||||
| use Core\Request; | |||||
| use Core\Router; | |||||
| $tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true); | |||||
| mkdir($tempMigrationPath, 0777, true); | |||||
| $migrationFile = $tempMigrationPath . '/20260509_120000_create_projects_table.php'; | |||||
| file_put_contents($migrationFile, <<<'PHP' | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS projects'); | |||||
| } | |||||
| }; | |||||
| PHP | |||||
| ); | |||||
| $memoryDatabase = new Database([ | |||||
| 'dsn' => 'sqlite::memory:', | |||||
| 'options' => [ | |||||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||||
| ], | |||||
| ]); | |||||
| $migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath); | |||||
| $ran = $migrationManager->runPending(); | |||||
| if ($ran !== ['20260509_120000_create_projects_table.php']) { | |||||
| echo "FAIL: migration manager did not apply the expected migration\n"; | |||||
| exit(1); | |||||
| } | |||||
| $projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||||
| if ($projectTable === null) { | |||||
| echo "FAIL: migration up() did not create the projects table\n"; | |||||
| exit(1); | |||||
| } | |||||
| $rolledBack = $migrationManager->rollback(); | |||||
| if ($rolledBack !== ['20260509_120000_create_projects_table.php']) { | |||||
| echo "FAIL: migration manager did not roll back the expected migration\n"; | |||||
| exit(1); | |||||
| } | |||||
| $projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||||
| if ($projectTableAfterRollback !== null) { | |||||
| echo "FAIL: migration down() did not remove the projects table\n"; | |||||
| exit(1); | |||||
| } | |||||
| $createdMigrationPath = $migrationManager->make('create_tasks_table'); | |||||
| if (!file_exists($createdMigrationPath)) { | |||||
| echo "FAIL: migration manager did not create a migration file\n"; | |||||
| exit(1); | |||||
| } | |||||
| $router = new Router(); | |||||
| $app = new App(); | |||||
| (new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending(); | |||||
| require_once __DIR__ . '/../routes/web.php'; | |||||
| $router->get('/hello/{name}', function (string $name) { | |||||
| return 'Hello, ' . $name; | |||||
| }); | |||||
| $request = new Request([], [], [ | |||||
| 'REQUEST_METHOD' => 'GET', | |||||
| 'REQUEST_URI' => '/hello/Daniel', | |||||
| ]); | |||||
| $response = (new Dispatcher($router, $app))->dispatch($request); | |||||
| if ($response->status() !== 200) { | |||||
| echo "FAIL: expected status 200\n"; | |||||
| exit(1); | |||||
| } | |||||
| if ($response->content() !== 'Hello, Daniel') { | |||||
| echo "FAIL: unexpected response content\n"; | |||||
| exit(1); | |||||
| } | |||||
| echo "PASS: migration manager and route dispatch work\n"; | |||||
Powered by TurnKey Linux.