From 353432166bed6af62ee54762a01e5623b86f2e2c Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Sun, 17 May 2026 13:29:04 -0400 Subject: [PATCH] Init --- .claude/settings.local.json | 10 + .gitignore | 20 + AGENTS.md | 907 ++++++++++++++++++++++++++ app/Controllers/HomeController.php | 32 + app/Models/User.php | 12 + app/Repositories/UserRepository.php | 21 + app/ViewModels/HomeIndexViewModel.php | 13 + app/Views/home/index.php | 39 ++ app/Views/layouts/app.php | 14 + app/Views/partials/footer.php | 9 + app/Views/partials/header.php | 42 ++ composer.json | 22 + composer.lock | 18 + config/database.php | 11 + core/App.php | 57 ++ core/Controller.php | 35 + core/Database.php | 49 ++ core/Dispatcher.php | 53 ++ core/Migration.php | 12 + core/MigrationManager.php | 289 ++++++++ core/Repository.php | 38 ++ core/Request.php | 70 ++ core/Response.php | 64 ++ core/Route.php | 50 ++ core/Router.php | 39 ++ core/Validator.php | 52 ++ core/View.php | 68 ++ core/helpers.php | 134 ++++ docker/apache/vhost.conf | 12 + docs/README.md | 78 +++ docs/REQUEST_FLOW.md | 80 +++ public/.htaccess | 5 + public/css/site.css | 468 +++++++++++++ public/index.php | 23 + public/js/app.js | 0 routes/web.php | 8 + scripts/README.md | 18 + scripts/migrate.php | 100 +++ tests/run.php | 113 ++++ 39 files changed, 3085 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 app/Controllers/HomeController.php create mode 100644 app/Models/User.php create mode 100644 app/Repositories/UserRepository.php create mode 100644 app/ViewModels/HomeIndexViewModel.php create mode 100644 app/Views/home/index.php create mode 100644 app/Views/layouts/app.php create mode 100644 app/Views/partials/footer.php create mode 100644 app/Views/partials/header.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/database.php create mode 100644 core/App.php create mode 100644 core/Controller.php create mode 100644 core/Database.php create mode 100644 core/Dispatcher.php create mode 100644 core/Migration.php create mode 100644 core/MigrationManager.php create mode 100644 core/Repository.php create mode 100644 core/Request.php create mode 100644 core/Response.php create mode 100644 core/Route.php create mode 100644 core/Router.php create mode 100644 core/Validator.php create mode 100644 core/View.php create mode 100644 core/helpers.php create mode 100644 docker/apache/vhost.conf create mode 100644 docs/README.md create mode 100644 docs/REQUEST_FLOW.md create mode 100644 public/.htaccess create mode 100644 public/css/site.css create mode 100644 public/index.php create mode 100644 public/js/app.js create mode 100644 routes/web.php create mode 100644 scripts/README.md create mode 100644 scripts/migrate.php create mode 100644 tests/run.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c96683d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force)", + "Bash(Select-Object Mode, Name)", + "Bash(Format-Table -AutoSize)", + "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force | Where-Object {$_.Name -match '^[A-Z]'} | Select-Object Mode, Name)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..290c83d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8aa6a62 --- /dev/null +++ b/AGENTS.md @@ -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 `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 +

name()) ?>

+``` + +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 +

+ + +``` + +--- + +## 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 + */ +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 \ No newline at end of file diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..f52aeba --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,32 @@ +title = 'MindVisionCode PHP'; + $model->eyebrow = 'Small MVC framework'; + $model->message = 'A lightweight PHP MVC starter with a central dispatcher, clean controllers, SQLite-backed repositories, and readable conventions.'; + $model->routeExample = '/users/123'; + + return $this->view('home.index', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + public function user(string $id) + { + return $this->json([ + 'userId' => $id, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..10662ea --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,12 @@ +database->first( + 'SELECT * FROM users WHERE email = :email', + ['email' => $email] + ); + } +} diff --git a/app/ViewModels/HomeIndexViewModel.php b/app/ViewModels/HomeIndexViewModel.php new file mode 100644 index 0000000..458b11f --- /dev/null +++ b/app/ViewModels/HomeIndexViewModel.php @@ -0,0 +1,13 @@ + +
+ eyebrow) ?> +

title) ?>

+

message) ?>

+ + +
+ + + + +
+
+

Readable by design

+

Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.

+
+ +
+

Classic MVC feel

+

Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.

+
+ +
+

SQLite ready

+

Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.

+
+
diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php new file mode 100644 index 0000000..5796585 --- /dev/null +++ b/app/Views/layouts/app.php @@ -0,0 +1,14 @@ + + +
+
+ +
+
+ + diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php new file mode 100644 index 0000000..d2a38dd --- /dev/null +++ b/app/Views/partials/footer.php @@ -0,0 +1,9 @@ +
+ +
+ + + diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php new file mode 100644 index 0000000..fc9392a --- /dev/null +++ b/app/Views/partials/header.php @@ -0,0 +1,42 @@ + 'Home', 'href' => '/'], + ['label' => 'Example JSON', 'href' => '/users/123'], +]; + +$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); +$currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; +?> + + + + + + <?= e($pageTitle ?? 'MindVisionCode PHP') ?> + + + +
+ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fa53963 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "kci/mindvisioncode", + "description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.", + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Core\\": "core/" + }, + "files": [ + "core/helpers.php" + ] + }, + "scripts": { + "migrate": "php scripts/migrate.php up", + "migrate:down": "php scripts/migrate.php down", + "migrate:status": "php scripts/migrate.php status", + "migrate:fresh": "php scripts/migrate.php fresh", + "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" + }, + "require": {} +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..75912c3 --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..a7bea57 --- /dev/null +++ b/config/database.php @@ -0,0 +1,11 @@ + 'sqlite:' . __DIR__ . '/../database/app.sqlite', + 'username' => null, + 'password' => null, + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; diff --git a/core/App.php b/core/App.php new file mode 100644 index 0000000..2ac8b9f --- /dev/null +++ b/core/App.php @@ -0,0 +1,57 @@ +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)); + } +} diff --git a/core/Controller.php b/core/Controller.php new file mode 100644 index 0000000..a73032e --- /dev/null +++ b/core/Controller.php @@ -0,0 +1,35 @@ +method() !== 'POST') { + throw new \Exception('This action requires POST.'); + } + } +} diff --git a/core/Database.php b/core/Database.php new file mode 100644 index 0000000..5ee020d --- /dev/null +++ b/core/Database.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/core/Dispatcher.php b/core/Dispatcher.php new file mode 100644 index 0000000..16ff518 --- /dev/null +++ b/core/Dispatcher.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/core/Migration.php b/core/Migration.php new file mode 100644 index 0000000..4bc46ee --- /dev/null +++ b/core/Migration.php @@ -0,0 +1,12 @@ +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 = <<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.'); + } +} diff --git a/core/Repository.php b/core/Repository.php new file mode 100644 index 0000000..4c01c77 --- /dev/null +++ b/core/Repository.php @@ -0,0 +1,38 @@ +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] + ); + } +} diff --git a/core/Request.php b/core/Request.php new file mode 100644 index 0000000..c04f7e8 --- /dev/null +++ b/core/Request.php @@ -0,0 +1,70 @@ +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); + } +} diff --git a/core/Response.php b/core/Response.php new file mode 100644 index 0000000..afd3a0c --- /dev/null +++ b/core/Response.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/core/Route.php b/core/Route.php new file mode 100644 index 0000000..9d4a8c4 --- /dev/null +++ b/core/Route.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..014250d --- /dev/null +++ b/core/Router.php @@ -0,0 +1,39 @@ +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; + } +} diff --git a/core/Validator.php b/core/Validator.php new file mode 100644 index 0000000..3e6f340 --- /dev/null +++ b/core/Validator.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/core/View.php b/core/View.php new file mode 100644 index 0000000..c823d3a --- /dev/null +++ b/core/View.php @@ -0,0 +1,68 @@ +title) && + trim($data['model']->title) !== '' + ) { + return $data['model']->title; + } + + return 'MindVisionCode PHP'; + } +} diff --git a/core/helpers.php b/core/helpers.php new file mode 100644 index 0000000..fffc7cb --- /dev/null +++ b/core/helpers.php @@ -0,0 +1,134 @@ + $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 ''; +} + +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); +} diff --git a/docker/apache/vhost.conf b/docker/apache/vhost.conf new file mode 100644 index 0000000..24d2fd2 --- /dev/null +++ b/docker/apache/vhost.conf @@ -0,0 +1,12 @@ + + DocumentRoot /var/www/html/public + + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..55488aa --- /dev/null +++ b/docs/README.md @@ -0,0 +1,78 @@ +# MindVisionCode PHP + +A small PHP MVC framework inspired by a Classic ASP MVC framework. + +## Run + +```bash +composer install +php scripts/migrate.php up +php -S localhost:8000 -t public +``` + +Open: + +```text +http://localhost:8000 +``` + +Try: + +```text +http://localhost:8000/users/123 +``` + +Employee form: + +```text +http://localhost:8000/employees +``` + +## Request Flow + +Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response + +## Main Folders + +- `core/` framework classes +- `app/Controllers/` application controllers +- `app/ViewModels/` view model classes +- `app/Repositories/` data access classes +- `app/Views/` PHP templates +- `routes/web.php` route definitions +- `database/migrations/` migrations +- `scripts/` runnable PHP CLI scripts + +## SQLite + +The default database is SQLite and points to: + +```text +database/app.sqlite +``` + +The database file is created automatically when the app first needs it. + +Run migrations from the PHP CLI: + +```bash +php scripts/migrate.php up +php scripts/migrate.php down +php scripts/migrate.php status +php scripts/migrate.php make create_projects_table +php scripts/migrate.php fresh +php scripts/migrate.php fresh --seed +php scripts/seed_employees.php 1000 +``` + +## Frontend Libraries + +The employee directory page uses: + +- `htmx` for fragment-based form and summary updates +- `Alpine.js` for lightweight page state +- `Tabulator` for the interactive employee table + +## Flow chart + +See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. diff --git a/docs/REQUEST_FLOW.md b/docs/REQUEST_FLOW.md new file mode 100644 index 0000000..31ed9d1 --- /dev/null +++ b/docs/REQUEST_FLOW.md @@ -0,0 +1,80 @@ +# Request / Response Flow + +This chart shows how a browser request moves through the MindVisionCode framework and how a response is built and returned. + +```text +Browser Request + | + v +public/index.php + |-- loads autoload.php / vendor autoload + |-- starts the session + |-- creates App + Router + |-- loads routes/web.php + | + v +Request::capture() + | + v +Dispatcher::dispatch() + | + +--> no route matched ----> Response::notFound() + | + +--> route matched -------> Route::dispatch() + | + v + App::call() + | + +--> controller method + | | + | v + | Controller action + | | + | +--> repository / service / view model + | +--> Database::query() / execute() + | +--> view() / json() / redirect() + | + +--> closure route + | + v + direct response data + +Dispatcher::normalizeResponse() + | + +--> Response object --------> Response::send() + +--> array ------------------> Response::json() --> Response::send() + +--> string -----------------> Response::send() + +Final result: +Browser receives HTML, JSON, or a redirect +``` + +## Response building paths + +### View response + +```text +Controller -> view() -> View::render() -> template -> layout -> Response +``` + +### JSON response + +```text +Controller -> json() -> Response::json() -> Response::send() +``` + +### Redirect response + +```text +Controller -> redirect() -> Response::redirect() -> Response::send() +``` + +## Key classes + +- `public/index.php` bootstraps the app +- `Core\Dispatcher` matches routes and handles errors +- `Core\Route` extracts route parameters +- `Core\App` invokes controller methods or closures +- `Core\Controller` gives actions helper methods +- `Core\View` renders templates into a layout +- `Core\Response` sends the final output diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..8661356 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,5 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/public/css/site.css b/public/css/site.css new file mode 100644 index 0000000..c99ebd9 --- /dev/null +++ b/public/css/site.css @@ -0,0 +1,468 @@ +:root { + --page-background: #f4efe7; + --surface: rgba(255, 252, 247, 0.88); + --surface-strong: #fffdf8; + --surface-border: rgba(26, 72, 64, 0.12); + --text-primary: #143631; + --text-secondary: #4f655f; + --accent: #1d7a6d; + --accent-strong: #135c52; + --accent-soft: #daf1ec; + --highlight: #ef7c4d; + --shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1); + --shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif; + color: var(--text-primary); + background: + radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%), + radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%), + linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%); +} + +a { + color: inherit; +} + +code { + font-family: Consolas, "Courier New", monospace; +} + +.page-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.container { + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; +} + +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(14px); + background: rgba(248, 242, 232, 0.78); + border-bottom: 1px solid rgba(20, 54, 49, 0.08); +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; + text-decoration: none; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.95rem; + background: linear-gradient(135deg, var(--accent), var(--highlight)); + color: #fff; + font-weight: 700; + letter-spacing: 0.08em; + box-shadow: var(--shadow-soft); +} + +.brand-copy { + display: flex; + flex-direction: column; + line-height: 1.1; +} + +.brand-copy strong { + font-size: 1rem; +} + +.brand-copy small { + color: var(--text-secondary); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.site-nav { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.nav-link { + text-decoration: none; + color: var(--text-secondary); + font-weight: 600; + padding: 0.7rem 1rem; + border-radius: 999px; + transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; +} + +.nav-link:hover, +.nav-link:focus-visible, +.nav-link.is-active { + color: var(--accent-strong); + background: rgba(29, 122, 109, 0.12); + transform: translateY(-1px); +} + +.page-content { + flex: 1; + padding: 3.5rem 0 4rem; +} + +.content-stack { + display: grid; + gap: 1.5rem; +} + +.section-heading { + max-width: 46rem; +} + +.section-heading h1 { + margin: 0.3rem 0 0.8rem; + font-size: clamp(2.4rem, 5vw, 4rem); + line-height: 1; + letter-spacing: -0.04em; +} + +.section-heading p { + margin: 0; + color: var(--text-secondary); + line-height: 1.8; + font-size: 1.05rem; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr); + gap: 1.5rem; + align-items: stretch; +} + +.hero-copy, +.hero-panel, +.feature-card, +.section-panel, +.alert, +.empty-state { + background: var(--surface); + border: 1px solid var(--surface-border); + box-shadow: var(--shadow-card); +} + +.hero-copy { + padding: 3rem; + border-radius: 2rem; +} + +.eyebrow { + display: inline-block; + margin-bottom: 1rem; + padding: 0.4rem 0.75rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-strong); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.hero h1 { + margin: 0; + font-size: clamp(2.8rem, 6vw, 4.8rem); + line-height: 0.98; + letter-spacing: -0.04em; +} + +.hero-text { + max-width: 44rem; + margin: 1.25rem 0 0; + font-size: 1.12rem; + line-height: 1.8; + color: var(--text-secondary); +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin-top: 2rem; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.9rem 1.35rem; + border-radius: 999px; + text-decoration: none; + font-weight: 700; +} + +.button-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #fff; + box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25); +} + +.button-secondary { + background: rgba(29, 122, 109, 0.08); + color: var(--accent-strong); +} + +.hero-panel { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 2rem; + border-radius: 1.8rem; +} + +.panel-label { + margin: 0 0 1rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.hero-panel code { + display: block; + padding: 1rem 1.1rem; + border-radius: 1.2rem; + background: #173d37; + color: #eefbf6; + line-height: 1.7; + white-space: normal; +} + +.route-callout { + margin-top: 1.5rem; + padding: 1rem 1.1rem; + border-radius: 1.2rem; + background: var(--surface-strong); +} + +.route-callout span { + display: block; + margin-bottom: 0.45rem; + color: var(--text-secondary); + font-size: 0.92rem; +} + +.route-callout a { + color: var(--highlight); + font-weight: 700; + text-decoration: none; +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.25rem; + margin-top: 1.5rem; +} + +.feature-card { + padding: 1.75rem; + border-radius: 1.6rem; +} + +.feature-card h2 { + margin-top: 0; + margin-bottom: 0.8rem; + font-size: 1.25rem; +} + +.feature-card p { + margin: 0; + color: var(--text-secondary); + line-height: 1.7; +} + +.section-panel { + padding: 1.75rem; + border-radius: 1.8rem; +} + +.panel-header { + margin-bottom: 1.5rem; +} + +.panel-header h2 { + margin: 0 0 0.45rem; + font-size: 1.45rem; +} + +.panel-header p { + margin: 0; + color: var(--text-secondary); + line-height: 1.7; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.field { + display: grid; + gap: 0.45rem; + font-weight: 600; +} + +.field span { + font-size: 0.96rem; +} + +.input { + width: 100%; + padding: 0.95rem 1rem; + border: 1px solid rgba(20, 54, 49, 0.16); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.92); + color: var(--text-primary); + font: inherit; +} + +.input:focus { + outline: 2px solid rgba(29, 122, 109, 0.22); + border-color: rgba(29, 122, 109, 0.45); +} + +.field-error { + color: #a43d1f; + font-size: 0.88rem; + font-weight: 600; +} + +.form-actions { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0.85rem; +} + +.button { + border: 0; + cursor: pointer; +} + +.alert, +.empty-state { + padding: 1rem 1.15rem; + border-radius: 1.2rem; +} + +.alert-success { + background: rgba(218, 241, 236, 0.92); + color: var(--accent-strong); +} + +.alert-error { + background: rgba(239, 124, 77, 0.14); + color: #8f3518; +} + +.empty-state p { + margin: 0; + color: var(--text-secondary); + line-height: 1.7; +} + +.empty-state p + p { + margin-top: 0.45rem; +} + +.site-footer { + margin-top: auto; + border-top: 1px solid rgba(20, 54, 49, 0.08); + background: rgba(255, 252, 247, 0.72); +} + +.footer-inner { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 0 2rem; + color: var(--text-secondary); + font-size: 0.95rem; +} + +.footer-inner p { + margin: 0; +} + +@media (max-width: 860px) { + .header-inner, + .footer-inner { + flex-direction: column; + align-items: flex-start; + } + + .hero, + .feature-grid { + grid-template-columns: 1fr; + } + + .hero-copy, + .hero-panel { + padding: 2rem; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .page-content { + padding-top: 2rem; + } +} + +@media (max-width: 560px) { + .container { + width: min(100% - 1.25rem, 1120px); + } + + .site-nav { + width: 100%; + } + + .nav-link { + width: 100%; + text-align: center; + } + + .hero h1 { + font-size: 2.5rem; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9d26c73 --- /dev/null +++ b/public/index.php @@ -0,0 +1,23 @@ +dispatch($request); + +$response->send(); diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..9a631e8 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,8 @@ +get('/', [HomeController::class, 'index']); +$router->get('/users/{id}', [HomeController::class, 'user']); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..ff39152 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,18 @@ +# Scripts + +This directory holds project PHP scripts that are meant to be run from the command line. + +Examples: + +```bash +php scripts/migrate.php up +php scripts/migrate.php status +php scripts/migrate.php fresh --seed +php scripts/seed_employees.php 1000 +``` + +Guidelines: + +- Put CLI-only PHP entrypoints here. +- Keep reusable logic in `core/`, `app/`, or `database/`. +- Let scripts stay thin and call into application classes or helper functions. diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..977d16c --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,100 @@ +runPending(); + + if ($ran === []) { + echo "No pending migrations." . PHP_EOL; + exit(0); + } + + foreach ($ran as $migration) { + echo "Migrated: {$migration}" . PHP_EOL; + } + + echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL; + exit(0); + + case 'down': + $steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1; + $rolledBack = $manager->rollback($steps); + + if ($rolledBack === []) { + echo "No applied migrations to roll back." . PHP_EOL; + exit(0); + } + + foreach ($rolledBack as $migration) { + echo "Rolled back: {$migration}" . PHP_EOL; + } + + echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL; + exit(0); + + case 'status': + $status = $manager->status(); + + if ($status === []) { + echo "No migration files found." . PHP_EOL; + exit(0); + } + + foreach ($status as $row) { + $state = $row['ran'] ? 'up' : 'pending'; + $ranAt = $row['ran_at'] ?? '-'; + echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL; + } + + exit(0); + + case 'make': + case 'create': + $name = $argv[2] ?? ''; + + if ($name === '') { + throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table'); + } + + $path = $manager->make($name); + echo "Created migration: {$path}" . PHP_EOL; + exit(0); + + case 'fresh': + $result = $manager->fresh(); + + foreach ($result['rolled_back'] as $migration) { + echo "Rolled back: {$migration}" . PHP_EOL; + } + + foreach ($result['migrated'] as $migration) { + echo "Migrated: {$migration}" . PHP_EOL; + } + + echo "Fresh migration run complete." . PHP_EOL; + exit(0); + + case 'help': + default: + echo "Migration CLI" . PHP_EOL; + echo "Usage:" . PHP_EOL; + echo " php scripts/migrate.php up" . PHP_EOL; + echo " php scripts/migrate.php down [steps]" . PHP_EOL; + echo " php scripts/migrate.php status" . PHP_EOL; + echo " php scripts/migrate.php make " . PHP_EOL; + echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; + exit(0); + } +} catch (Throwable $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); +} diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..dfc6615 --- /dev/null +++ b/tests/run.php @@ -0,0 +1,113 @@ +execute('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS projects'); + } +}; +PHP +); + +$memoryDatabase = new Database([ + 'dsn' => 'sqlite::memory:', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]); + +$migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath); +$ran = $migrationManager->runPending(); + +if ($ran !== ['20260509_120000_create_projects_table.php']) { + echo "FAIL: migration manager did not apply the expected migration\n"; + exit(1); +} + +$projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); + +if ($projectTable === null) { + echo "FAIL: migration up() did not create the projects table\n"; + exit(1); +} + +$rolledBack = $migrationManager->rollback(); + +if ($rolledBack !== ['20260509_120000_create_projects_table.php']) { + echo "FAIL: migration manager did not roll back the expected migration\n"; + exit(1); +} + +$projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); + +if ($projectTableAfterRollback !== null) { + echo "FAIL: migration down() did not remove the projects table\n"; + exit(1); +} + +$createdMigrationPath = $migrationManager->make('create_tasks_table'); + +if (!file_exists($createdMigrationPath)) { + echo "FAIL: migration manager did not create a migration file\n"; + exit(1); +} + +$router = new Router(); +$app = new App(); + +(new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending(); + +require_once __DIR__ . '/../routes/web.php'; + +$router->get('/hello/{name}', function (string $name) { + return 'Hello, ' . $name; +}); + +$request = new Request([], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/hello/Daniel', +]); + +$response = (new Dispatcher($router, $app))->dispatch($request); + +if ($response->status() !== 200) { + echo "FAIL: expected status 200\n"; + exit(1); +} + +if ($response->content() !== 'Hello, Daniel') { + echo "FAIL: unexpected response content\n"; + exit(1); +} + +echo "PASS: migration manager and route dispatch work\n";