From 8acedba1d0e1574f933969e5bf6101c0972a1681 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Thu, 21 May 2026 11:25:45 -0400 Subject: [PATCH] Ai Fixes --- .ai/SKILLS.md | 42 +++++++++ .ai/skills/database/SKILL.md | 97 +++++++++++++++++--- .ai/skills/mvc/SKILL.md | 117 ++++++++++++++++++++++++- .ai/skills/security/SKILL.md | 73 ++++++++++++++- .gitignore | 3 +- AGENTS.md | 7 +- app/Controllers/EmployeeController.php | 57 ++++++------ config/view.php | 6 ++ core/App.php | 33 ++++++- core/Controller.php | 6 +- core/Database.php | 20 +++++ core/Dispatcher.php | 12 ++- core/MigrationManager.php | 86 ++++-------------- core/Request.php | 12 ++- core/Response.php | 2 +- core/Route.php | 16 ++-- core/Router.php | 15 ++++ core/Validator.php | 62 +++++++++++++ core/View.php | 15 +++- public/index.php | 7 +- 20 files changed, 544 insertions(+), 144 deletions(-) create mode 100644 config/view.php diff --git a/.ai/SKILLS.md b/.ai/SKILLS.md index acef0c5..ca3f84d 100644 --- a/.ai/SKILLS.md +++ b/.ai/SKILLS.md @@ -27,6 +27,17 @@ Do not turn this into Laravel, Symfony, Slim, CakePHP, or another large framewor --- +## Configuration Files + +| File | Purpose | +|------|---------| +| `config/database.php` | PDO DSN, credentials, and PDO options | +| `config/view.php` | Views directory path and layout file path | + +Add new config files to `config/` — never hardcode environment-specific paths in `core/`. + +--- + ## Tech Stack - PHP 8.2+ @@ -119,6 +130,37 @@ Run basic tests: php tests/run.php ``` +Run database migrations: + +```bash +php scripts/migrate.php up +``` + +Roll back the last migration: + +```bash +php scripts/migrate.php down +``` + +Check migration status: + +```bash +php scripts/migrate.php status +``` + +Create a new migration file: + +```bash +php scripts/migrate.php make +``` + +Reset and re-run all migrations: + +```bash +php scripts/migrate.php fresh +php scripts/migrate.php fresh --seed +``` + --- ## Request Flow diff --git a/.ai/skills/database/SKILL.md b/.ai/skills/database/SKILL.md index 2e79687..aab7d35 100644 --- a/.ai/skills/database/SKILL.md +++ b/.ai/skills/database/SKILL.md @@ -17,6 +17,37 @@ Preferred database access: --- +## Database Class API + +`Core\Database` wraps PDO and exposes these methods: + +| Method | Returns | Description | +|--------|---------|-------------| +| `query(string $sql, array $params = [])` | `array` | Runs a SELECT and returns all rows as associative arrays | +| `first(string $sql, array $params = [])` | `?array` | Runs a SELECT and returns the first row, or `null` | +| `execute(string $sql, array $params = [])` | `bool` | Runs INSERT / UPDATE / DELETE | +| `lastInsertId()` | `string` | Returns the last auto-increment ID as a string — cast to `int` for integer PKs | +| `transaction(callable $fn)` | `mixed` | Runs `$fn($db)` inside a transaction; commits on success, rolls back and rethrows on failure | +| `pdo()` | `PDO` | Returns the raw PDO instance for advanced use | + +`lastInsertId()` is only meaningful immediately after an `execute()` INSERT on the same connection. Calling it at any other point returns `"0"`. + +Typical INSERT + ID retrieval in a repository: + +```php +public function create(Employee $employee): int +{ + $this->database->execute( + 'INSERT INTO employees (first_name, email) VALUES (:first_name, :email)', + ['first_name' => $employee->firstName, 'email' => $employee->email] + ); + + return (int) $this->database->lastInsertId(); +} +``` + +--- + ## Database Access Rules Use PDO or a well-maintained database abstraction layer/ORM. @@ -52,23 +83,17 @@ Rules: ## Transactions -Use transactions when multiple writes must succeed or fail together. - -Example: +Use `Database::transaction()` when multiple writes must succeed or fail together. It begins a transaction, runs the callback, commits on success, and rolls back and rethrows on any `Throwable`. ```php -$pdo->beginTransaction(); - -try { +$database->transaction(function (Database $db) use ($order): void { $orders->create($order); $auditLog->record('order.created', $order->id()); - $pdo->commit(); -} catch (Throwable $e) { - $pdo->rollBack(); - throw $e; -} +}); ``` +Do not call `$database->pdo()->beginTransaction()` directly for transaction management — use `transaction()` instead. Reserve `pdo()` for driver-specific features that have no `Database` API equivalent. + --- ## Repository Rules @@ -82,6 +107,53 @@ try { --- +## Migration System + +The migration system is made up of three files: + +- `core/Migration.php` — abstract base class all migration files extend +- `core/MigrationManager.php` — runs, rolls back, and tracks applied migrations +- `core/helpers.php` — provides the `migration_manager()` helper that wires a `MigrationManager` to the app database and the `database/migrations/` path + +The CLI entry point is `scripts/migrate.php`. Run it from the project root: + +| Command | Description | +|---------|-------------| +| `php scripts/migrate.php up` | Run all pending migrations | +| `php scripts/migrate.php down [steps]` | Roll back the last N migrations (default: 1) | +| `php scripts/migrate.php status` | Show which migrations have run and when | +| `php scripts/migrate.php make ` | Scaffold a new timestamped migration file | +| `php scripts/migrate.php fresh` | Roll back everything and re-run from scratch | +| `php scripts/migrate.php fresh --seed` | Same as fresh, then run the employee seed | + +Migration files live in `database/migrations/` and are named `YYYYMMDD_HHMMSS_.php`. + +Each file must return a `Migration` instance: + +```php +execute('CREATE TABLE ...'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS ...'); + } +}; +``` + +--- + ## Migration Rules - Keep migrations small and reversible when practical. @@ -89,6 +161,9 @@ try { - Do not mix schema changes with unrelated feature logic. - Use project migration conventions before inventing new ones. - Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines. +- Do not use database-specific SQL in framework or migration code. `INSERT OR IGNORE` (SQLite) and `INSERT IGNORE` (MySQL) are not portable — use a check-then-insert pattern instead. +- Migration records in the `migrations` table are permanent. A row means "this migration ran against this database." Deleting a migration file does not remove the record and does not allow the migration to be re-run. To intentionally re-run a migration, delete its row from the `migrations` table manually — this makes the action explicit. +- To reset completely, use `php scripts/migrate.php fresh`, which rolls back all migrations in reverse order and re-runs them from scratch. --- diff --git a/.ai/skills/mvc/SKILL.md b/.ai/skills/mvc/SKILL.md index 91f9aaf..cdd606a 100644 --- a/.ai/skills/mvc/SKILL.md +++ b/.ai/skills/mvc/SKILL.md @@ -122,6 +122,42 @@ php tests/run.php --- +## Service and Request Injection + +`Core\App::resolveArgs()` injects constructor and action parameters by type. Any service registered via `$app->bind(SomeClass::class, $instance)` in `public/index.php` is automatically injected when an action declares a typed parameter of that class. + +`Core\Request` is registered as a binding in `public/index.php` before dispatch, so controller actions can declare it as a parameter without calling `Request::capture()` manually: + +```php +public function index(Request $request): Response +{ + $search = $request->input('search', ''); + // ... +} +``` + +Route segment parameters (e.g. `{id}`) are still resolved by name before the binding lookup. + +To make a service injectable, register it once at bootstrap: + +```php +// public/index.php +$app->bind(Database::class, database()); +``` + +Then declare it as a typed parameter in any action: + +```php +public function index(Database $db): Response +{ + // $db is injected automatically +} +``` + +Do not call `Request::capture()` inside action bodies. Declare the parameter instead. + +--- + ## Controller Rules - Keep controllers thin. @@ -129,6 +165,29 @@ php tests/run.php - Do not put database query details directly in controllers when a repository or service is more appropriate. - Do not put template rendering logic inside business services. - Return or produce a response through the framework’s response mechanism. +- Use `requirePost($request)` to guard POST-only actions. It returns `?Response` (null when the method is POST, a 405 Response otherwise). Always return it immediately if non-null: + +```php +if ($guard = $this->requirePost($request)) { + return $guard; +} +``` + +- Verify CSRF **before** field validation on any state-changing action. CSRF failure is a security gate, not a form validation error. See the Security skill for the helper pattern. +- When a controller uses a repository across multiple methods, store it as a nullable property and lazy-initialize once — do not call `new Repository(database())` on every method call: + +```php +private ?EmployeeRepository $employees = null; + +private function employees(): EmployeeRepository +{ + if ($this->employees === null) { + $this->employees = new EmployeeRepository(database()); + } + + return $this->employees; +} +``` --- @@ -141,6 +200,21 @@ php tests/run.php --- +## View Configuration + +View paths are set in `config/view.php`: + +```php +return [ + 'views_path' => __DIR__ . '/../app/Views', + 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', +]; +``` + +`core/View.php` reads this file lazily on first use and caches the result. To change where views or the layout live, edit `config/view.php` — do not edit `core/View.php`. This follows the same pattern as `config/database.php`. + +--- + ## Templates and Views Keep presentation separate from business logic. @@ -167,6 +241,37 @@ Plain PHP template example: --- +## Router Methods + +`Core\Router` exposes one method per HTTP verb: + +| Method | HTTP verb | +|--------|-----------| +| `$router->get($path, $handler)` | GET | +| `$router->post($path, $handler)` | POST | +| `$router->put($path, $handler)` | PUT | +| `$router->patch($path, $handler)` | PATCH | +| `$router->delete($path, $handler)` | DELETE | +| `$router->add($method, $path, $handler)` | Any verb | + +--- + +## Method Override for HTML Forms + +HTML forms only support GET and POST. To route a form submission to a PUT, PATCH, or DELETE handler, add a hidden `_method` field: + +```html +
+ + + +
+``` + +`Core\Request::method()` checks for this field (and the `X-HTTP-Method-Override` header from JavaScript clients) when the base method is POST, and returns the overridden verb. Only `PUT`, `PATCH`, and `DELETE` are accepted as override values — all others are ignored. + +--- + ## HTTP and Web Application Rules Rules: @@ -179,12 +284,16 @@ Rules: - 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: +Example POST guard using the framework helper: ```php -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - exit('Method Not Allowed'); +public function store(Request $request): Response +{ + if ($guard = $this->requirePost($request)) { + return $guard; + } + + // POST-only logic here } ``` diff --git a/.ai/skills/security/SKILL.md b/.ai/skills/security/SKILL.md index 377bdfe..055183b 100644 --- a/.ai/skills/security/SKILL.md +++ b/.ai/skills/security/SKILL.md @@ -27,15 +27,41 @@ Untrusted data includes: ## Input Validation -Validate on input. +Validate on input. Use `Core\Validator` for field validation — it is a fluent chain that collects all errors before returning. + +### Validator API + +| Method | Description | +|--------|-------------| +| `required(field, value, message?)` | Fails if value is null or blank | +| `maxLength(field, value, max, message?)` | Fails if string length exceeds max | +| `minLength(field, value, min, message?)` | Fails if string length is below min | +| `numeric(field, value, message?)` | Fails if value is not numeric | +| `min(field, value, min, message?)` | Fails if numeric value is below min | +| `max(field, value, max, message?)` | Fails if numeric value exceeds max | +| `email(field, value, message?)` | Fails if non-empty value is not a valid email | +| `date(field, value, format?, message?)` | Fails if non-empty value does not match the date format (default `Y-m-d`) | +| `in(field, value, allowed[], message?)` | Fails if value is not in the allowed list (strict comparison) | +| `passes()` | Returns `true` when no errors were collected | +| `fails()` | Returns `true` when any errors were collected | +| `errors()` | Returns `array>` of field errors | + +`email()`, `date()`, `min()`, and `max()` skip empty or non-numeric values respectively — pair them with `required()` or `numeric()` when the field is mandatory. Example: ```php -$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); +$validator = new Validator(); -if ($email === false || $email === null) { - throw new InvalidArgumentException('A valid email address is required.'); +$validator + ->required('email', $form['email'], 'Email is required.') + ->maxLength('email', $form['email'], 255) + ->email('email', $form['email'], 'Enter a valid email address.') + ->required('start_date', $form['start_date'], 'Start date is required.') + ->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.'); + +if ($validator->fails()) { + // $validator->errors() returns field => [messages] map } ``` @@ -45,6 +71,7 @@ Rules: - Validate server-side even when client-side validation exists. - Reject unexpected fields when appropriate. - Normalize data intentionally, not accidentally. +- Do not reimplement email or date validation inline in controllers — use the Validator methods. --- @@ -130,6 +157,44 @@ State-changing actions include: - Email changes - Permission changes +When using `_method` override to tunnel PUT, PATCH, or DELETE through a POST form, always include a CSRF token. The override is only honoured for POST requests, and only for the values `PUT`, `PATCH`, and `DELETE` — all other values are rejected by the framework. + +In MindVisionCode PHP, use the built-in helpers from `core/helpers.php`: + +| Helper | Purpose | +|--------|---------| +| `csrf_token()` | Generates and persists the token in the session | +| `csrf_field()` | Outputs a hidden `` carrying the token — use in every state-changing form | +| `verify_csrf_token(string $token)` | Returns `bool` — call before any business logic in POST actions | + +**Always verify CSRF before field validation and business logic.** A token failure is a security event, not a form validation error. Use a dedicated private method that returns `?Response` and short-circuits the action: + +```php +private function verifyCsrf(Request $request): ?Response +{ + if (!verify_csrf_token((string) $request->input('_token', ''))) { + return new Response('Your session has expired. Please go back and try again.', 419); + } + + return null; +} +``` + +Call it as the first thing in the action: + +```php +public function store(): Response +{ + $request = Request::capture(); + + if ($guard = $this->verifyCsrf($request)) { + return $guard; + } + + // field validation and business logic follow +} +``` + --- ## Serialization and Data Exchange diff --git a/.gitignore b/.gitignore index 290c83d..8d68168 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ /database/*.sqlite-journal .phpunit.result.cache Thumbs.db -docker-compose.yml -Dockerfile + diff --git a/AGENTS.md b/AGENTS.md index 12e305c..ac9781c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ For simple questions, answer directly. ## Framework Change Policy -The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `framework/`), it must stop and present the following proposal to the user before writing a single line: +The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `core/`), it must stop and present the following proposal to the user before writing a single line: ```text FRAMEWORK CHANGE PROPOSAL @@ -145,6 +145,9 @@ Benefits: Alternatives Considered: Any application-level workarounds that were ruled out and why. +Ai Agent Skills Update: + - What skills need to be changed to support this framework-level change? + Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask questions. ``` @@ -153,7 +156,7 @@ Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask - If the user says NO, document the limitation as a comment or note and continue with the best available application-level workaround. - Keep framework changes small and focused — one concern per change. - After approval, note the change in the commit message so the history is clear. - +- Update the proper skill file so that the new process can be applied to all future changes in this repository. --- ## Skill Feedback Rule diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php index 6ff7eae..88065e5 100644 --- a/app/Controllers/EmployeeController.php +++ b/app/Controllers/EmployeeController.php @@ -9,13 +9,15 @@ use App\Repositories\EmployeeRepository; use App\ViewModels\EmployeeFormViewModel; use Core\Controller; use Core\Request; +use Core\Response; use Core\Validator; class EmployeeController extends Controller { - public function index() + private ?EmployeeRepository $employees = null; + + public function index(Request $request) { - $request = Request::capture(); $viewModel = $this->buildViewModel((string) $request->input('search', '')); $viewModel->saved = $request->input('saved') === '1'; @@ -25,11 +27,14 @@ class EmployeeController extends Controller ]); } - public function store() + public function store(Request $request) { - $request = Request::capture(); + if ($guard = $this->verifyCsrf($request)) { + return $guard; + } + $form = $this->sanitizeFormData($request); - $errors = $this->validateForm($form, $request); + $errors = $this->validateForm($form); if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) { $errors['email'][] = 'That email address is already in use.'; @@ -81,9 +86,8 @@ class EmployeeController extends Controller return $this->redirect('/employees'); } - public function summary() + public function summary(Request $request) { - $request = Request::capture(); $viewModel = $this->buildViewModel((string) $request->input('search', '')); return $this->fragment('employees.partials.summary', [ @@ -91,9 +95,8 @@ class EmployeeController extends Controller ]); } - public function data() + public function data(Request $request) { - $request = Request::capture(); $search = trim((string) $request->input('search', '')); $rows = $this->employees()->search($search); @@ -136,7 +139,7 @@ class EmployeeController extends Controller * @param array $form * @return array> */ - private function validateForm(array $form, Request $request): array + private function validateForm(array $form): array { $validator = new Validator(); @@ -147,39 +150,33 @@ class EmployeeController extends Controller ->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.') + ->email('email', $form['email'], 'Enter a valid email address.') ->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(); + ->required('start_date', $form['start_date'], 'Start date is required.') + ->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.'); - 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; + return $validator->errors(); } - private function isValidDate(string $value): bool + private function verifyCsrf(Request $request): ?Response { - $date = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + if (!verify_csrf_token((string) $request->input('_token', ''))) { + return new Response('Your session has expired. Please go back and try again.', 419); + } - return $date !== false && $date->format('Y-m-d') === $value; + return null; } private function employees(): EmployeeRepository { - return new EmployeeRepository(database()); + if ($this->employees === null) { + $this->employees = new EmployeeRepository(database()); + } + + return $this->employees; } private function isHtmxRequest(Request $request): bool diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..0db4705 --- /dev/null +++ b/config/view.php @@ -0,0 +1,6 @@ + __DIR__ . '/../app/Views', + 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', +]; diff --git a/core/App.php b/core/App.php index 2ac8b9f..16e8ad1 100644 --- a/core/App.php +++ b/core/App.php @@ -39,7 +39,7 @@ class App { $reflection = new ReflectionFunction($handler); - return $reflection->invokeArgs(array_values($parameters)); + return $reflection->invokeArgs($this->resolveArgs($reflection, $parameters)); } protected function callMethod(array $handler, array $parameters): mixed @@ -52,6 +52,35 @@ class App $reflection = new ReflectionMethod($class, $method); - return $reflection->invokeArgs($class, array_values($parameters)); + return $reflection->invokeArgs($class, $this->resolveArgs($reflection, $parameters)); + } + + protected function resolveArgs(\ReflectionFunctionAbstract $reflection, array $parameters): array + { + $args = []; + + foreach ($reflection->getParameters() as $param) { + $name = $param->getName(); + + if (array_key_exists($name, $parameters)) { + $args[] = $parameters[$name]; + continue; + } + + $type = $param->getType(); + + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + + if (array_key_exists($typeName, $this->bindings)) { + $args[] = $this->bindings[$typeName]; + continue; + } + } + + $args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + } + + return $args; } } diff --git a/core/Controller.php b/core/Controller.php index a73032e..bcc8f2f 100644 --- a/core/Controller.php +++ b/core/Controller.php @@ -26,10 +26,12 @@ abstract class Controller return Response::json($data); } - protected function requirePost(Request $request): void + protected function requirePost(Request $request): ?Response { if ($request->method() !== 'POST') { - throw new \Exception('This action requires POST.'); + return new Response('Method Not Allowed.', 405); } + + return null; } } diff --git a/core/Database.php b/core/Database.php index 5ee020d..6ccd2b0 100644 --- a/core/Database.php +++ b/core/Database.php @@ -46,4 +46,24 @@ class Database return $statement->execute($parameters); } + + public function lastInsertId(): string + { + return $this->pdo->lastInsertId(); + } + + public function transaction(callable $fn): mixed + { + $this->pdo->beginTransaction(); + + try { + $result = $fn($this); + $this->pdo->commit(); + + return $result; + } catch (\Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } } diff --git a/core/Dispatcher.php b/core/Dispatcher.php index 16ff518..fe344a9 100644 --- a/core/Dispatcher.php +++ b/core/Dispatcher.php @@ -10,11 +10,13 @@ class Dispatcher { protected Router $router; protected App $app; + protected bool $debug; - public function __construct(Router $router, App $app) + public function __construct(Router $router, App $app, bool $debug = false) { $this->router = $router; $this->app = $app; + $this->debug = $debug; } public function dispatch(Request $request): Response @@ -32,7 +34,13 @@ class Dispatcher return $this->normalizeResponse($result); } catch (Throwable $e) { - return Response::serverError($e->getMessage()); + if (!$this->debug) { + error_log($e->getMessage()); + } + + $message = $this->debug ? $e->getMessage() : 'An unexpected error occurred.'; + + return Response::serverError($message); } finally { Request::clearCurrent(); } diff --git a/core/MigrationManager.php b/core/MigrationManager.php index ada51c0..d604cb0 100644 --- a/core/MigrationManager.php +++ b/core/MigrationManager.php @@ -21,39 +21,9 @@ class MigrationManager '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 @@ -101,22 +71,15 @@ class MigrationManager $migration = $this->loadMigration($file); - $this->database->pdo()->beginTransaction(); - - try { - $migration->up($this->database); + $this->database->transaction(function (Database $db) use ($migration, $name, &$ranMigrations): void { + $migration->up($db); - $this->database->execute( - 'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)', - ['migration' => $name] - ); + if ($db->first('SELECT id FROM migrations WHERE migration = :migration', ['migration' => $name]) === null) { + $db->execute('INSERT INTO migrations (migration) VALUES (:migration)', ['migration' => $name]); + } - $this->database->pdo()->commit(); $ranMigrations[] = $name; - } catch (\Throwable $exception) { - $this->database->pdo()->rollBack(); - throw $exception; - } + }); } return $ranMigrations; @@ -128,11 +91,8 @@ class MigrationManager $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}" + 'SELECT id, migration FROM migrations ORDER BY id DESC LIMIT :steps', + ['steps' => $steps] ); $rolledBack = []; @@ -144,22 +104,12 @@ class MigrationManager } $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(); + $this->database->transaction(function (Database $db) use ($migration, $row, &$rolledBack): void { + $migration->down($db); + $db->execute('DELETE FROM migrations WHERE id = :id', ['id' => $row['id']]); $rolledBack[] = $row['migration']; - } catch (\Throwable $exception) { - $this->database->pdo()->rollBack(); - throw $exception; - } + }); } return $rolledBack; @@ -176,16 +126,10 @@ class MigrationManager $migration = $this->loadMigration($file); $name = basename($file); - $this->database->pdo()->beginTransaction(); - - try { - $migration->down($this->database); - $this->database->pdo()->commit(); + $this->database->transaction(function (Database $db) use ($migration, $name, &$rolledBack): void { + $migration->down($db); $rolledBack[] = $name; - } catch (\Throwable $exception) { - $this->database->pdo()->rollBack(); - throw $exception; - } + }); } $this->database->execute('DELETE FROM migrations'); diff --git a/core/Request.php b/core/Request.php index c04f7e8..4cd22eb 100644 --- a/core/Request.php +++ b/core/Request.php @@ -43,7 +43,17 @@ class Request public function method(): string { - return strtoupper($this->method); + $method = strtoupper($this->method); + + if ($method === 'POST') { + $override = strtoupper((string) ($this->post['_method'] ?? $this->server['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '')); + + if (in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) { + return $override; + } + } + + return $method; } public function path(): string diff --git a/core/Response.php b/core/Response.php index afd3a0c..da74f72 100644 --- a/core/Response.php +++ b/core/Response.php @@ -20,7 +20,7 @@ class Response public static function json(array $data, int $status = 200): self { return new self( - json_encode($data, JSON_PRETTY_PRINT), + json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), $status, ['Content-Type' => 'application/json'] ); diff --git a/core/Route.php b/core/Route.php index 9d4a8c4..a91a919 100644 --- a/core/Route.php +++ b/core/Route.php @@ -10,12 +10,19 @@ class Route protected string $path; protected mixed $handler; protected array $parameters = []; + protected string $compiledPattern; + protected array $parameterNames = []; public function __construct(string $method, string $path, mixed $handler) { $this->method = strtoupper($method); $this->path = $path; $this->handler = $handler; + + preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $path, $names); + $this->parameterNames = $names[1]; + $compiled = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $path); + $this->compiledPattern = '#^' . $compiled . '$#'; } public function matches(string $method, string $path): bool @@ -24,19 +31,14 @@ class Route return false; } - $routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path); - $routePattern = '#^' . $routePattern . '$#'; - - if (!preg_match($routePattern, $path, $matches)) { + if (!preg_match($this->compiledPattern, $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) { + foreach ($this->parameterNames as $index => $name) { $this->parameters[$name] = $matches[$index] ?? null; } diff --git a/core/Router.php b/core/Router.php index 014250d..24b51b0 100644 --- a/core/Router.php +++ b/core/Router.php @@ -18,6 +18,21 @@ class Router return $this->add('POST', $path, $handler); } + public function put(string $path, callable|array|string $handler): Route + { + return $this->add('PUT', $path, $handler); + } + + public function patch(string $path, callable|array|string $handler): Route + { + return $this->add('PATCH', $path, $handler); + } + + public function delete(string $path, callable|array|string $handler): Route + { + return $this->add('DELETE', $path, $handler); + } + public function add(string $method, string $path, callable|array|string $handler): Route { $route = new Route($method, $path, $handler); diff --git a/core/Validator.php b/core/Validator.php index 3e6f340..e6c34d7 100644 --- a/core/Validator.php +++ b/core/Validator.php @@ -35,6 +35,68 @@ class Validator return $this; } + public function email(string $field, mixed $value, string $message = ''): self + { + if ((string) $value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) === false) { + $this->errors[$field][] = $message ?: "{$field} must be a valid email address."; + } + + return $this; + } + + public function date(string $field, mixed $value, string $format = 'Y-m-d', string $message = ''): self + { + $str = (string) $value; + + if ($str === '') { + return $this; + } + + $parsed = \DateTimeImmutable::createFromFormat($format, $str); + + if ($parsed === false || $parsed->format($format) !== $str) { + $this->errors[$field][] = $message ?: "{$field} must be a valid date ({$format})."; + } + + return $this; + } + + public function minLength(string $field, mixed $value, int $min, string $message = ''): self + { + if (strlen((string) $value) < $min) { + $this->errors[$field][] = $message ?: "{$field} must be at least {$min} characters."; + } + + return $this; + } + + public function min(string $field, mixed $value, int|float $min, string $message = ''): self + { + if (!is_numeric($value) || (float) $value < $min) { + $this->errors[$field][] = $message ?: "{$field} must be at least {$min}."; + } + + return $this; + } + + public function max(string $field, mixed $value, int|float $max, string $message = ''): self + { + if (!is_numeric($value) || (float) $value > $max) { + $this->errors[$field][] = $message ?: "{$field} must be no more than {$max}."; + } + + return $this; + } + + public function in(string $field, mixed $value, array $allowed, string $message = ''): self + { + if (!in_array($value, $allowed, true)) { + $this->errors[$field][] = $message ?: "{$field} must be one of: " . implode(', ', $allowed) . '.'; + } + + return $this; + } + public function passes(): bool { return empty($this->errors); diff --git a/core/View.php b/core/View.php index c823d3a..96a9d17 100644 --- a/core/View.php +++ b/core/View.php @@ -9,7 +9,7 @@ class View public static function render(string $view, array $data = []): Response { $content = self::renderContent($view, $data); - $layoutPath = __DIR__ . '/../app/Views/layouts/app.php'; + $layoutPath = self::config()['layout_path']; if (!file_exists($layoutPath)) { return new Response($content); @@ -33,7 +33,7 @@ class View private static function renderContent(string $view, array $data): string { - $path = __DIR__ . '/../app/Views/' . str_replace('.', '/', $view) . '.php'; + $path = self::config()['views_path'] . '/' . str_replace('.', '/', $view) . '.php'; if (!file_exists($path)) { throw new \Exception("View not found: {$view}"); @@ -47,6 +47,17 @@ class View return (string) ob_get_clean(); } + private static function config(): array + { + static $config = null; + + if ($config === null) { + $config = require __DIR__ . '/../config/view.php'; + } + + return $config; + } + private static function resolvePageTitle(array $data): string { if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { diff --git a/public/index.php b/public/index.php index 9d26c73..14e2713 100644 --- a/public/index.php +++ b/public/index.php @@ -4,20 +4,21 @@ 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(); +$app = app(); $router = new Router(); require_once __DIR__ . '/../routes/web.php'; -$dispatcher = new Dispatcher($router, $app); +$debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); +$dispatcher = new Dispatcher($router, $app, $debug); $request = Request::capture(); +$app->bind(Request::class, $request); $response = $dispatcher->dispatch($request); $response->send();