| @@ -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 <name> | |||
| ``` | |||
| Reset and re-run all migrations: | |||
| ```bash | |||
| php scripts/migrate.php fresh | |||
| php scripts/migrate.php fresh --seed | |||
| ``` | |||
| --- | |||
| ## Request Flow | |||
| @@ -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 <name>` | 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_<slug>.php`. | |||
| Each file must return a `Migration` instance: | |||
| ```php | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute('CREATE TABLE ...'); | |||
| } | |||
| 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. | |||
| --- | |||
| @@ -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 | |||
| <form method="POST" action="/employees/42"> | |||
| <?= csrf_field() ?> | |||
| <input type="hidden" name="_method" value="PUT"> | |||
| <!-- fields --> | |||
| </form> | |||
| ``` | |||
| `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 | |||
| } | |||
| ``` | |||
| @@ -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<string, list<string>>` 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 `<input>` 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 | |||
| @@ -16,5 +16,4 @@ | |||
| /database/*.sqlite-journal | |||
| .phpunit.result.cache | |||
| Thumbs.db | |||
| docker-compose.yml | |||
| Dockerfile | |||
| @@ -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 | |||
| @@ -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<string, string> $form | |||
| * @return array<string, list<string>> | |||
| */ | |||
| 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 | |||
| @@ -0,0 +1,6 @@ | |||
| <?php | |||
| return [ | |||
| 'views_path' => __DIR__ . '/../app/Views', | |||
| 'layout_path' => __DIR__ . '/../app/Views/layouts/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; | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| } | |||
| @@ -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(); | |||
| } | |||
| @@ -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'); | |||
| @@ -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 | |||
| @@ -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'] | |||
| ); | |||
| @@ -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; | |||
| } | |||
| @@ -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); | |||
| @@ -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); | |||
| @@ -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']) !== '') { | |||
| @@ -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(); | |||
Powered by TurnKey Linux.