| @@ -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 | ## Tech Stack | ||||
| - PHP 8.2+ | - PHP 8.2+ | ||||
| @@ -119,6 +130,37 @@ Run basic tests: | |||||
| php tests/run.php | 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 | ## 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 | ## Database Access Rules | ||||
| Use PDO or a well-maintained database abstraction layer/ORM. | Use PDO or a well-maintained database abstraction layer/ORM. | ||||
| @@ -52,23 +83,17 @@ Rules: | |||||
| ## Transactions | ## 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 | ```php | ||||
| $pdo->beginTransaction(); | |||||
| try { | |||||
| $database->transaction(function (Database $db) use ($order): void { | |||||
| $orders->create($order); | $orders->create($order); | ||||
| $auditLog->record('order.created', $order->id()); | $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 | ## 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 | ## Migration Rules | ||||
| - Keep migrations small and reversible when practical. | - Keep migrations small and reversible when practical. | ||||
| @@ -89,6 +161,9 @@ try { | |||||
| - Do not mix schema changes with unrelated feature logic. | - Do not mix schema changes with unrelated feature logic. | ||||
| - Use project migration conventions before inventing new ones. | - Use project migration conventions before inventing new ones. | ||||
| - Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines. | - 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 | ## Controller Rules | ||||
| - Keep controllers thin. | - 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 database query details directly in controllers when a repository or service is more appropriate. | ||||
| - Do not put template rendering logic inside business services. | - Do not put template rendering logic inside business services. | ||||
| - Return or produce a response through the framework’s response mechanism. | - 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 | ## Templates and Views | ||||
| Keep presentation separate from business logic. | 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 | ## HTTP and Web Application Rules | ||||
| Rules: | Rules: | ||||
| @@ -179,12 +284,16 @@ Rules: | |||||
| - Redirect after successful POST to avoid duplicate form submission. | - Redirect after successful POST to avoid duplicate form submission. | ||||
| - Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy. | - 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 | ```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 | ## 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: | Example: | ||||
| ```php | ```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. | - Validate server-side even when client-side validation exists. | ||||
| - Reject unexpected fields when appropriate. | - Reject unexpected fields when appropriate. | ||||
| - Normalize data intentionally, not accidentally. | - 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 | - Email changes | ||||
| - Permission 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 | ## Serialization and Data Exchange | ||||
| @@ -16,5 +16,4 @@ | |||||
| /database/*.sqlite-journal | /database/*.sqlite-journal | ||||
| .phpunit.result.cache | .phpunit.result.cache | ||||
| Thumbs.db | Thumbs.db | ||||
| docker-compose.yml | |||||
| Dockerfile | |||||
| @@ -118,7 +118,7 @@ For simple questions, answer directly. | |||||
| ## Framework Change Policy | ## 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 | ```text | ||||
| FRAMEWORK CHANGE PROPOSAL | FRAMEWORK CHANGE PROPOSAL | ||||
| @@ -145,6 +145,9 @@ Benefits: | |||||
| Alternatives Considered: | Alternatives Considered: | ||||
| Any application-level workarounds that were ruled out and why. | 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. | 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. | - 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. | - Keep framework changes small and focused — one concern per change. | ||||
| - After approval, note the change in the commit message so the history is clear. | - 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 | ## Skill Feedback Rule | ||||
| @@ -9,13 +9,15 @@ use App\Repositories\EmployeeRepository; | |||||
| use App\ViewModels\EmployeeFormViewModel; | use App\ViewModels\EmployeeFormViewModel; | ||||
| use Core\Controller; | use Core\Controller; | ||||
| use Core\Request; | use Core\Request; | ||||
| use Core\Response; | |||||
| use Core\Validator; | use Core\Validator; | ||||
| class EmployeeController extends Controller | 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 = $this->buildViewModel((string) $request->input('search', '')); | ||||
| $viewModel->saved = $request->input('saved') === '1'; | $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); | $form = $this->sanitizeFormData($request); | ||||
| $errors = $this->validateForm($form, $request); | |||||
| $errors = $this->validateForm($form); | |||||
| if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) { | if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) { | ||||
| $errors['email'][] = 'That email address is already in use.'; | $errors['email'][] = 'That email address is already in use.'; | ||||
| @@ -81,9 +86,8 @@ class EmployeeController extends Controller | |||||
| return $this->redirect('/employees'); | return $this->redirect('/employees'); | ||||
| } | } | ||||
| public function summary() | |||||
| public function summary(Request $request) | |||||
| { | { | ||||
| $request = Request::capture(); | |||||
| $viewModel = $this->buildViewModel((string) $request->input('search', '')); | $viewModel = $this->buildViewModel((string) $request->input('search', '')); | ||||
| return $this->fragment('employees.partials.summary', [ | 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', '')); | $search = trim((string) $request->input('search', '')); | ||||
| $rows = $this->employees()->search($search); | $rows = $this->employees()->search($search); | ||||
| @@ -136,7 +139,7 @@ class EmployeeController extends Controller | |||||
| * @param array<string, string> $form | * @param array<string, string> $form | ||||
| * @return array<string, list<string>> | * @return array<string, list<string>> | ||||
| */ | */ | ||||
| private function validateForm(array $form, Request $request): array | |||||
| private function validateForm(array $form): array | |||||
| { | { | ||||
| $validator = new Validator(); | $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.') | ->maxLength('last_name', $form['last_name'], 100, 'Last name must be 100 characters or fewer.') | ||||
| ->required('email', $form['email'], 'Email is required.') | ->required('email', $form['email'], 'Email is required.') | ||||
| ->maxLength('email', $form['email'], 255, 'Email must be 255 characters or fewer.') | ->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.') | ->required('department', $form['department'], 'Department is required.') | ||||
| ->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.') | ->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.') | ||||
| ->required('job_title', $form['job_title'], 'Job title is required.') | ->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.') | ->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 | 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 | 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); | $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 | protected function callMethod(array $handler, array $parameters): mixed | ||||
| @@ -52,6 +52,35 @@ class App | |||||
| $reflection = new ReflectionMethod($class, $method); | $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); | return Response::json($data); | ||||
| } | } | ||||
| protected function requirePost(Request $request): void | |||||
| protected function requirePost(Request $request): ?Response | |||||
| { | { | ||||
| if ($request->method() !== 'POST') { | 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); | 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 Router $router; | ||||
| protected App $app; | 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->router = $router; | ||||
| $this->app = $app; | $this->app = $app; | ||||
| $this->debug = $debug; | |||||
| } | } | ||||
| public function dispatch(Request $request): Response | public function dispatch(Request $request): Response | ||||
| @@ -32,7 +34,13 @@ class Dispatcher | |||||
| return $this->normalizeResponse($result); | return $this->normalizeResponse($result); | ||||
| } catch (Throwable $e) { | } 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 { | } finally { | ||||
| Request::clearCurrent(); | 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)' | '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( | $this->database->execute( | ||||
| 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' | '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 | public function status(): array | ||||
| @@ -101,22 +71,15 @@ class MigrationManager | |||||
| $migration = $this->loadMigration($file); | $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; | $ranMigrations[] = $name; | ||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| }); | |||||
| } | } | ||||
| return $ranMigrations; | return $ranMigrations; | ||||
| @@ -128,11 +91,8 @@ class MigrationManager | |||||
| $steps = max(1, $steps); | $steps = max(1, $steps); | ||||
| $applied = $this->database->query( | $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 = []; | $rolledBack = []; | ||||
| @@ -144,22 +104,12 @@ class MigrationManager | |||||
| } | } | ||||
| $migration = $this->loadMigration($file); | $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']; | $rolledBack[] = $row['migration']; | ||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| }); | |||||
| } | } | ||||
| return $rolledBack; | return $rolledBack; | ||||
| @@ -176,16 +126,10 @@ class MigrationManager | |||||
| $migration = $this->loadMigration($file); | $migration = $this->loadMigration($file); | ||||
| $name = basename($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; | $rolledBack[] = $name; | ||||
| } catch (\Throwable $exception) { | |||||
| $this->database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| }); | |||||
| } | } | ||||
| $this->database->execute('DELETE FROM migrations'); | $this->database->execute('DELETE FROM migrations'); | ||||
| @@ -43,7 +43,17 @@ class Request | |||||
| public function method(): string | 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 | public function path(): string | ||||
| @@ -20,7 +20,7 @@ class Response | |||||
| public static function json(array $data, int $status = 200): self | public static function json(array $data, int $status = 200): self | ||||
| { | { | ||||
| return new self( | return new self( | ||||
| json_encode($data, JSON_PRETTY_PRINT), | |||||
| json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), | |||||
| $status, | $status, | ||||
| ['Content-Type' => 'application/json'] | ['Content-Type' => 'application/json'] | ||||
| ); | ); | ||||
| @@ -10,12 +10,19 @@ class Route | |||||
| protected string $path; | protected string $path; | ||||
| protected mixed $handler; | protected mixed $handler; | ||||
| protected array $parameters = []; | protected array $parameters = []; | ||||
| protected string $compiledPattern; | |||||
| protected array $parameterNames = []; | |||||
| public function __construct(string $method, string $path, mixed $handler) | public function __construct(string $method, string $path, mixed $handler) | ||||
| { | { | ||||
| $this->method = strtoupper($method); | $this->method = strtoupper($method); | ||||
| $this->path = $path; | $this->path = $path; | ||||
| $this->handler = $handler; | $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 | public function matches(string $method, string $path): bool | ||||
| @@ -24,19 +31,14 @@ class Route | |||||
| return false; | 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; | return false; | ||||
| } | } | ||||
| array_shift($matches); | array_shift($matches); | ||||
| preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names); | |||||
| $this->parameters = []; | $this->parameters = []; | ||||
| foreach ($names[1] as $index => $name) { | |||||
| foreach ($this->parameterNames as $index => $name) { | |||||
| $this->parameters[$name] = $matches[$index] ?? null; | $this->parameters[$name] = $matches[$index] ?? null; | ||||
| } | } | ||||
| @@ -18,6 +18,21 @@ class Router | |||||
| return $this->add('POST', $path, $handler); | 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 | public function add(string $method, string $path, callable|array|string $handler): Route | ||||
| { | { | ||||
| $route = new Route($method, $path, $handler); | $route = new Route($method, $path, $handler); | ||||
| @@ -35,6 +35,68 @@ class Validator | |||||
| return $this; | 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 | public function passes(): bool | ||||
| { | { | ||||
| return empty($this->errors); | return empty($this->errors); | ||||
| @@ -9,7 +9,7 @@ class View | |||||
| public static function render(string $view, array $data = []): Response | public static function render(string $view, array $data = []): Response | ||||
| { | { | ||||
| $content = self::renderContent($view, $data); | $content = self::renderContent($view, $data); | ||||
| $layoutPath = __DIR__ . '/../app/Views/layouts/app.php'; | |||||
| $layoutPath = self::config()['layout_path']; | |||||
| if (!file_exists($layoutPath)) { | if (!file_exists($layoutPath)) { | ||||
| return new Response($content); | return new Response($content); | ||||
| @@ -33,7 +33,7 @@ class View | |||||
| private static function renderContent(string $view, array $data): string | 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)) { | if (!file_exists($path)) { | ||||
| throw new \Exception("View not found: {$view}"); | throw new \Exception("View not found: {$view}"); | ||||
| @@ -47,6 +47,17 @@ class View | |||||
| return (string) ob_get_clean(); | 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 | private static function resolvePageTitle(array $data): string | ||||
| { | { | ||||
| if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { | 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'; | require_once __DIR__ . '/../vendor/autoload.php'; | ||||
| use Core\App; | |||||
| use Core\Dispatcher; | use Core\Dispatcher; | ||||
| use Core\Request; | use Core\Request; | ||||
| use Core\Router; | use Core\Router; | ||||
| ensureSessionStarted(); | ensureSessionStarted(); | ||||
| $app = new App(); | |||||
| $app = app(); | |||||
| $router = new Router(); | $router = new Router(); | ||||
| require_once __DIR__ . '/../routes/web.php'; | 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(); | $request = Request::capture(); | ||||
| $app->bind(Request::class, $request); | |||||
| $response = $dispatcher->dispatch($request); | $response = $dispatcher->dispatch($request); | ||||
| $response->send(); | $response->send(); | ||||
Powered by TurnKey Linux.