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