Daniel Covington 1 тиждень тому
джерело
коміт
8acedba1d0
20 змінених файлів з 544 додано та 144 видалено
  1. +42
    -0
      .ai/SKILLS.md
  2. +86
    -11
      .ai/skills/database/SKILL.md
  3. +113
    -4
      .ai/skills/mvc/SKILL.md
  4. +69
    -4
      .ai/skills/security/SKILL.md
  5. +1
    -2
      .gitignore
  6. +5
    -2
      AGENTS.md
  7. +27
    -30
      app/Controllers/EmployeeController.php
  8. +6
    -0
      config/view.php
  9. +31
    -2
      core/App.php
  10. +4
    -2
      core/Controller.php
  11. +20
    -0
      core/Database.php
  12. +10
    -2
      core/Dispatcher.php
  13. +15
    -71
      core/MigrationManager.php
  14. +11
    -1
      core/Request.php
  15. +1
    -1
      core/Response.php
  16. +9
    -7
      core/Route.php
  17. +15
    -0
      core/Router.php
  18. +62
    -0
      core/Validator.php
  19. +13
    -2
      core/View.php
  20. +4
    -3
      public/index.php

+ 42
- 0
.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 <name>
```

Reset and re-run all migrations:

```bash
php scripts/migrate.php fresh
php scripts/migrate.php fresh --seed
```

---

## Request Flow


+ 86
- 11
.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 <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.

---



+ 113
- 4
.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
<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
}
```



+ 69
- 4
.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<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


+ 1
- 2
.gitignore Переглянути файл

@@ -16,5 +16,4 @@
/database/*.sqlite-journal
.phpunit.result.cache
Thumbs.db
docker-compose.yml
Dockerfile


+ 5
- 2
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


+ 27
- 30
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<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


+ 6
- 0
config/view.php Переглянути файл

@@ -0,0 +1,6 @@
<?php

return [
'views_path' => __DIR__ . '/../app/Views',
'layout_path' => __DIR__ . '/../app/Views/layouts/app.php',
];

+ 31
- 2
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;
}
}

+ 4
- 2
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;
}
}

+ 20
- 0
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;
}
}
}

+ 10
- 2
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();
}


+ 15
- 71
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');


+ 11
- 1
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


+ 1
- 1
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']
);


+ 9
- 7
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;
}



+ 15
- 0
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);


+ 62
- 0
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);


+ 13
- 2
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']) !== '') {


+ 4
- 3
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();

Завантаження…
Відмінити
Зберегти

Powered by TurnKey Linux.