| @@ -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.