| @@ -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,208 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\Employee; | |||
| use App\Repositories\EmployeeRepository; | |||
| use App\ViewModels\EmployeeFormViewModel; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| use Core\Validator; | |||
| class EmployeeController extends Controller | |||
| { | |||
| public function index() | |||
| { | |||
| $request = Request::capture(); | |||
| $viewModel = $this->buildViewModel((string) $request->input('search', '')); | |||
| $viewModel->saved = $request->input('saved') === '1'; | |||
| return $this->view('employees.create', [ | |||
| 'model' => $viewModel, | |||
| 'pageTitle' => $viewModel->title, | |||
| ]); | |||
| } | |||
| public function store() | |||
| { | |||
| $request = Request::capture(); | |||
| $form = $this->sanitizeFormData($request); | |||
| $errors = $this->validateForm($form, $request); | |||
| if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) { | |||
| $errors['email'][] = 'That email address is already in use.'; | |||
| } | |||
| if (!empty($errors)) { | |||
| $viewModel = $this->buildViewModel(); | |||
| $viewModel->form = $form; | |||
| $viewModel->errors = $errors; | |||
| if ($this->isHtmxRequest($request)) { | |||
| return $this->fragment('employees.partials.form', [ | |||
| 'model' => $viewModel, | |||
| ]); | |||
| } | |||
| return $this->view('employees.create', [ | |||
| 'model' => $viewModel, | |||
| 'pageTitle' => $viewModel->title, | |||
| ]); | |||
| } | |||
| $employee = new Employee(); | |||
| $employee->firstName = $form['first_name']; | |||
| $employee->lastName = $form['last_name']; | |||
| $employee->email = $form['email']; | |||
| $employee->department = $form['department']; | |||
| $employee->jobTitle = $form['job_title']; | |||
| $employee->startDate = $form['start_date']; | |||
| $this->employees()->create($employee); | |||
| if ($this->isHtmxRequest($request)) { | |||
| $viewModel = $this->buildViewModel(); | |||
| $viewModel->saved = true; | |||
| return $this->fragment('employees.partials.form', [ | |||
| 'model' => $viewModel, | |||
| ], 200, [ | |||
| 'HX-Trigger' => json_encode(['employees-changed' => true]), | |||
| ]); | |||
| } | |||
| return $this->redirect('/employees?saved=1'); | |||
| } | |||
| public function create() | |||
| { | |||
| return $this->redirect('/employees'); | |||
| } | |||
| public function summary() | |||
| { | |||
| $request = Request::capture(); | |||
| $viewModel = $this->buildViewModel((string) $request->input('search', '')); | |||
| return $this->fragment('employees.partials.summary', [ | |||
| 'model' => $viewModel, | |||
| ]); | |||
| } | |||
| public function data() | |||
| { | |||
| $request = Request::capture(); | |||
| $search = trim((string) $request->input('search', '')); | |||
| $rows = $this->employees()->search($search); | |||
| $data = array_map( | |||
| static function (array $row): array { | |||
| return [ | |||
| 'id' => (int) $row['id'], | |||
| 'full_name' => trim($row['first_name'] . ' ' . $row['last_name']), | |||
| 'first_name' => (string) $row['first_name'], | |||
| 'last_name' => (string) $row['last_name'], | |||
| 'email' => (string) $row['email'], | |||
| 'department' => (string) $row['department'], | |||
| 'job_title' => (string) $row['job_title'], | |||
| 'start_date' => (string) $row['start_date'], | |||
| 'created_at' => (string) $row['created_at'], | |||
| ]; | |||
| }, | |||
| $rows | |||
| ); | |||
| return $this->json($data); | |||
| } | |||
| /** | |||
| * @return array<string, string> | |||
| */ | |||
| private function sanitizeFormData(Request $request): array | |||
| { | |||
| return [ | |||
| 'first_name' => trim((string) $request->input('first_name', '')), | |||
| 'last_name' => trim((string) $request->input('last_name', '')), | |||
| 'email' => trim((string) $request->input('email', '')), | |||
| 'department' => trim((string) $request->input('department', '')), | |||
| 'job_title' => trim((string) $request->input('job_title', '')), | |||
| 'start_date' => trim((string) $request->input('start_date', '')), | |||
| ]; | |||
| } | |||
| /** | |||
| * @param array<string, string> $form | |||
| * @return array<string, list<string>> | |||
| */ | |||
| private function validateForm(array $form, Request $request): array | |||
| { | |||
| $validator = new Validator(); | |||
| $validator | |||
| ->required('first_name', $form['first_name'], 'First name is required.') | |||
| ->maxLength('first_name', $form['first_name'], 100, 'First name must be 100 characters or fewer.') | |||
| ->required('last_name', $form['last_name'], 'Last name is required.') | |||
| ->maxLength('last_name', $form['last_name'], 100, 'Last name must be 100 characters or fewer.') | |||
| ->required('email', $form['email'], 'Email is required.') | |||
| ->maxLength('email', $form['email'], 255, 'Email must be 255 characters or fewer.') | |||
| ->required('department', $form['department'], 'Department is required.') | |||
| ->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.') | |||
| ->required('job_title', $form['job_title'], 'Job title is required.') | |||
| ->maxLength('job_title', $form['job_title'], 150, 'Job title must be 150 characters or fewer.') | |||
| ->required('start_date', $form['start_date'], 'Start date is required.'); | |||
| $errors = $validator->errors(); | |||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||
| $errors['_token'][] = 'Your form session expired. Please refresh the page and try again.'; | |||
| } | |||
| if ($form['email'] !== '' && filter_var($form['email'], FILTER_VALIDATE_EMAIL) === false) { | |||
| $errors['email'][] = 'Enter a valid email address.'; | |||
| } | |||
| if ($form['start_date'] !== '' && !$this->isValidDate($form['start_date'])) { | |||
| $errors['start_date'][] = 'Enter a valid start date.'; | |||
| } | |||
| return $errors; | |||
| } | |||
| private function isValidDate(string $value): bool | |||
| { | |||
| $date = \DateTimeImmutable::createFromFormat('Y-m-d', $value); | |||
| return $date !== false && $date->format('Y-m-d') === $value; | |||
| } | |||
| private function employees(): EmployeeRepository | |||
| { | |||
| return new EmployeeRepository(database()); | |||
| } | |||
| private function isHtmxRequest(Request $request): bool | |||
| { | |||
| return strtolower((string) $request->server('HTTP_HX_REQUEST', '')) === 'true'; | |||
| } | |||
| private function buildViewModel(string $search = ''): EmployeeFormViewModel | |||
| { | |||
| $viewModel = new EmployeeFormViewModel(); | |||
| $viewModel->search = trim($search); | |||
| $employees = $this->employees()->search($viewModel->search); | |||
| $newestEmployee = $this->employees()->newestMatching($viewModel->search); | |||
| $viewModel->employees = array_slice($employees, 0, 5); | |||
| $viewModel->newestEmployee = $newestEmployee; | |||
| $viewModel->summary = [ | |||
| 'employee_count' => $this->employees()->countMatching($viewModel->search), | |||
| 'department_count' => $this->employees()->countDepartments($viewModel->search), | |||
| 'latest_start_date' => $newestEmployee['start_date'] ?? 'N/A', | |||
| ]; | |||
| return $viewModel; | |||
| } | |||
| } | |||
| @@ -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 = '/employees'; | |||
| return $this->view('home.index', [ | |||
| 'model' => $model, | |||
| 'pageTitle' => $model->title, | |||
| ]); | |||
| } | |||
| public function user(string $id) | |||
| { | |||
| return $this->json([ | |||
| 'userId' => $id, | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class Employee | |||
| { | |||
| public ?int $id = null; | |||
| public string $firstName = ''; | |||
| public string $lastName = ''; | |||
| public string $email = ''; | |||
| public string $department = ''; | |||
| public string $jobTitle = ''; | |||
| public string $startDate = ''; | |||
| } | |||
| @@ -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,122 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\Employee; | |||
| use Core\Repository; | |||
| class EmployeeRepository extends Repository | |||
| { | |||
| protected string $table = 'employees'; | |||
| protected string $primaryKey = 'id'; | |||
| public function create(Employee $employee): bool | |||
| { | |||
| return $this->database->execute( | |||
| 'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date) | |||
| VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)', | |||
| [ | |||
| 'first_name' => $employee->firstName, | |||
| 'last_name' => $employee->lastName, | |||
| 'email' => $employee->email, | |||
| 'department' => $employee->department, | |||
| 'job_title' => $employee->jobTitle, | |||
| 'start_date' => $employee->startDate, | |||
| ] | |||
| ); | |||
| } | |||
| public function findByEmail(string $email): ?array | |||
| { | |||
| return $this->database->first( | |||
| 'SELECT * FROM employees WHERE email = :email', | |||
| ['email' => $email] | |||
| ); | |||
| } | |||
| /** | |||
| * @return list<array<string, mixed>> | |||
| */ | |||
| public function latest(int $limit = 8): array | |||
| { | |||
| $limit = max(1, $limit); | |||
| return $this->database->query( | |||
| "SELECT * FROM employees ORDER BY created_at DESC, id DESC LIMIT {$limit}" | |||
| ); | |||
| } | |||
| /** | |||
| * @return list<array<string, mixed>> | |||
| */ | |||
| public function search(string $search = ''): array | |||
| { | |||
| [$whereClause, $parameters] = $this->buildSearchClause($search); | |||
| return $this->database->query( | |||
| 'SELECT id, first_name, last_name, email, department, job_title, start_date, created_at | |||
| FROM employees' . $whereClause . ' | |||
| ORDER BY created_at DESC, id DESC', | |||
| $parameters | |||
| ); | |||
| } | |||
| public function countMatching(string $search = ''): int | |||
| { | |||
| [$whereClause, $parameters] = $this->buildSearchClause($search); | |||
| $row = $this->database->first( | |||
| 'SELECT COUNT(*) AS total FROM employees' . $whereClause, | |||
| $parameters | |||
| ); | |||
| return (int) ($row['total'] ?? 0); | |||
| } | |||
| public function countDepartments(string $search = ''): int | |||
| { | |||
| [$whereClause, $parameters] = $this->buildSearchClause($search); | |||
| $row = $this->database->first( | |||
| 'SELECT COUNT(DISTINCT department) AS total FROM employees' . $whereClause, | |||
| $parameters | |||
| ); | |||
| return (int) ($row['total'] ?? 0); | |||
| } | |||
| public function newestMatching(string $search = ''): ?array | |||
| { | |||
| [$whereClause, $parameters] = $this->buildSearchClause($search); | |||
| return $this->database->first( | |||
| 'SELECT id, first_name, last_name, department, job_title, start_date | |||
| FROM employees' . $whereClause . ' | |||
| ORDER BY created_at DESC, id DESC | |||
| LIMIT 1', | |||
| $parameters | |||
| ); | |||
| } | |||
| /** | |||
| * @return array{0:string,1:array<string,string>} | |||
| */ | |||
| private function buildSearchClause(string $search): array | |||
| { | |||
| $search = trim($search); | |||
| if ($search === '') { | |||
| return ['', []]; | |||
| } | |||
| return [ | |||
| ' WHERE first_name LIKE :search | |||
| OR last_name LIKE :search | |||
| OR email LIKE :search | |||
| OR department LIKE :search | |||
| OR job_title LIKE :search | |||
| OR start_date LIKE :search', | |||
| ['search' => '%' . $search . '%'], | |||
| ]; | |||
| } | |||
| } | |||
| @@ -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,50 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\ViewModels; | |||
| class EmployeeFormViewModel | |||
| { | |||
| public string $title = 'Employee Directory'; | |||
| public string $eyebrow = 'SQLite employee form'; | |||
| public string $intro = 'Capture employee details in a lightweight SQLite-backed page that fits the framework style.'; | |||
| public bool $saved = false; | |||
| public string $search = ''; | |||
| /** | |||
| * @var array<string, string> | |||
| */ | |||
| public array $form = [ | |||
| 'first_name' => '', | |||
| 'last_name' => '', | |||
| 'email' => '', | |||
| 'department' => '', | |||
| 'job_title' => '', | |||
| 'start_date' => '', | |||
| ]; | |||
| /** | |||
| * @var array<string, list<string>> | |||
| */ | |||
| public array $errors = []; | |||
| /** | |||
| * @var list<array<string, mixed>> | |||
| */ | |||
| public array $employees = []; | |||
| /** | |||
| * @var array<string, int|string> | |||
| */ | |||
| public array $summary = [ | |||
| 'employee_count' => 0, | |||
| 'department_count' => 0, | |||
| 'latest_start_date' => 'N/A', | |||
| ]; | |||
| /** | |||
| * @var array<string, mixed>|null | |||
| */ | |||
| public ?array $newestEmployee = null; | |||
| } | |||
| @@ -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,54 @@ | |||
| <section class="content-stack" x-data="employeeDirectory()"> | |||
| <div class="section-heading"> | |||
| <span class="eyebrow"><?= e($model->eyebrow) ?></span> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p><?= e($model->intro) ?></p> | |||
| </div> | |||
| <section class="section-panel controls-panel"> | |||
| <div class="panel-header controls-header"> | |||
| <div> | |||
| <h2>Employee Workspace</h2> | |||
| <p>Use htmx for server updates, Alpine for page state, and Tabulator for a richer table experience.</p> | |||
| </div> | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh Table</button> | |||
| </div> | |||
| <div class="search-row"> | |||
| <label class="field field-full"> | |||
| <span>Search employees</span> | |||
| <input | |||
| id="employee-search" | |||
| class="input" | |||
| type="search" | |||
| name="search" | |||
| placeholder="Search by name, email, department, title, or date" | |||
| x-model.debounce.300ms="search" | |||
| x-on:input.debounce.300ms="applySearch()" | |||
| value="<?= e($model->search) ?>" | |||
| > | |||
| </label> | |||
| </div> | |||
| </section> | |||
| <div class="employee-layout"> | |||
| <div id="employee-form-panel"> | |||
| <?php require __DIR__ . '/partials/form.php'; ?> | |||
| </div> | |||
| <section class="section-panel table-shell directory-panel"> | |||
| <div class="panel-header"> | |||
| <h2>Employee Directory Table</h2> | |||
| <p>Browse, search, and sort all employees in one place. The table refreshes after new employee records are saved.</p> | |||
| </div> | |||
| <div class="table-toolbar"> | |||
| <span class="table-pill">HTMX + Alpine + Tabulator</span> | |||
| <span class="table-caption">Live data endpoint: <code>/employees/data</code></span> | |||
| </div> | |||
| <div id="employee-table" class="tabulator-host"></div> | |||
| </section> | |||
| </div> | |||
| </section> | |||
| @@ -0,0 +1,83 @@ | |||
| <section class="section-panel"> | |||
| <div class="panel-header"> | |||
| <h2>Add Employee</h2> | |||
| <p>Store a clean employee record with basic contact and role details.</p> | |||
| </div> | |||
| <?php if ($model->saved): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Employee information was saved to SQLite successfully. | |||
| </div> | |||
| <?php endif; ?> | |||
| <?php if (isset($model->errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form | |||
| method="post" | |||
| action="/employees" | |||
| class="employee-form" | |||
| novalidate | |||
| hx-post="/employees" | |||
| hx-target="#employee-form-panel" | |||
| hx-swap="outerHTML" | |||
| > | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <label class="field"> | |||
| <span>First name</span> | |||
| <input class="input" type="text" name="first_name" maxlength="100" value="<?= e($model->form['first_name']) ?>" required> | |||
| <?php if (isset($model->errors['first_name'])): ?> | |||
| <small class="field-error"><?= e($model->errors['first_name'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Last name</span> | |||
| <input class="input" type="text" name="last_name" maxlength="100" value="<?= e($model->form['last_name']) ?>" required> | |||
| <?php if (isset($model->errors['last_name'])): ?> | |||
| <small class="field-error"><?= e($model->errors['last_name'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Email</span> | |||
| <input class="input" type="email" name="email" maxlength="255" value="<?= e($model->form['email']) ?>" required> | |||
| <?php if (isset($model->errors['email'])): ?> | |||
| <small class="field-error"><?= e($model->errors['email'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Department</span> | |||
| <input class="input" type="text" name="department" maxlength="100" value="<?= e($model->form['department']) ?>" required> | |||
| <?php if (isset($model->errors['department'])): ?> | |||
| <small class="field-error"><?= e($model->errors['department'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Job title</span> | |||
| <input class="input" type="text" name="job_title" maxlength="150" value="<?= e($model->form['job_title']) ?>" required> | |||
| <?php if (isset($model->errors['job_title'])): ?> | |||
| <small class="field-error"><?= e($model->errors['job_title'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Start date</span> | |||
| <input class="input" type="date" name="start_date" value="<?= e($model->form['start_date']) ?>" required> | |||
| <?php if (isset($model->errors['start_date'])): ?> | |||
| <small class="field-error"><?= e($model->errors['start_date'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| </div> | |||
| <div class="form-actions"> | |||
| <button class="button button-primary" type="submit">Save Employee</button> | |||
| <span class="inline-indicator htmx-indicator">Saving employee...</span> | |||
| </div> | |||
| </form> | |||
| </section> | |||
| @@ -0,0 +1,34 @@ | |||
| <div class="panel-header"> | |||
| <h2>Live Summary</h2> | |||
| <p>Server-rendered fragments refresh here with htmx whenever employees change or search terms update.</p> | |||
| </div> | |||
| <div class="stats-grid"> | |||
| <article class="stat-card"> | |||
| <span>Total Employees</span> | |||
| <strong><?= e((string) $model->summary['employee_count']) ?></strong> | |||
| </article> | |||
| <article class="stat-card"> | |||
| <span>Departments</span> | |||
| <strong><?= e((string) $model->summary['department_count']) ?></strong> | |||
| </article> | |||
| <article class="stat-card"> | |||
| <span>Latest Start Date</span> | |||
| <strong><?= e((string) $model->summary['latest_start_date']) ?></strong> | |||
| </article> | |||
| </div> | |||
| <?php if ($model->newestEmployee !== null): ?> | |||
| <div class="summary-feature"> | |||
| <span class="summary-label">Newest matching record</span> | |||
| <h3><?= e($model->newestEmployee['first_name'] . ' ' . $model->newestEmployee['last_name']) ?></h3> | |||
| <p><?= e((string) $model->newestEmployee['job_title']) ?> in <?= e((string) $model->newestEmployee['department']) ?></p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="empty-state"> | |||
| <p>No matching employees yet.</p> | |||
| <p>Try a broader search or add a new employee record.</p> | |||
| </div> | |||
| <?php endif; ?> | |||
| @@ -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,48 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| $navigationItems = [ | |||
| ['label' => 'Home', 'href' => '/'], | |||
| ['label' => 'Employees', 'href' => '/employees'], | |||
| ['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="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css"> | |||
| <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | |||
| <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous" defer></script> | |||
| <script src="https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js" defer></script> | |||
| <script src="<?= e(asset('js/app.js')) ?>" defer></script> | |||
| <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> | |||
| </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 migrate.php up", | |||
| "migrate:down": "php migrate.php down", | |||
| "migrate:status": "php migrate.php status", | |||
| "migrate:fresh": "php migrate.php fresh", | |||
| "migrate:fresh-seed": "php 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,30 @@ | |||
| <?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 IF NOT EXISTS employees ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| first_name VARCHAR(100) NOT NULL, | |||
| last_name VARCHAR(100) NOT NULL, | |||
| email VARCHAR(255) NOT NULL UNIQUE, | |||
| department VARCHAR(100) NOT NULL, | |||
| job_title VARCHAR(150) NOT NULL, | |||
| start_date DATE NOT NULL, | |||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |||
| )' | |||
| ); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS employees'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,107 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||
| function seed_employees(int $targetTotal = 1000, bool $resetExisting = false): void | |||
| { | |||
| $targetTotal = max(1, $targetTotal); | |||
| $migrationManager = migration_manager(); | |||
| $migrationManager->runPending(); | |||
| $database = database(); | |||
| if ($resetExisting) { | |||
| $database->execute('DELETE FROM employees'); | |||
| } | |||
| $currentTotal = (int) (database()->first('SELECT COUNT(*) AS total FROM employees')['total'] ?? 0); | |||
| if ($currentTotal >= $targetTotal) { | |||
| echo "Employee table already has {$currentTotal} records." . PHP_EOL; | |||
| return; | |||
| } | |||
| $firstNames = [ | |||
| 'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Mason', 'Sophia', 'Ethan', 'Isabella', 'Lucas', | |||
| 'Mia', 'Amelia', 'James', 'Harper', 'Benjamin', 'Ella', 'Henry', 'Evelyn', 'Jack', 'Abigail', | |||
| 'Alexander', 'Emily', 'Michael', 'Charlotte', 'Daniel', 'Grace', 'Elijah', 'Scarlett', 'William', 'Chloe', | |||
| 'Matthew', 'Victoria', 'Samuel', 'Lily', 'David', 'Aria', 'Joseph', 'Zoey', 'Carter', 'Hannah', | |||
| 'Owen', 'Addison', 'Wyatt', 'Natalie', 'John', 'Aubrey', 'Luke', 'Brooklyn', 'Gabriel', 'Layla', | |||
| 'Anthony', 'Zoe', 'Isaac', 'Penelope', 'Dylan', 'Riley', 'Grayson', 'Nora', 'Levi', 'Lillian', | |||
| 'Julian', 'Eleanor', 'Christopher', 'Stella', 'Joshua', 'Savannah', 'Andrew', 'Audrey', 'Nathan', 'Claire', | |||
| 'Thomas', 'Skylar', 'Caleb', 'Lucy', 'Ryan', 'Paisley', 'Christian', 'Everly', 'Hunter', 'Anna', | |||
| 'Jonathan', 'Caroline', 'Aaron', 'Nova', 'Charles', 'Genesis', 'Connor', 'Kennedy', 'Eli', 'Samantha', | |||
| 'Landon', 'Maya', 'Adrian', 'Willow', 'Nicholas', 'Kinsley', 'Jeremiah', 'Naomi', 'Easton', 'Ariana', | |||
| ]; | |||
| $lastNames = [ | |||
| 'Carter', 'Brooks', 'Hayes', 'Parker', 'Turner', 'Sullivan', 'Reed', 'Ward', 'Price', 'Foster', | |||
| 'Powell', 'Bennett', 'Coleman', 'Russell', 'Long', 'Perry', 'Morgan', 'Peterson', 'Cooper', 'Bailey', | |||
| 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', | |||
| 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', | |||
| 'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', | |||
| 'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores', | |||
| 'Green', 'Adams', 'Nelson', 'Baker', 'Hall', 'Rivera', 'Campbell', 'Mitchell', 'Roberts', 'Gomez', | |||
| 'Phillips', 'Evans', 'Edwards', 'Collins', 'Stewart', 'Morris', 'Rogers', 'Murphy', 'Cook', 'Ramos', | |||
| 'Richardson', 'Cox', 'Howard', 'Bell', 'Ortiz', 'Gutierrez', 'Chavez', 'Wood', 'James', 'Bennett', | |||
| 'Gray', 'Mendoza', 'Ruiz', 'Hughes', 'Grant', 'Stone', 'Spencer', 'Warren', 'Porter', 'Bryant', | |||
| ]; | |||
| $departments = [ | |||
| 'Engineering', 'Finance', 'Operations', 'Sales', 'Marketing', 'People', 'Support', 'Legal', | |||
| ]; | |||
| $jobTitles = [ | |||
| 'Coordinator', 'Analyst', 'Manager', 'Specialist', 'Administrator', 'Engineer', 'Consultant', 'Lead', | |||
| ]; | |||
| $statement = $database->pdo()->prepare( | |||
| 'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date) | |||
| VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)' | |||
| ); | |||
| $database->pdo()->beginTransaction(); | |||
| try { | |||
| for ($i = $currentTotal + 1; $i <= $targetTotal; $i++) { | |||
| $firstName = $firstNames[$i % count($firstNames)]; | |||
| $lastName = $lastNames[$i % count($lastNames)]; | |||
| $department = $departments[$i % count($departments)]; | |||
| $jobTitle = $jobTitles[$i % count($jobTitles)]; | |||
| $email = sprintf( | |||
| '%s.%s.%04d@example.test', | |||
| strtolower($firstName), | |||
| strtolower($lastName), | |||
| $i | |||
| ); | |||
| $month = (($i - 1) % 12) + 1; | |||
| $day = (($i - 1) % 28) + 1; | |||
| $year = 2019 + (($i - 1) % 8); | |||
| $startDate = sprintf('%04d-%02d-%02d', $year, $month, $day); | |||
| $statement->execute([ | |||
| 'first_name' => $firstName, | |||
| 'last_name' => $lastName, | |||
| 'email' => $email, | |||
| 'department' => $department, | |||
| 'job_title' => $jobTitle, | |||
| 'start_date' => $startDate, | |||
| ]); | |||
| } | |||
| $database->pdo()->commit(); | |||
| } catch (Throwable $exception) { | |||
| $database->pdo()->rollBack(); | |||
| throw $exception; | |||
| } | |||
| $inserted = $targetTotal - $currentTotal; | |||
| echo "Inserted {$inserted} sample employees. Total is now {$targetTotal}." . PHP_EOL; | |||
| } | |||
| if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { | |||
| $targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000; | |||
| seed_employees($targetTotal); | |||
| } | |||
| @@ -0,0 +1,72 @@ | |||
| # MindVisionCode PHP | |||
| A small PHP MVC framework inspired by a Classic ASP MVC framework. | |||
| ## Run | |||
| ```bash | |||
| composer install | |||
| php 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 | |||
| ## 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 migrate.php up | |||
| php migrate.php down | |||
| php migrate.php status | |||
| php migrate.php make create_projects_table | |||
| php migrate.php fresh | |||
| php migrate.php fresh --seed | |||
| ``` | |||
| ## 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 | |||
| @@ -0,0 +1,105 @@ | |||
| <?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 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; | |||
| } | |||
| if (in_array('--seed', $options, true)) { | |||
| require __DIR__ . '/database/seed_employees.php'; | |||
| seed_employees(1000, true); | |||
| } | |||
| echo "Fresh migration run complete." . PHP_EOL; | |||
| exit(0); | |||
| case 'help': | |||
| default: | |||
| echo "Migration CLI" . PHP_EOL; | |||
| echo "Usage:" . PHP_EOL; | |||
| echo " php migrate.php up" . PHP_EOL; | |||
| echo " php migrate.php down [steps]" . PHP_EOL; | |||
| echo " php migrate.php status" . PHP_EOL; | |||
| echo " php migrate.php make <name>" . PHP_EOL; | |||
| echo " php migrate.php fresh [--seed]" . PHP_EOL; | |||
| exit(0); | |||
| } | |||
| } catch (Throwable $exception) { | |||
| fwrite(STDERR, $exception->getMessage() . PHP_EOL); | |||
| exit(1); | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| RewriteEngine On | |||
| RewriteCond %{REQUEST_FILENAME} !-f | |||
| RewriteCond %{REQUEST_FILENAME} !-d | |||
| RewriteRule ^ index.php [QSA,L] | |||
| @@ -0,0 +1,791 @@ | |||
| :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, | |||
| .employee-card, | |||
| .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; | |||
| } | |||
| .employee-layout { | |||
| display: grid; | |||
| grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.5fr); | |||
| gap: 1.5rem; | |||
| align-items: start; | |||
| } | |||
| .controls-panel, | |||
| .table-shell { | |||
| overflow: hidden; | |||
| background: | |||
| linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)), | |||
| var(--surface); | |||
| } | |||
| .controls-header { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| } | |||
| .search-row { | |||
| display: grid; | |||
| grid-template-columns: minmax(0, 1fr); | |||
| } | |||
| .field-full { | |||
| width: 100%; | |||
| } | |||
| .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; | |||
| } | |||
| .employee-form { | |||
| display: grid; | |||
| gap: 1.25rem; | |||
| } | |||
| .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; | |||
| } | |||
| .htmx-indicator { | |||
| display: none; | |||
| } | |||
| .htmx-request .htmx-indicator, | |||
| .htmx-request.htmx-indicator { | |||
| display: inline-flex; | |||
| } | |||
| .inline-indicator { | |||
| color: var(--text-secondary); | |||
| font-size: 0.9rem; | |||
| font-weight: 600; | |||
| } | |||
| .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; | |||
| } | |||
| .employee-cards { | |||
| display: grid; | |||
| gap: 1rem; | |||
| } | |||
| .employee-card { | |||
| padding: 1.15rem; | |||
| border-radius: 1.3rem; | |||
| } | |||
| .employee-card-top { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| margin-bottom: 0.8rem; | |||
| } | |||
| .employee-card-top h3 { | |||
| margin: 0; | |||
| font-size: 1.05rem; | |||
| } | |||
| .employee-card-top span { | |||
| padding: 0.4rem 0.7rem; | |||
| border-radius: 999px; | |||
| background: rgba(29, 122, 109, 0.09); | |||
| color: var(--accent-strong); | |||
| font-size: 0.78rem; | |||
| font-weight: 700; | |||
| } | |||
| .employee-card p { | |||
| margin: 0 0 1rem; | |||
| color: var(--text-secondary); | |||
| } | |||
| .employee-meta { | |||
| display: grid; | |||
| gap: 0.75rem; | |||
| margin: 0; | |||
| } | |||
| .employee-meta div { | |||
| display: grid; | |||
| gap: 0.2rem; | |||
| } | |||
| .employee-meta dt { | |||
| color: var(--text-secondary); | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| } | |||
| .employee-meta dd { | |||
| margin: 0; | |||
| font-weight: 600; | |||
| } | |||
| .stats-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||
| gap: 0.9rem; | |||
| } | |||
| .stat-card { | |||
| padding: 1rem; | |||
| border-radius: 1.3rem; | |||
| background: rgba(255, 255, 255, 0.72); | |||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||
| } | |||
| .stat-card span { | |||
| display: block; | |||
| color: var(--text-secondary); | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| } | |||
| .stat-card strong { | |||
| display: block; | |||
| margin-top: 0.45rem; | |||
| font-size: 1.7rem; | |||
| line-height: 1; | |||
| } | |||
| .summary-feature { | |||
| margin-top: 1rem; | |||
| padding: 1.15rem; | |||
| border-radius: 1.3rem; | |||
| background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12)); | |||
| } | |||
| .summary-label { | |||
| display: block; | |||
| color: var(--text-secondary); | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| } | |||
| .summary-feature h3 { | |||
| margin: 0.55rem 0 0.3rem; | |||
| font-size: 1.35rem; | |||
| } | |||
| .summary-feature p { | |||
| margin: 0; | |||
| color: var(--text-secondary); | |||
| } | |||
| .table-toolbar { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| margin-bottom: 1rem; | |||
| flex-wrap: wrap; | |||
| padding: 0.9rem 1rem; | |||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||
| border-radius: 1rem; | |||
| background: rgba(255, 255, 255, 0.58); | |||
| } | |||
| .table-pill { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| padding: 0.5rem 0.8rem; | |||
| border-radius: 999px; | |||
| background: rgba(29, 122, 109, 0.12); | |||
| color: var(--accent-strong); | |||
| font-size: 0.82rem; | |||
| font-weight: 700; | |||
| letter-spacing: 0.04em; | |||
| } | |||
| .table-caption { | |||
| color: var(--text-secondary); | |||
| font-size: 0.92rem; | |||
| } | |||
| .directory-panel .tabulator-host { | |||
| min-height: 38rem; | |||
| } | |||
| .tabulator-host .tabulator { | |||
| border: 1px solid var(--surface-border); | |||
| border-radius: 1.35rem; | |||
| overflow: hidden; | |||
| background: rgba(255, 255, 255, 0.82); | |||
| box-shadow: | |||
| inset 0 1px 0 rgba(255, 255, 255, 0.5), | |||
| 0 18px 35px rgba(20, 54, 49, 0.08); | |||
| } | |||
| .tabulator-host .tabulator-header { | |||
| border-bottom: 1px solid rgba(20, 54, 49, 0.08); | |||
| background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08)); | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col { | |||
| min-height: 3.25rem; | |||
| background: transparent; | |||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col:last-child { | |||
| border-right: 0; | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-content { | |||
| padding: 0.9rem 0.95rem 0.85rem; | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-title { | |||
| font-size: 0.78rem; | |||
| font-weight: 800; | |||
| letter-spacing: 0.08em; | |||
| text-transform: uppercase; | |||
| color: var(--accent-strong); | |||
| } | |||
| .tabulator-host .tabulator-col, | |||
| .tabulator-host .tabulator-cell { | |||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||
| } | |||
| .tabulator-host .tabulator-row .tabulator-cell:last-child { | |||
| border-right: 0; | |||
| } | |||
| .tabulator-host .tabulator-row { | |||
| background: rgba(255, 255, 255, 0.96); | |||
| border-bottom: 1px solid rgba(20, 54, 49, 0.06); | |||
| transition: background-color 160ms ease, transform 160ms ease; | |||
| } | |||
| .tabulator-host .tabulator-row:nth-child(even) { | |||
| background: rgba(248, 242, 232, 0.82); | |||
| } | |||
| .tabulator-host .tabulator-row:hover { | |||
| background: rgba(218, 241, 236, 0.72); | |||
| } | |||
| .tabulator-host .tabulator-row.tabulator-selected { | |||
| background: rgba(29, 122, 109, 0.18); | |||
| } | |||
| .tabulator-host .tabulator-cell { | |||
| padding: 0.95rem 0.95rem; | |||
| font-size: 0.96rem; | |||
| line-height: 1.4; | |||
| } | |||
| .tabulator-host .tabulator-row .tabulator-cell:first-child { | |||
| font-weight: 700; | |||
| color: var(--text-primary); | |||
| } | |||
| .tabulator-host .tabulator-footer { | |||
| padding: 0.55rem 0.7rem; | |||
| background: rgba(255, 255, 255, 0.88); | |||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-paginator { | |||
| font-family: inherit; | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-page { | |||
| margin: 0 0.2rem; | |||
| padding: 0.45rem 0.7rem; | |||
| border: 1px solid rgba(20, 54, 49, 0.1); | |||
| border-radius: 0.8rem; | |||
| background: rgba(255, 255, 255, 0.9); | |||
| color: var(--text-secondary); | |||
| font-weight: 700; | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-page.active, | |||
| .tabulator-host .tabulator-footer .tabulator-page:hover { | |||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||
| border-color: transparent; | |||
| color: #fff; | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-page:disabled { | |||
| opacity: 0.45; | |||
| } | |||
| .tabulator-host .tabulator-placeholder { | |||
| padding: 2.5rem 1rem; | |||
| color: var(--text-secondary); | |||
| font-size: 1rem; | |||
| font-weight: 600; | |||
| } | |||
| .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, | |||
| .employee-layout { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| .controls-header, | |||
| .table-toolbar { | |||
| flex-direction: column; | |||
| align-items: flex-start; | |||
| } | |||
| .hero-copy, | |||
| .hero-panel { | |||
| padding: 2rem; | |||
| } | |||
| .form-grid { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| .stats-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,65 @@ | |||
| window.employeeDirectory = function () { | |||
| return { | |||
| search: '', | |||
| table: null, | |||
| init() { | |||
| this.search = this.$root.querySelector('#employee-search')?.value ?? ''; | |||
| this.initTable(); | |||
| document.body.addEventListener('employees-changed', () => { | |||
| this.reloadTable(); | |||
| }); | |||
| }, | |||
| initTable() { | |||
| const tableElement = document.getElementById('employee-table'); | |||
| if (!tableElement || typeof Tabulator === 'undefined') { | |||
| return; | |||
| } | |||
| this.table = new Tabulator(tableElement, { | |||
| ajaxURL: '/employees/data', | |||
| ajaxParams: { | |||
| search: this.search, | |||
| }, | |||
| layout: 'fitColumns', | |||
| responsiveLayout: 'collapse', | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 8, | |||
| movableColumns: true, | |||
| placeholder: 'No employees found.', | |||
| columns: [ | |||
| { title: 'Name', field: 'full_name', minWidth: 180 }, | |||
| { title: 'Email', field: 'email', minWidth: 220 }, | |||
| { title: 'Department', field: 'department', minWidth: 140 }, | |||
| { title: 'Job Title', field: 'job_title', minWidth: 180 }, | |||
| { title: 'Start Date', field: 'start_date', hozAlign: 'left', minWidth: 130 }, | |||
| ], | |||
| }); | |||
| }, | |||
| applySearch() { | |||
| if (!this.table) { | |||
| return; | |||
| } | |||
| this.table.setData('/employees/data', { | |||
| search: this.search, | |||
| }); | |||
| }, | |||
| reloadTable() { | |||
| if (!this.table) { | |||
| this.initTable(); | |||
| return; | |||
| } | |||
| this.table.setData('/employees/data', { | |||
| search: this.search, | |||
| }); | |||
| }, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use App\Controllers\HomeController; | |||
| use App\Controllers\EmployeeController; | |||
| $router->get('/', [HomeController::class, 'index']); | |||
| $router->get('/users/{id}', [HomeController::class, 'user']); | |||
| $router->get('/employees', [EmployeeController::class, 'index']); | |||
| $router->get('/employees/create', [EmployeeController::class, 'create']); | |||
| $router->get('/employees/summary', [EmployeeController::class, 'summary']); | |||
| $router->get('/employees/data', [EmployeeController::class, 'data']); | |||
| $router->post('/employees', [EmployeeController::class, 'store']); | |||
| @@ -0,0 +1,145 @@ | |||
| <?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); | |||
| } | |||
| $employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [ | |||
| 'REQUEST_METHOD' => 'GET', | |||
| 'REQUEST_URI' => '/employees', | |||
| ])); | |||
| if ($employeePage->status() !== 200) { | |||
| echo "FAIL: expected employee page status 200\n"; | |||
| exit(1); | |||
| } | |||
| if (strpos($employeePage->content(), 'Add Employee') === false) { | |||
| echo "FAIL: employee page did not render form content\n"; | |||
| exit(1); | |||
| } | |||
| $employeeData = (new Dispatcher($router, $app))->dispatch(new Request([ | |||
| 'search' => '', | |||
| ], [], [ | |||
| 'REQUEST_METHOD' => 'GET', | |||
| 'REQUEST_URI' => '/employees/data', | |||
| ])); | |||
| if ($employeeData->status() !== 200) { | |||
| echo "FAIL: expected employee data status 200\n"; | |||
| exit(1); | |||
| } | |||
| if (strpos($employeeData->content(), '[') === false) { | |||
| echo "FAIL: employee data endpoint did not return JSON array content\n"; | |||
| exit(1); | |||
| } | |||
| echo "PASS: migration manager and route dispatch work\n"; | |||
Powered by TurnKey Linux.