Daniel Covington преди 3 дни
ревизия
5046c2af11
променени са 44 файла, в които са добавени 4100 реда и са изтрити 0 реда
  1. +20
    -0
      .gitignore
  2. +907
    -0
      AGENTS.md
  3. +208
    -0
      app/Controllers/EmployeeController.php
  4. +32
    -0
      app/Controllers/HomeController.php
  5. +16
    -0
      app/Models/Employee.php
  6. +12
    -0
      app/Models/User.php
  7. +122
    -0
      app/Repositories/EmployeeRepository.php
  8. +21
    -0
      app/Repositories/UserRepository.php
  9. +50
    -0
      app/ViewModels/EmployeeFormViewModel.php
  10. +13
    -0
      app/ViewModels/HomeIndexViewModel.php
  11. +54
    -0
      app/Views/employees/create.php
  12. +83
    -0
      app/Views/employees/partials/form.php
  13. +34
    -0
      app/Views/employees/partials/summary.php
  14. +39
    -0
      app/Views/home/index.php
  15. +14
    -0
      app/Views/layouts/app.php
  16. +9
    -0
      app/Views/partials/footer.php
  17. +48
    -0
      app/Views/partials/header.php
  18. +22
    -0
      composer.json
  19. +18
    -0
      composer.lock
  20. +11
    -0
      config/database.php
  21. +57
    -0
      core/App.php
  22. +35
    -0
      core/Controller.php
  23. +49
    -0
      core/Database.php
  24. +53
    -0
      core/Dispatcher.php
  25. +12
    -0
      core/Migration.php
  26. +289
    -0
      core/MigrationManager.php
  27. +38
    -0
      core/Repository.php
  28. +70
    -0
      core/Request.php
  29. +64
    -0
      core/Response.php
  30. +50
    -0
      core/Route.php
  31. +39
    -0
      core/Router.php
  32. +52
    -0
      core/Validator.php
  33. +68
    -0
      core/View.php
  34. +134
    -0
      core/helpers.php
  35. +30
    -0
      database/migrations/20260509_000001_create_employees_table.php
  36. +107
    -0
      database/seed_employees.php
  37. +72
    -0
      docs/README.md
  38. +105
    -0
      migrate.php
  39. +5
    -0
      public/.htaccess
  40. +791
    -0
      public/css/site.css
  41. +23
    -0
      public/index.php
  42. +65
    -0
      public/js/app.js
  43. +14
    -0
      routes/web.php
  44. +145
    -0
      tests/run.php

+ 20
- 0
.gitignore Целия файл

@@ -0,0 +1,20 @@
/vendor/
.env
.env.local
.env.*
*.log
.DS_Store
.idea/
.vscode/
/public/uploads/
/storage/cache/
/storage/logs/
/database/app.sqlite
/database/*.sqlite
/database/*.sqlite-shm
/database/*.sqlite-wal
/database/*.sqlite-journal
.phpunit.result.cache
Thumbs.db
docker-compose.yml
Dockerfile

+ 907
- 0
AGENTS.md Целия файл

@@ -0,0 +1,907 @@
# AGENT.md — PHP Coding Standard

This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from **PHP: The Right Way** and adapted into practical project instructions.

Source reference: https://phptherightway.com/

---

## 1. Core Philosophy

Write PHP that is:

- **Readable** before clever.
- **Secure by default**.
- **Consistent with community standards**.
- **Easy to test, debug, and refactor**.
- **Separated by responsibility**: routing, controllers, services, models, persistence, templates, and configuration should not be mixed together.

PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style.

---

## 2. PHP Version Standard

Use the current stable PHP version supported by the project.

Default expectation:

```text
PHP 8.x+
```

Do not introduce code that depends on unsupported PHP versions unless the project explicitly targets a legacy runtime.

When adding a language feature, verify that it is supported by the project’s configured PHP version in `composer.json`.

Example:

```json
{
"require": {
"php": ">=8.2"
}
}
```

---

## 3. Coding Style

Follow recognized PHP standards unless the repository already defines stricter rules.

Preferred standards:

- **PSR-1**: Basic Coding Standard
- **PSR-12**: Extended Coding Style
- **PSR-4**: Autoloading

Use automated tooling rather than manual formatting arguments.

Recommended tools:

```bash
composer require --dev squizlabs/php_codesniffer
composer require --dev friendsofphp/php-cs-fixer
```

Example checks:

```bash
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/php-cs-fixer fix --dry-run --diff
```

Example fix:

```bash
vendor/bin/php-cs-fixer fix
```

### Naming Rules

Use English names for code symbols and infrastructure.

Use:

```php
class InvoiceRepository
{
public function findByCustomerId(int $customerId): array
{
// ...
}
}
```

Avoid unclear abbreviations:

```php
class InvRepo
{
public function fbcid($cid)
{
// ...
}
}
```

### Formatting Rules

- Use `<?php` tags. Do not use short open tags.
- Use strict types at the top of new PHP files when practical:

```php
declare(strict_types=1);
```

- One class per file.
- Match namespaces to directory structure.
- Keep functions and methods small and focused.
- Prefer explicit visibility: `public`, `protected`, or `private`.
- Avoid global state unless required by the framework or legacy integration.

---

## 4. Project Structure

Prefer a predictable structure.

Example:

```text
project-root/
public/
index.php
src/
Controller/
Service/
Repository/
Entity/
ValueObject/
templates/
config/
tests/
var/
cache/
logs/
vendor/
composer.json
```

Rules:

- `public/` is the web root.
- Do not expose `src/`, `config/`, `tests/`, `vendor/`, or `.env` files through the web server.
- Put application code under `src/`.
- Put generated cache/log files under `var/` or another ignored runtime directory.
- Keep secrets outside the web root.

---

## 5. Namespaces and Autoloading

All new application classes must use namespaces.

Use PSR-4 autoloading through Composer.

Example `composer.json`:

```json
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
```

After changing autoload rules, run:

```bash
composer dump-autoload
```

Example class:

```php
<?php

declare(strict_types=1);

namespace App\Service;

final class InvoiceCalculator
{
public function calculateTotal(array $items): int
{
// Return cents, not floating-point dollars.
return array_sum(array_column($items, 'amountCents'));
}
}
```

---

## 6. Dependency Management

Use Composer for PHP dependencies.

Rules:

- Add packages with `composer require` or `composer require --dev`.
- Commit `composer.json` and `composer.lock` for applications.
- Do not manually copy vendor libraries into the project.
- Do not edit files under `vendor/`.
- Prefer maintained packages with clear documentation, tests, and recent releases.
- Remove unused packages.

Commands:

```bash
composer install
composer update vendor/package
composer audit
composer validate
```

Use `composer update` intentionally. Do not casually update every dependency in unrelated work.

---

## 7. Object-Oriented Design

Prefer clear object-oriented code for domain and application logic.

Use classes for cohesive behavior:

```php
final class CustomerName
{
public function __construct(private string $value)
{
if (trim($value) === '') {
throw new InvalidArgumentException('Customer name is required.');
}
}

public function value(): string
{
return $this->value;
}
}
```

Guidelines:

- Keep controllers thin.
- Put business rules in services or domain objects.
- Put persistence logic in repositories or data access classes.
- Use interfaces when multiple implementations are expected or when it improves testing.
- Avoid huge “utility” classes.
- Avoid magic methods unless they provide clear framework integration or a documented benefit.

---

## 8. Dependency Injection

Prefer dependency injection over creating dependencies inside classes.

Good:

```php
final class RegisterUser
{
public function __construct(
private UserRepository $users,
private PasswordHasher $passwords
) {
}

public function handle(string $email, string $plainPassword): void
{
$hash = $this->passwords->hash($plainPassword);
$this->users->create($email, $hash);
}
}
```

Avoid:

```php
final class RegisterUser
{
public function handle(string $email, string $plainPassword): void
{
$users = new UserRepository();
$passwords = new PasswordHasher();
// ...
}
}
```

Rules:

- Constructor injection is preferred for required dependencies.
- Do not use service locators casually.
- Do not hide dependencies in global variables.
- Keep dependency containers at application boundaries, not inside domain logic.

---

## 9. Database Access

Use PDO or a well-maintained database abstraction layer/ORM.

Never concatenate untrusted input into SQL.

Bad:

```php
$sql = "SELECT * FROM users WHERE id = " . $_GET['id'];
```

Good:

```php
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
```

Rules:

- Use prepared statements and bound parameters.
- Validate input before using it in writes.
- Keep SQL out of templates.
- Keep database access out of controllers where practical.
- Use transactions when multiple writes must succeed or fail together.
- Do not rely only on client-side validation.
- Do not expose raw database errors to users.

Transaction example:

```php
$pdo->beginTransaction();

try {
$orders->create($order);
$auditLog->record('order.created', $order->id());
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
```

---

## 10. Input Validation and Output Escaping

Treat all external data as untrusted.

Untrusted data includes:

- `$_GET`
- `$_POST`
- `$_REQUEST`
- `$_COOKIE`
- `$_SERVER`
- uploaded files
- request bodies
- session values
- database values originally supplied by users
- third-party API responses

### Validate on Input

Example:

```php
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);

if ($email === false || $email === null) {
throw new InvalidArgumentException('A valid email address is required.');
}
```

### Escape on Output

For HTML output:

```php
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
```

Usage:

```php
<p><?= e($user->name()) ?></p>
```

Rules:

- Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell.
- Do not use the same escaping function for every context.
- Prefer template engines with automatic escaping when appropriate.
- Avoid allowing raw HTML from users. If required, sanitize with a proven whitelist sanitizer.
- Use `escapeshellarg()` when passing controlled values to shell commands, and avoid shell execution when possible.
- Never trust file paths supplied by users. Reject path traversal values such as `../`, `/`, `\`, and null bytes.

---

## 11. Passwords and Authentication

Never store plain-text passwords.

Use PHP’s password API:

```php
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);

if (! password_verify($plainPassword, $hash)) {
throw new RuntimeException('Invalid credentials.');
}
```

Rules:

- Use `password_hash()` for new password hashes.
- Use `password_verify()` for login checks.
- Use `password_needs_rehash()` when algorithm/cost settings change.
- Do not create your own password hashing algorithm.
- Do not use general-purpose hashes like `md5`, `sha1`, or raw `sha256` for passwords.
- Rate-limit login attempts.
- Regenerate session IDs after login.
- Use secure, HTTP-only, SameSite cookies for sessions.

---

## 12. Serialization and Data Exchange

Do not call `unserialize()` on untrusted data.

Prefer JSON for data exchange:

```php
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
$json = json_encode($data, JSON_THROW_ON_ERROR);
```

Rules:

- Use `JSON_THROW_ON_ERROR` for new code.
- Validate decoded data before using it.
- Avoid PHP serialization for data that crosses trust boundaries.

---

## 13. Configuration and Secrets

Rules:

- Keep secrets out of source control.
- Do not commit passwords, API keys, private keys, tokens, or production DSNs.
- Store configuration outside the public web root.
- Use environment variables or ignored local config files for secrets.
- Provide a safe example file such as `.env.example`.

Example `.gitignore` entries:

```text
.env
.env.local
/config/local.php
/var/cache/
/var/log/
/vendor/
```

Example `.env.example`:

```text
APP_ENV=local
APP_DEBUG=true
DATABASE_URL=mysql://user:password@localhost:3306/app
```

---

## 14. Error Handling and Logging

Use exceptions for exceptional failure paths.

Development:

- Show errors locally.
- Log errors.
- Use Xdebug when debugging complex issues.

Production:

- Do not display errors to users.
- Log errors to a secure log destination.
- Return safe, generic error messages.
- Preserve enough context in logs for troubleshooting.

Do not leak:

- stack traces to users
- SQL statements with secrets
- environment variables
- full filesystem paths
- tokens or passwords

Example:

```php
try {
$service->handle($request);
} catch (Throwable $e) {
$logger->error('Order processing failed.', [
'exception' => $e,
'requestId' => $requestId,
]);

http_response_code(500);
echo 'An unexpected error occurred.';
}
```

---

## 15. Templates and Views

Keep presentation separate from business logic.

Rules:

- Do not query the database from templates.
- Do not place business rules in templates.
- Escape output by default.
- Prefer simple view models or arrays passed into templates.
- Use a template engine with automatic escaping when it fits the project.

Plain PHP template example:

```php
<h1><?= e($pageTitle) ?></h1>

<ul>
<?php foreach ($users as $user): ?>
<li><?= e($user->name()) ?></li>
<?php endforeach; ?>
</ul>
```

---

## 16. HTTP and Web Application Rules

Rules:

- Use the front controller pattern where appropriate.
- Keep routing separate from business logic.
- Validate request methods.
- Use CSRF protection for state-changing forms.
- Use proper HTTP status codes.
- Redirect after successful POST to avoid duplicate form submission.
- Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy.

Example POST guard:

```php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
```

---

## 17. Security Checklist

Before completing any feature, verify:

- [ ] All external input is validated.
- [ ] All output is escaped for the correct context.
- [ ] SQL uses prepared statements or safe query builders.
- [ ] Authentication and authorization are checked server-side.
- [ ] Secrets are not committed.
- [ ] Errors are not exposed in production responses.
- [ ] File uploads validate size, extension, MIME type, and storage path.
- [ ] Passwords use `password_hash()` and `password_verify()`.
- [ ] CSRF protection exists for state-changing requests.
- [ ] Dangerous functions are avoided or justified: `eval`, `exec`, `shell_exec`, `system`, `passthru`, `unserialize`.
- [ ] Dependencies have no known vulnerabilities according to `composer audit`.

---

## 18. Testing Standard

Automated tests are expected for new behavior.

Preferred tools:

- PHPUnit
- Pest, if the project already uses it

Rules:

- Add or update tests with every behavior change.
- Cover success paths and failure paths.
- Unit-test business logic.
- Integration-test database and framework wiring where useful.
- Functional-test important user flows.
- Avoid relying on `var_dump()` or manual browser testing as the only verification.

Example PHPUnit test:

```php
final class InvoiceCalculatorTest extends TestCase
{
public function testItCalculatesTotalInCents(): void
{
$calculator = new InvoiceCalculator();

$total = $calculator->calculateTotal([
['amountCents' => 1000],
['amountCents' => 2500],
]);

self::assertSame(3500, $total);
}
}
```

Run tests:

```bash
vendor/bin/phpunit
```

---

## 19. Static Analysis and Quality Gates

Use static analysis when available.

Recommended tools:

```bash
composer require --dev phpstan/phpstan
composer require --dev vimeo/psalm
```

Common quality commands:

```bash
composer validate
composer audit
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/phpunit
vendor/bin/phpstan analyse src tests
```

Do not ignore tool failures without documenting why.

---

## 20. Documentation

Use PHPDoc where it adds clarity, especially for arrays, generics-like structures, complex return values, and public APIs.

Good:

```php
/**
* @return list<Customer>
*/
public function findActiveCustomers(): array
{
// ...
}
```

Avoid noisy comments that repeat the code:

```php
// Increment i by one.
$i++;
```

Rules:

- Explain why, not just what.
- Document non-obvious tradeoffs.
- Keep README setup instructions current.
- Update examples when behavior changes.

---

## 21. Performance and Caching

Rules:

- Measure before optimizing.
- Avoid unnecessary database queries in loops.
- Use pagination for large result sets.
- Cache expensive reads where appropriate.
- Use OPcache in production.
- Do not cache user-specific sensitive data in shared caches without a clear key strategy.

---

## 22. Agent Workflow

When modifying this codebase, the AI agent must:

1. Inspect existing project conventions before adding new patterns.
2. Prefer small, focused changes.
3. Preserve public behavior unless explicitly asked to change it.
4. Add or update tests when behavior changes.
5. Run relevant checks when possible.
6. Explain any checks that could not be run.
7. Avoid introducing new dependencies unless they solve a clear problem.
8. Never place secrets in code, tests, fixtures, logs, or documentation.
9. Keep generated code consistent with this file.
10. Leave the repository better organized than it was found.

---

## 23. Pull Request / Review Checklist

Before considering work complete:

- [ ] Code follows PSR-12 or project-specific style.
- [ ] Namespaces and autoloading are correct.
- [ ] Composer files are valid.
- [ ] No unrelated dependency updates were introduced.
- [ ] New behavior is tested.
- [ ] Existing tests pass.
- [ ] SQL is parameterized.
- [ ] User input is validated.
- [ ] Output is escaped.
- [ ] No secrets are committed.
- [ ] Errors are handled safely.
- [ ] Documentation was updated where needed.

---

## 24. Legacy PHP Exception Policy

If this project contains legacy PHP:

- Do not rewrite large areas without approval.
- Add tests around legacy behavior before refactoring.
- Improve safety incrementally.
- Replace deprecated patterns as touched.
- Avoid mixing modernization with unrelated feature work.
- Document any compatibility constraints.

Legacy code should still move toward:

- Composer autoloading
- Namespaces
- PDO/prepared statements
- Centralized configuration
- Automated tests
- Safer error handling

---

## 25. Non-Negotiable Rules

The agent must not:

- Commit secrets.
- Build SQL using untrusted string concatenation.
- Store plain-text passwords.
- Use `md5`, `sha1`, or raw fast hashes for passwords.
- Display production errors to users.
- `unserialize()` untrusted data.
- Put database queries in templates.
- Edit files under `vendor/`.
- Add dependencies without a clear reason.
- Ignore failing tests or quality checks without explanation.

---

## 26. Recommended Composer Scripts

A project may include scripts like this:

```json
{
"scripts": {
"test": "phpunit",
"style": "phpcs --standard=PSR12 src tests",
"style:fix": "php-cs-fixer fix",
"analyse": "phpstan analyse src tests",
"quality": [
"@style",
"@test",
"@analyse"
]
}
}
```

Then run:

```bash
composer quality
```

---

## 27. Final Instruction to Coding Agents

When in doubt, choose the boring, obvious, secure PHP solution:

- Composer-managed dependencies
- PSR-style code
- Namespaced classes
- Dependency injection
- PDO prepared statements
- Escaped output
- Tested behavior
- Clear errors and logs
- No secrets in source control


## Project Overview

This project is a small PHP MVC framework called MindVisionCode PHP.

It is intentionally inspired by a Classic ASP MVC framework style:

- Central dispatcher
- Controllers and actions
- ViewModels
- Repository classes
- Simple validation
- Database migrations
- Small, readable files
- Minimal dependencies

Do not turn this into Laravel, Symfony, Slim, or another large framework.

## Tech Stack

- PHP 8.2+
- Composer
- PSR-4 autoloading
- PDO
- PHP views
- Optional SQLite/MySQL/SQL Server through PDO

## Development Commands

Install dependencies:

```bash
composer install
```

Regenerate autoload files:

```bash
composer dump-autoload
```

Run local server:

```bash
php -S localhost:8000 -t public
```

Run basic tests:

```bash
php tests/run.php
```

## Coding Rules

- Keep code simple and readable.
- Prefer small classes.
- Use typed properties and return types where practical.
- Avoid hidden magic.
- Do not add dependencies without a clear reason.
- Preserve the framework style.
- Explain any architectural changes.

## Request Flow

Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response

+ 208
- 0
app/Controllers/EmployeeController.php Целия файл

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

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Employee;
use App\Repositories\EmployeeRepository;
use App\ViewModels\EmployeeFormViewModel;
use Core\Controller;
use Core\Request;
use Core\Validator;

class EmployeeController extends Controller
{
public function index()
{
$request = Request::capture();
$viewModel = $this->buildViewModel((string) $request->input('search', ''));
$viewModel->saved = $request->input('saved') === '1';

return $this->view('employees.create', [
'model' => $viewModel,
'pageTitle' => $viewModel->title,
]);
}

public function store()
{
$request = Request::capture();
$form = $this->sanitizeFormData($request);
$errors = $this->validateForm($form, $request);

if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) {
$errors['email'][] = 'That email address is already in use.';
}

if (!empty($errors)) {
$viewModel = $this->buildViewModel();
$viewModel->form = $form;
$viewModel->errors = $errors;

if ($this->isHtmxRequest($request)) {
return $this->fragment('employees.partials.form', [
'model' => $viewModel,
]);
}

return $this->view('employees.create', [
'model' => $viewModel,
'pageTitle' => $viewModel->title,
]);
}

$employee = new Employee();
$employee->firstName = $form['first_name'];
$employee->lastName = $form['last_name'];
$employee->email = $form['email'];
$employee->department = $form['department'];
$employee->jobTitle = $form['job_title'];
$employee->startDate = $form['start_date'];

$this->employees()->create($employee);

if ($this->isHtmxRequest($request)) {
$viewModel = $this->buildViewModel();
$viewModel->saved = true;

return $this->fragment('employees.partials.form', [
'model' => $viewModel,
], 200, [
'HX-Trigger' => json_encode(['employees-changed' => true]),
]);
}

return $this->redirect('/employees?saved=1');
}

public function create()
{
return $this->redirect('/employees');
}

public function summary()
{
$request = Request::capture();
$viewModel = $this->buildViewModel((string) $request->input('search', ''));

return $this->fragment('employees.partials.summary', [
'model' => $viewModel,
]);
}

public function data()
{
$request = Request::capture();
$search = trim((string) $request->input('search', ''));
$rows = $this->employees()->search($search);

$data = array_map(
static function (array $row): array {
return [
'id' => (int) $row['id'],
'full_name' => trim($row['first_name'] . ' ' . $row['last_name']),
'first_name' => (string) $row['first_name'],
'last_name' => (string) $row['last_name'],
'email' => (string) $row['email'],
'department' => (string) $row['department'],
'job_title' => (string) $row['job_title'],
'start_date' => (string) $row['start_date'],
'created_at' => (string) $row['created_at'],
];
},
$rows
);

return $this->json($data);
}

/**
* @return array<string, string>
*/
private function sanitizeFormData(Request $request): array
{
return [
'first_name' => trim((string) $request->input('first_name', '')),
'last_name' => trim((string) $request->input('last_name', '')),
'email' => trim((string) $request->input('email', '')),
'department' => trim((string) $request->input('department', '')),
'job_title' => trim((string) $request->input('job_title', '')),
'start_date' => trim((string) $request->input('start_date', '')),
];
}

/**
* @param array<string, string> $form
* @return array<string, list<string>>
*/
private function validateForm(array $form, Request $request): array
{
$validator = new Validator();

$validator
->required('first_name', $form['first_name'], 'First name is required.')
->maxLength('first_name', $form['first_name'], 100, 'First name must be 100 characters or fewer.')
->required('last_name', $form['last_name'], 'Last name is required.')
->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.')
->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();

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

private function isValidDate(string $value): bool
{
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);

return $date !== false && $date->format('Y-m-d') === $value;
}

private function employees(): EmployeeRepository
{
return new EmployeeRepository(database());
}

private function isHtmxRequest(Request $request): bool
{
return strtolower((string) $request->server('HTTP_HX_REQUEST', '')) === 'true';
}

private function buildViewModel(string $search = ''): EmployeeFormViewModel
{
$viewModel = new EmployeeFormViewModel();
$viewModel->search = trim($search);

$employees = $this->employees()->search($viewModel->search);
$newestEmployee = $this->employees()->newestMatching($viewModel->search);

$viewModel->employees = array_slice($employees, 0, 5);
$viewModel->newestEmployee = $newestEmployee;
$viewModel->summary = [
'employee_count' => $this->employees()->countMatching($viewModel->search),
'department_count' => $this->employees()->countDepartments($viewModel->search),
'latest_start_date' => $newestEmployee['start_date'] ?? 'N/A',
];

return $viewModel;
}
}

+ 32
- 0
app/Controllers/HomeController.php Целия файл

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

declare(strict_types=1);

namespace App\Controllers;

use App\ViewModels\HomeIndexViewModel;
use Core\Controller;

class HomeController extends Controller
{
public function index()
{
$model = new HomeIndexViewModel();
$model->title = 'MindVisionCode PHP';
$model->eyebrow = 'Small MVC framework';
$model->message = 'A lightweight PHP MVC starter with a central dispatcher, clean controllers, SQLite-backed repositories, and readable conventions.';
$model->routeExample = '/employees';

return $this->view('home.index', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

public function user(string $id)
{
return $this->json([
'userId' => $id,
]);
}
}

+ 16
- 0
app/Models/Employee.php Целия файл

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

declare(strict_types=1);

namespace App\Models;

class Employee
{
public ?int $id = null;
public string $firstName = '';
public string $lastName = '';
public string $email = '';
public string $department = '';
public string $jobTitle = '';
public string $startDate = '';
}

+ 12
- 0
app/Models/User.php Целия файл

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

declare(strict_types=1);

namespace App\Models;

class User
{
public int|string|null $id = null;
public string $name = '';
public string $email = '';
}

+ 122
- 0
app/Repositories/EmployeeRepository.php Целия файл

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

declare(strict_types=1);

namespace App\Repositories;

use App\Models\Employee;
use Core\Repository;

class EmployeeRepository extends Repository
{
protected string $table = 'employees';
protected string $primaryKey = 'id';

public function create(Employee $employee): bool
{
return $this->database->execute(
'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date)
VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)',
[
'first_name' => $employee->firstName,
'last_name' => $employee->lastName,
'email' => $employee->email,
'department' => $employee->department,
'job_title' => $employee->jobTitle,
'start_date' => $employee->startDate,
]
);
}

public function findByEmail(string $email): ?array
{
return $this->database->first(
'SELECT * FROM employees WHERE email = :email',
['email' => $email]
);
}

/**
* @return list<array<string, mixed>>
*/
public function latest(int $limit = 8): array
{
$limit = max(1, $limit);

return $this->database->query(
"SELECT * FROM employees ORDER BY created_at DESC, id DESC LIMIT {$limit}"
);
}

/**
* @return list<array<string, mixed>>
*/
public function search(string $search = ''): array
{
[$whereClause, $parameters] = $this->buildSearchClause($search);

return $this->database->query(
'SELECT id, first_name, last_name, email, department, job_title, start_date, created_at
FROM employees' . $whereClause . '
ORDER BY created_at DESC, id DESC',
$parameters
);
}

public function countMatching(string $search = ''): int
{
[$whereClause, $parameters] = $this->buildSearchClause($search);
$row = $this->database->first(
'SELECT COUNT(*) AS total FROM employees' . $whereClause,
$parameters
);

return (int) ($row['total'] ?? 0);
}

public function countDepartments(string $search = ''): int
{
[$whereClause, $parameters] = $this->buildSearchClause($search);
$row = $this->database->first(
'SELECT COUNT(DISTINCT department) AS total FROM employees' . $whereClause,
$parameters
);

return (int) ($row['total'] ?? 0);
}

public function newestMatching(string $search = ''): ?array
{
[$whereClause, $parameters] = $this->buildSearchClause($search);

return $this->database->first(
'SELECT id, first_name, last_name, department, job_title, start_date
FROM employees' . $whereClause . '
ORDER BY created_at DESC, id DESC
LIMIT 1',
$parameters
);
}

/**
* @return array{0:string,1:array<string,string>}
*/
private function buildSearchClause(string $search): array
{
$search = trim($search);

if ($search === '') {
return ['', []];
}

return [
' WHERE first_name LIKE :search
OR last_name LIKE :search
OR email LIKE :search
OR department LIKE :search
OR job_title LIKE :search
OR start_date LIKE :search',
['search' => '%' . $search . '%'],
];
}
}

+ 21
- 0
app/Repositories/UserRepository.php Целия файл

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

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

class UserRepository extends Repository
{
protected string $table = 'users';
protected string $primaryKey = 'id';

public function findByEmail(string $email): ?array
{
return $this->database->first(
'SELECT * FROM users WHERE email = :email',
['email' => $email]
);
}
}

+ 50
- 0
app/ViewModels/EmployeeFormViewModel.php Целия файл

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

declare(strict_types=1);

namespace App\ViewModels;

class EmployeeFormViewModel
{
public string $title = 'Employee Directory';
public string $eyebrow = 'SQLite employee form';
public string $intro = 'Capture employee details in a lightweight SQLite-backed page that fits the framework style.';
public bool $saved = false;
public string $search = '';

/**
* @var array<string, string>
*/
public array $form = [
'first_name' => '',
'last_name' => '',
'email' => '',
'department' => '',
'job_title' => '',
'start_date' => '',
];

/**
* @var array<string, list<string>>
*/
public array $errors = [];

/**
* @var list<array<string, mixed>>
*/
public array $employees = [];

/**
* @var array<string, int|string>
*/
public array $summary = [
'employee_count' => 0,
'department_count' => 0,
'latest_start_date' => 'N/A',
];

/**
* @var array<string, mixed>|null
*/
public ?array $newestEmployee = null;
}

+ 13
- 0
app/ViewModels/HomeIndexViewModel.php Целия файл

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

declare(strict_types=1);

namespace App\ViewModels;

class HomeIndexViewModel
{
public string $title = '';
public string $eyebrow = '';
public string $message = '';
public string $routeExample = '';
}

+ 54
- 0
app/Views/employees/create.php Целия файл

@@ -0,0 +1,54 @@
<section class="content-stack" x-data="employeeDirectory()">
<div class="section-heading">
<span class="eyebrow"><?= e($model->eyebrow) ?></span>
<h1><?= e($model->title) ?></h1>
<p><?= e($model->intro) ?></p>
</div>

<section class="section-panel controls-panel">
<div class="panel-header controls-header">
<div>
<h2>Employee Workspace</h2>
<p>Use htmx for server updates, Alpine for page state, and Tabulator for a richer table experience.</p>
</div>

<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh Table</button>
</div>

<div class="search-row">
<label class="field field-full">
<span>Search employees</span>
<input
id="employee-search"
class="input"
type="search"
name="search"
placeholder="Search by name, email, department, title, or date"
x-model.debounce.300ms="search"
x-on:input.debounce.300ms="applySearch()"
value="<?= e($model->search) ?>"
>
</label>
</div>
</section>

<div class="employee-layout">
<div id="employee-form-panel">
<?php require __DIR__ . '/partials/form.php'; ?>
</div>

<section class="section-panel table-shell directory-panel">
<div class="panel-header">
<h2>Employee Directory Table</h2>
<p>Browse, search, and sort all employees in one place. The table refreshes after new employee records are saved.</p>
</div>

<div class="table-toolbar">
<span class="table-pill">HTMX + Alpine + Tabulator</span>
<span class="table-caption">Live data endpoint: <code>/employees/data</code></span>
</div>

<div id="employee-table" class="tabulator-host"></div>
</section>
</div>
</section>

+ 83
- 0
app/Views/employees/partials/form.php Целия файл

@@ -0,0 +1,83 @@
<section class="section-panel">
<div class="panel-header">
<h2>Add Employee</h2>
<p>Store a clean employee record with basic contact and role details.</p>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Employee information was saved to SQLite successfully.
</div>
<?php endif; ?>

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form
method="post"
action="/employees"
class="employee-form"
novalidate
hx-post="/employees"
hx-target="#employee-form-panel"
hx-swap="outerHTML"
>
<?= csrf_field() ?>

<div class="form-grid">
<label class="field">
<span>First name</span>
<input class="input" type="text" name="first_name" maxlength="100" value="<?= e($model->form['first_name']) ?>" required>
<?php if (isset($model->errors['first_name'])): ?>
<small class="field-error"><?= e($model->errors['first_name'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Last name</span>
<input class="input" type="text" name="last_name" maxlength="100" value="<?= e($model->form['last_name']) ?>" required>
<?php if (isset($model->errors['last_name'])): ?>
<small class="field-error"><?= e($model->errors['last_name'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Email</span>
<input class="input" type="email" name="email" maxlength="255" value="<?= e($model->form['email']) ?>" required>
<?php if (isset($model->errors['email'])): ?>
<small class="field-error"><?= e($model->errors['email'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Department</span>
<input class="input" type="text" name="department" maxlength="100" value="<?= e($model->form['department']) ?>" required>
<?php if (isset($model->errors['department'])): ?>
<small class="field-error"><?= e($model->errors['department'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Job title</span>
<input class="input" type="text" name="job_title" maxlength="150" value="<?= e($model->form['job_title']) ?>" required>
<?php if (isset($model->errors['job_title'])): ?>
<small class="field-error"><?= e($model->errors['job_title'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Start date</span>
<input class="input" type="date" name="start_date" value="<?= e($model->form['start_date']) ?>" required>
<?php if (isset($model->errors['start_date'])): ?>
<small class="field-error"><?= e($model->errors['start_date'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Save Employee</button>
<span class="inline-indicator htmx-indicator">Saving employee...</span>
</div>
</form>
</section>

+ 34
- 0
app/Views/employees/partials/summary.php Целия файл

@@ -0,0 +1,34 @@
<div class="panel-header">
<h2>Live Summary</h2>
<p>Server-rendered fragments refresh here with htmx whenever employees change or search terms update.</p>
</div>

<div class="stats-grid">
<article class="stat-card">
<span>Total Employees</span>
<strong><?= e((string) $model->summary['employee_count']) ?></strong>
</article>

<article class="stat-card">
<span>Departments</span>
<strong><?= e((string) $model->summary['department_count']) ?></strong>
</article>

<article class="stat-card">
<span>Latest Start Date</span>
<strong><?= e((string) $model->summary['latest_start_date']) ?></strong>
</article>
</div>

<?php if ($model->newestEmployee !== null): ?>
<div class="summary-feature">
<span class="summary-label">Newest matching record</span>
<h3><?= e($model->newestEmployee['first_name'] . ' ' . $model->newestEmployee['last_name']) ?></h3>
<p><?= e((string) $model->newestEmployee['job_title']) ?> in <?= e((string) $model->newestEmployee['department']) ?></p>
</div>
<?php else: ?>
<div class="empty-state">
<p>No matching employees yet.</p>
<p>Try a broader search or add a new employee record.</p>
</div>
<?php endif; ?>

+ 39
- 0
app/Views/home/index.php Целия файл

@@ -0,0 +1,39 @@
<section class="hero">
<div class="hero-copy">
<span class="eyebrow"><?= e($model->eyebrow) ?></span>
<h1><?= e($model->title) ?></h1>
<p class="hero-text"><?= e($model->message) ?></p>

<div class="hero-actions">
<a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Employee Form</a>
<a class="button button-secondary" href="#framework-highlights">See Highlights</a>
</div>
</div>

<aside class="hero-panel" aria-label="Framework route example">
<p class="panel-label">Request Flow</p>
<code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code>

<div class="route-callout">
<span>Employee entry page</span>
<a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a>
</div>
</aside>
</section>

<section class="feature-grid" id="framework-highlights">
<article class="feature-card">
<h2>Readable by design</h2>
<p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p>
</article>

<article class="feature-card">
<h2>Classic MVC feel</h2>
<p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p>
</article>

<article class="feature-card">
<h2>SQLite ready</h2>
<p>Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.</p>
</article>
</section>

+ 14
- 0
app/Views/layouts/app.php Целия файл

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

declare(strict_types=1);

require __DIR__ . '/../partials/header.php';
?>

<main class="page-content">
<div class="container">
<?= $content ?>
</div>
</main>

<?php require __DIR__ . '/../partials/footer.php'; ?>

+ 9
- 0
app/Views/partials/footer.php Целия файл

@@ -0,0 +1,9 @@
<footer class="site-footer">
<div class="container footer-inner">
<p>MindVisionCode PHP keeps the framework small, readable, and ready for real features.</p>
<p>&copy; <?= e((string) date('Y')) ?> MindVisionCode</p>
</div>
</footer>
</div>
</body>
</html>

+ 48
- 0
app/Views/partials/header.php Целия файл

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

declare(strict_types=1);

$navigationItems = [
['label' => 'Home', 'href' => '/'],
['label' => 'Employees', 'href' => '/employees'],
['label' => 'Example JSON', 'href' => '/users/123'],
];

$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle ?? 'MindVisionCode PHP') ?></title>
<link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css">
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>">
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous" defer></script>
<script src="https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js" defer></script>
<script src="<?= e(asset('js/app.js')) ?>" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div class="page-shell">
<header class="site-header">
<div class="container header-inner">
<a class="brand" href="/">
<span class="brand-mark">MV</span>
<span class="brand-copy">
<strong>MindVisionCode</strong>
<small>PHP MVC</small>
</span>
</a>

<nav class="site-nav" aria-label="Primary navigation">
<?php foreach ($navigationItems as $item): ?>
<?php $isActive = $currentPath === $item['href']; ?>
<a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>">
<?= e($item['label']) ?>
</a>
<?php endforeach; ?>
</nav>
</div>
</header>

+ 22
- 0
composer.json Целия файл

@@ -0,0 +1,22 @@
{
"name": "kci/mindvisioncode",
"description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.",
"type": "project",
"autoload": {
"psr-4": {
"App\\": "app/",
"Core\\": "core/"
},
"files": [
"core/helpers.php"
]
},
"scripts": {
"migrate": "php migrate.php up",
"migrate:down": "php migrate.php down",
"migrate:status": "php migrate.php status",
"migrate:fresh": "php migrate.php fresh",
"migrate:fresh-seed": "php migrate.php fresh --seed"
},
"require": {}
}

+ 18
- 0
composer.lock Целия файл

@@ -0,0 +1,18 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a9e5ff0daf78f24b652c32b38b47d81b",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

+ 11
- 0
config/database.php Целия файл

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

return [
'dsn' => 'sqlite:' . __DIR__ . '/../database/app.sqlite',
'username' => null,
'password' => null,
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
];

+ 57
- 0
core/App.php Целия файл

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

declare(strict_types=1);

namespace Core;

use Exception;
use ReflectionFunction;
use ReflectionMethod;

class App
{
protected array $bindings = [];

public function bind(string $name, mixed $value): void
{
$this->bindings[$name] = $value;
}

public function get(string $name): mixed
{
return $this->bindings[$name] ?? null;
}

public function call(callable|array|string $handler, array $parameters = []): mixed
{
if (is_array($handler)) {
return $this->callMethod($handler, $parameters);
}

if (is_callable($handler)) {
return $this->callFunction($handler, $parameters);
}

throw new Exception('Invalid handler.');
}

protected function callFunction(callable $handler, array $parameters): mixed
{
$reflection = new ReflectionFunction($handler);

return $reflection->invokeArgs(array_values($parameters));
}

protected function callMethod(array $handler, array $parameters): mixed
{
[$class, $method] = $handler;

if (is_string($class)) {
$class = new $class();
}

$reflection = new ReflectionMethod($class, $method);

return $reflection->invokeArgs($class, array_values($parameters));
}
}

+ 35
- 0
core/Controller.php Целия файл

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

declare(strict_types=1);

namespace Core;

abstract class Controller
{
protected function view(string $view, array $data = []): Response
{
return View::render($view, $data);
}

protected function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response
{
return View::fragment($view, $data, $status, $headers);
}

protected function redirect(string $url): Response
{
return Response::redirect($url);
}

protected function json(array $data): Response
{
return Response::json($data);
}

protected function requirePost(Request $request): void
{
if ($request->method() !== 'POST') {
throw new \Exception('This action requires POST.');
}
}
}

+ 49
- 0
core/Database.php Целия файл

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

declare(strict_types=1);

namespace Core;

use PDO;

class Database
{
protected PDO $pdo;

public function __construct(array $config)
{
$this->pdo = new PDO(
$config['dsn'],
$config['username'] ?? null,
$config['password'] ?? null,
$config['options'] ?? []
);
}

public function pdo(): PDO
{
return $this->pdo;
}

public function query(string $sql, array $parameters = []): array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);

return $statement->fetchAll(PDO::FETCH_ASSOC);
}

public function first(string $sql, array $parameters = []): ?array
{
$rows = $this->query($sql, $parameters);

return $rows[0] ?? null;
}

public function execute(string $sql, array $parameters = []): bool
{
$statement = $this->pdo->prepare($sql);

return $statement->execute($parameters);
}
}

+ 53
- 0
core/Dispatcher.php Целия файл

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

declare(strict_types=1);

namespace Core;

use Throwable;

class Dispatcher
{
protected Router $router;
protected App $app;

public function __construct(Router $router, App $app)
{
$this->router = $router;
$this->app = $app;
}

public function dispatch(Request $request): Response
{
Request::setCurrent($request);

try {
$route = $this->router->match($request->method(), $request->path());

if (!$route) {
return Response::notFound('Page not found.');
}

$result = $route->dispatch($this->app);

return $this->normalizeResponse($result);
} catch (Throwable $e) {
return Response::serverError($e->getMessage());
} finally {
Request::clearCurrent();
}
}

protected function normalizeResponse(mixed $result): Response
{
if ($result instanceof Response) {
return $result;
}

if (is_array($result)) {
return Response::json($result);
}

return new Response((string) $result);
}
}

+ 12
- 0
core/Migration.php Целия файл

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

declare(strict_types=1);

namespace Core;

abstract class Migration
{
abstract public function up(Database $database): void;

abstract public function down(Database $database): void;
}

+ 289
- 0
core/MigrationManager.php Целия файл

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

declare(strict_types=1);

namespace Core;

class MigrationManager
{
protected Database $database;
protected string $path;

public function __construct(Database $database, string $path)
{
$this->database = $database;
$this->path = rtrim($path, '/');
}

public function ensureTable(): void
{
$this->database->execute(
'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)'
);

$this->database->execute(
'DELETE FROM migrations
WHERE id NOT IN (
SELECT MIN(id)
FROM migrations
GROUP BY migration
)'
);

$this->database->execute(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)'
);

$files = array_map('basename', $this->migrationFiles());

if ($files === []) {
$this->database->execute('DELETE FROM migrations');
return;
}

$placeholders = [];
$parameters = [];

foreach ($files as $index => $file) {
$placeholder = 'migration_' . $index;
$placeholders[] = ':' . $placeholder;
$parameters[$placeholder] = $file;
}

$this->database->execute(
'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')',
$parameters
);
}

public function status(): array
{
$this->ensureTable();

$ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC');
$ranByName = [];

foreach ($ran as $row) {
$ranByName[$row['migration']] = $row['ran_at'];
}

$files = $this->migrationFiles();

$status = [];

foreach ($files as $file) {
$name = basename($file);
$status[] = [
'migration' => $name,
'ran' => array_key_exists($name, $ranByName),
'ran_at' => $ranByName[$name] ?? null,
];
}

return $status;
}

public function runPending(): array
{
$this->ensureTable();

$ran = $this->database->query('SELECT migration FROM migrations');
$ranNames = array_column($ran, 'migration');
$files = $this->migrationFiles();
$ranMigrations = [];

foreach ($files as $file) {
$name = basename($file);

if (in_array($name, $ranNames, true)) {
continue;
}

$migration = $this->loadMigration($file);

$this->database->pdo()->beginTransaction();

try {
$migration->up($this->database);

$this->database->execute(
'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)',
['migration' => $name]
);

$this->database->pdo()->commit();
$ranMigrations[] = $name;
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
}

return $ranMigrations;
}

public function rollback(int $steps = 1): array
{
$this->ensureTable();

$steps = max(1, $steps);
$applied = $this->database->query(
"SELECT MAX(id) AS id, migration
FROM migrations
GROUP BY migration
ORDER BY id DESC
LIMIT {$steps}"
);
$rolledBack = [];

foreach ($applied as $row) {
$file = $this->path . '/' . $row['migration'];

if (!file_exists($file)) {
throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}");
}

$migration = $this->loadMigration($file);
$this->database->pdo()->beginTransaction();

try {
$migration->down($this->database);

$this->database->execute(
'DELETE FROM migrations WHERE id = :id',
['id' => $row['id']]
);

$this->database->pdo()->commit();
$rolledBack[] = $row['migration'];
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
}

return $rolledBack;
}

public function fresh(): array
{
$this->ensureTable();

$files = array_reverse($this->migrationFiles());
$rolledBack = [];

foreach ($files as $file) {
$migration = $this->loadMigration($file);
$name = basename($file);

$this->database->pdo()->beginTransaction();

try {
$migration->down($this->database);
$this->database->pdo()->commit();
$rolledBack[] = $name;
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
}

$this->database->execute('DELETE FROM migrations');
$ran = $this->runPending();

return [
'rolled_back' => $rolledBack,
'migrated' => $ran,
];
}

public function make(string $name): string
{
$slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_');

if ($slug === '') {
throw new \InvalidArgumentException('Migration name must contain letters or numbers.');
}

if (!is_dir($this->path)) {
mkdir($this->path, 0777, true);
}

$timestamp = date('Ymd_His');
$filename = $timestamp . '_' . $slug . '.php';
$path = $this->path . '/' . $filename;

if (file_exists($path)) {
throw new \RuntimeException("Migration already exists: {$filename}");
}

$template = <<<PHP
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database \$database): void
{
// Write the forward migration here.
}

public function down(Database \$database): void
{
// Write the rollback migration here.
}
};
PHP;

file_put_contents($path, $template . PHP_EOL);

return $path;
}

private function migrationFiles(): array
{
$files = glob($this->path . '/*.php') ?: [];
sort($files);

return $files;
}

private function loadMigration(string $file): Migration
{
$migration = require $file;

if ($migration instanceof Migration) {
return $migration;
}

if (is_callable($migration)) {
return new class ($migration, basename($file)) extends Migration
{
private $callback;
private string $name;

public function __construct(callable $callback, string $name)
{
$this->callback = $callback;
$this->name = $name;
}

public function up(Database $database): void
{
($this->callback)($database);
}

public function down(Database $database): void
{
throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method.");
}
};
}

throw new \RuntimeException('Migration files must return a Migration instance.');
}
}

+ 38
- 0
core/Repository.php Целия файл

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

declare(strict_types=1);

namespace Core;

abstract class Repository
{
protected Database $database;
protected string $table;
protected string $primaryKey = 'id';

public function __construct(Database $database)
{
$this->database = $database;
}

public function find(int|string $id): ?array
{
return $this->database->first(
"SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id",
['id' => $id]
);
}

public function all(): array
{
return $this->database->query("SELECT * FROM {$this->table}");
}

public function delete(int|string $id): bool
{
return $this->database->execute(
"DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id",
['id' => $id]
);
}
}

+ 70
- 0
core/Request.php Целия файл

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

declare(strict_types=1);

namespace Core;

class Request
{
protected static ?self $current = null;
protected string $method;
protected string $uri;
protected array $get;
protected array $post;
protected array $server;

public function __construct(array $get, array $post, array $server)
{
$this->get = $get;
$this->post = $post;
$this->server = $server;
$this->method = $server['REQUEST_METHOD'] ?? 'GET';
$this->uri = $server['REQUEST_URI'] ?? '/';
}

public static function capture(): self
{
if (self::$current instanceof self) {
return self::$current;
}

return new self($_GET, $_POST, $_SERVER);
}

public static function setCurrent(self $request): void
{
self::$current = $request;
}

public static function clearCurrent(): void
{
self::$current = null;
}

public function method(): string
{
return strtoupper($this->method);
}

public function path(): string
{
$path = parse_url($this->uri, PHP_URL_PATH);

return $path ?: '/';
}

public function input(string $key, mixed $default = null): mixed
{
return $this->post[$key] ?? $this->get[$key] ?? $default;
}

public function server(string $key, mixed $default = null): mixed
{
return $this->server[$key] ?? $default;
}

public function all(): array
{
return array_merge($this->get, $this->post);
}
}

+ 64
- 0
core/Response.php Целия файл

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

declare(strict_types=1);

namespace Core;

class Response
{
protected string $content;
protected int $status;
protected array $headers;

public function __construct(string $content = '', int $status = 200, array $headers = [])
{
$this->content = $content;
$this->status = $status;
$this->headers = $headers;
}

public static function json(array $data, int $status = 200): self
{
return new self(
json_encode($data, JSON_PRETTY_PRINT),
$status,
['Content-Type' => 'application/json']
);
}

public static function redirect(string $url): self
{
return new self('', 302, ['Location' => $url]);
}

public static function notFound(string $message = 'Not found'): self
{
return new self($message, 404);
}

public static function serverError(string $message = 'Server error'): self
{
return new self($message, 500);
}

public function send(): void
{
http_response_code($this->status);

foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}

echo $this->content;
}

public function content(): string
{
return $this->content;
}

public function status(): int
{
return $this->status;
}
}

+ 50
- 0
core/Route.php Целия файл

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

declare(strict_types=1);

namespace Core;

class Route
{
protected string $method;
protected string $path;
protected mixed $handler;
protected array $parameters = [];

public function __construct(string $method, string $path, mixed $handler)
{
$this->method = strtoupper($method);
$this->path = $path;
$this->handler = $handler;
}

public function matches(string $method, string $path): bool
{
if (strtoupper($method) !== $this->method) {
return false;
}

$routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path);
$routePattern = '#^' . $routePattern . '$#';

if (!preg_match($routePattern, $path, $matches)) {
return false;
}

array_shift($matches);
preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names);

$this->parameters = [];

foreach ($names[1] as $index => $name) {
$this->parameters[$name] = $matches[$index] ?? null;
}

return true;
}

public function dispatch(App $app): mixed
{
return $app->call($this->handler, $this->parameters);
}
}

+ 39
- 0
core/Router.php Целия файл

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

declare(strict_types=1);

namespace Core;

class Router
{
protected array $routes = [];

public function get(string $path, callable|array|string $handler): Route
{
return $this->add('GET', $path, $handler);
}

public function post(string $path, callable|array|string $handler): Route
{
return $this->add('POST', $path, $handler);
}

public function add(string $method, string $path, callable|array|string $handler): Route
{
$route = new Route($method, $path, $handler);
$this->routes[] = $route;

return $route;
}

public function match(string $method, string $path): ?Route
{
foreach ($this->routes as $route) {
if ($route->matches($method, $path)) {
return $route;
}
}

return null;
}
}

+ 52
- 0
core/Validator.php Целия файл

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

declare(strict_types=1);

namespace Core;

class Validator
{
protected array $errors = [];

public function required(string $field, mixed $value, string $message = ''): self
{
if ($value === null || trim((string) $value) === '') {
$this->errors[$field][] = $message ?: "{$field} is required.";
}

return $this;
}

public function maxLength(string $field, mixed $value, int $max, string $message = ''): self
{
if (strlen((string) $value) > $max) {
$this->errors[$field][] = $message ?: "{$field} must be {$max} characters or fewer.";
}

return $this;
}

public function numeric(string $field, mixed $value, string $message = ''): self
{
if (!is_numeric($value)) {
$this->errors[$field][] = $message ?: "{$field} must be numeric.";
}

return $this;
}

public function passes(): bool
{
return empty($this->errors);
}

public function fails(): bool
{
return !$this->passes();
}

public function errors(): array
{
return $this->errors;
}
}

+ 68
- 0
core/View.php Целия файл

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

declare(strict_types=1);

namespace Core;

class View
{
public static function render(string $view, array $data = []): Response
{
$content = self::renderContent($view, $data);
$layoutPath = __DIR__ . '/../app/Views/layouts/app.php';

if (!file_exists($layoutPath)) {
return new Response($content);
}

$pageTitle = self::resolvePageTitle($data);

extract($data, EXTR_SKIP);

ob_start();
require $layoutPath;
$content = ob_get_clean();

return new Response($content);
}

public static function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response
{
return new Response(self::renderContent($view, $data), $status, $headers);
}

private static function renderContent(string $view, array $data): string
{
$path = __DIR__ . '/../app/Views/' . str_replace('.', '/', $view) . '.php';

if (!file_exists($path)) {
throw new \Exception("View not found: {$view}");
}

extract($data, EXTR_SKIP);

ob_start();
require $path;

return (string) ob_get_clean();
}

private static function resolvePageTitle(array $data): string
{
if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') {
return $data['pageTitle'];
}

if (
isset($data['model']) &&
is_object($data['model']) &&
property_exists($data['model'], 'title') &&
is_string($data['model']->title) &&
trim($data['model']->title) !== ''
) {
return $data['model']->title;
}

return 'MindVisionCode PHP';
}
}

+ 134
- 0
core/helpers.php Целия файл

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

declare(strict_types=1);

use Core\App;
use Core\Database;
use Core\MigrationManager;
use Core\Response;
use Core\View;

function app(): App
{
static $app = null;

if ($app === null) {
$app = new App();
}

return $app;
}

function view(string $view, array $data = []): Response
{
return View::render($view, $data);
}

function redirect(string $url): Response
{
return Response::redirect($url);
}

function database(): Database
{
static $database = null;

if ($database === null) {
/** @var array<string, mixed> $config */
$config = require __DIR__ . '/../config/database.php';

prepareSqliteDatabase($config['dsn'] ?? '');

$database = new Database($config);
}

return $database;
}

function migration_manager(): MigrationManager
{
static $migrationManager = null;

if ($migrationManager === null) {
$migrationManager = new MigrationManager(database(), __DIR__ . '/../database/migrations');
}

return $migrationManager;
}

function ensureSessionStarted(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}

function prepareSqliteDatabase(string $dsn): void
{
if (!str_starts_with($dsn, 'sqlite:')) {
return;
}

$path = substr($dsn, 7);

if ($path === false || $path === '') {
return;
}

$directory = dirname($path);

if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}

if (!is_writable($directory)) {
@chmod($directory, 0777);
}

if (!file_exists($path)) {
touch($path);
}

if (!is_writable($path)) {
@chmod($path, 0666);
}
}

function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function asset(string $path): string
{
return '/' . ltrim($path, '/');
}

function csrf_token(): string
{
ensureSessionStarted();

if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) {
$_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
}

return $_SESSION['_csrf_token'];
}

function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">';
}

function verify_csrf_token(?string $token): bool
{
ensureSessionStarted();

if (!is_string($token) || $token === '') {
return false;
}

$sessionToken = $_SESSION['_csrf_token'] ?? null;

return is_string($sessionToken) && hash_equals($sessionToken, $token);
}

+ 30
- 0
database/migrations/20260509_000001_create_employees_table.php Целия файл

@@ -0,0 +1,30 @@
<?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 IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
department VARCHAR(100) NOT NULL,
job_title VARCHAR(150) NOT NULL,
start_date DATE NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)'
);
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS employees');
}
};

+ 107
- 0
database/seed_employees.php Целия файл

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

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

function seed_employees(int $targetTotal = 1000, bool $resetExisting = false): void
{
$targetTotal = max(1, $targetTotal);
$migrationManager = migration_manager();
$migrationManager->runPending();
$database = database();

if ($resetExisting) {
$database->execute('DELETE FROM employees');
}

$currentTotal = (int) (database()->first('SELECT COUNT(*) AS total FROM employees')['total'] ?? 0);

if ($currentTotal >= $targetTotal) {
echo "Employee table already has {$currentTotal} records." . PHP_EOL;
return;
}

$firstNames = [
'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Mason', 'Sophia', 'Ethan', 'Isabella', 'Lucas',
'Mia', 'Amelia', 'James', 'Harper', 'Benjamin', 'Ella', 'Henry', 'Evelyn', 'Jack', 'Abigail',
'Alexander', 'Emily', 'Michael', 'Charlotte', 'Daniel', 'Grace', 'Elijah', 'Scarlett', 'William', 'Chloe',
'Matthew', 'Victoria', 'Samuel', 'Lily', 'David', 'Aria', 'Joseph', 'Zoey', 'Carter', 'Hannah',
'Owen', 'Addison', 'Wyatt', 'Natalie', 'John', 'Aubrey', 'Luke', 'Brooklyn', 'Gabriel', 'Layla',
'Anthony', 'Zoe', 'Isaac', 'Penelope', 'Dylan', 'Riley', 'Grayson', 'Nora', 'Levi', 'Lillian',
'Julian', 'Eleanor', 'Christopher', 'Stella', 'Joshua', 'Savannah', 'Andrew', 'Audrey', 'Nathan', 'Claire',
'Thomas', 'Skylar', 'Caleb', 'Lucy', 'Ryan', 'Paisley', 'Christian', 'Everly', 'Hunter', 'Anna',
'Jonathan', 'Caroline', 'Aaron', 'Nova', 'Charles', 'Genesis', 'Connor', 'Kennedy', 'Eli', 'Samantha',
'Landon', 'Maya', 'Adrian', 'Willow', 'Nicholas', 'Kinsley', 'Jeremiah', 'Naomi', 'Easton', 'Ariana',
];

$lastNames = [
'Carter', 'Brooks', 'Hayes', 'Parker', 'Turner', 'Sullivan', 'Reed', 'Ward', 'Price', 'Foster',
'Powell', 'Bennett', 'Coleman', 'Russell', 'Long', 'Perry', 'Morgan', 'Peterson', 'Cooper', 'Bailey',
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson',
'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores',
'Green', 'Adams', 'Nelson', 'Baker', 'Hall', 'Rivera', 'Campbell', 'Mitchell', 'Roberts', 'Gomez',
'Phillips', 'Evans', 'Edwards', 'Collins', 'Stewart', 'Morris', 'Rogers', 'Murphy', 'Cook', 'Ramos',
'Richardson', 'Cox', 'Howard', 'Bell', 'Ortiz', 'Gutierrez', 'Chavez', 'Wood', 'James', 'Bennett',
'Gray', 'Mendoza', 'Ruiz', 'Hughes', 'Grant', 'Stone', 'Spencer', 'Warren', 'Porter', 'Bryant',
];

$departments = [
'Engineering', 'Finance', 'Operations', 'Sales', 'Marketing', 'People', 'Support', 'Legal',
];

$jobTitles = [
'Coordinator', 'Analyst', 'Manager', 'Specialist', 'Administrator', 'Engineer', 'Consultant', 'Lead',
];

$statement = $database->pdo()->prepare(
'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date)
VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)'
);

$database->pdo()->beginTransaction();

try {
for ($i = $currentTotal + 1; $i <= $targetTotal; $i++) {
$firstName = $firstNames[$i % count($firstNames)];
$lastName = $lastNames[$i % count($lastNames)];
$department = $departments[$i % count($departments)];
$jobTitle = $jobTitles[$i % count($jobTitles)];
$email = sprintf(
'%s.%s.%04d@example.test',
strtolower($firstName),
strtolower($lastName),
$i
);

$month = (($i - 1) % 12) + 1;
$day = (($i - 1) % 28) + 1;
$year = 2019 + (($i - 1) % 8);
$startDate = sprintf('%04d-%02d-%02d', $year, $month, $day);

$statement->execute([
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'department' => $department,
'job_title' => $jobTitle,
'start_date' => $startDate,
]);
}

$database->pdo()->commit();
} catch (Throwable $exception) {
$database->pdo()->rollBack();
throw $exception;
}

$inserted = $targetTotal - $currentTotal;
echo "Inserted {$inserted} sample employees. Total is now {$targetTotal}." . PHP_EOL;
}

if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000;
seed_employees($targetTotal);
}

+ 72
- 0
docs/README.md Целия файл

@@ -0,0 +1,72 @@
# MindVisionCode PHP

A small PHP MVC framework inspired by a Classic ASP MVC framework.

## Run

```bash
composer install
php migrate.php up
php -S localhost:8000 -t public
```

Open:

```text
http://localhost:8000
```

Try:

```text
http://localhost:8000/users/123
```

Employee form:

```text
http://localhost:8000/employees
```

## Request Flow

Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response

## Main Folders

- `core/` framework classes
- `app/Controllers/` application controllers
- `app/ViewModels/` view model classes
- `app/Repositories/` data access classes
- `app/Views/` PHP templates
- `routes/web.php` route definitions
- `database/migrations/` migrations

## SQLite

The default database is SQLite and points to:

```text
database/app.sqlite
```

The database file is created automatically when the app first needs it.

Run migrations from the PHP CLI:

```bash
php migrate.php up
php migrate.php down
php migrate.php status
php migrate.php make create_projects_table
php migrate.php fresh
php migrate.php fresh --seed
```

## Frontend Libraries

The employee directory page uses:

- `htmx` for fragment-based form and summary updates
- `Alpine.js` for lightweight page state
- `Tabulator` for the interactive employee table

+ 105
- 0
migrate.php Целия файл

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

declare(strict_types=1);

require_once __DIR__ . '/vendor/autoload.php';

$command = $argv[1] ?? 'help';
$options = array_slice($argv, 2);
$manager = migration_manager();

try {
switch ($command) {
case 'up':
$ran = $manager->runPending();

if ($ran === []) {
echo "No pending migrations." . PHP_EOL;
exit(0);
}

foreach ($ran as $migration) {
echo "Migrated: {$migration}" . PHP_EOL;
}

echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL;
exit(0);

case 'down':
$steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1;
$rolledBack = $manager->rollback($steps);

if ($rolledBack === []) {
echo "No applied migrations to roll back." . PHP_EOL;
exit(0);
}

foreach ($rolledBack as $migration) {
echo "Rolled back: {$migration}" . PHP_EOL;
}

echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL;
exit(0);

case 'status':
$status = $manager->status();

if ($status === []) {
echo "No migration files found." . PHP_EOL;
exit(0);
}

foreach ($status as $row) {
$state = $row['ran'] ? 'up' : 'pending';
$ranAt = $row['ran_at'] ?? '-';
echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL;
}

exit(0);

case 'make':
case 'create':
$name = $argv[2] ?? '';

if ($name === '') {
throw new InvalidArgumentException('Provide a migration name. Example: php migrate.php make create_projects_table');
}

$path = $manager->make($name);
echo "Created migration: {$path}" . PHP_EOL;
exit(0);

case 'fresh':
$result = $manager->fresh();

foreach ($result['rolled_back'] as $migration) {
echo "Rolled back: {$migration}" . PHP_EOL;
}

foreach ($result['migrated'] as $migration) {
echo "Migrated: {$migration}" . PHP_EOL;
}

if (in_array('--seed', $options, true)) {
require __DIR__ . '/database/seed_employees.php';
seed_employees(1000, true);
}

echo "Fresh migration run complete." . PHP_EOL;
exit(0);

case 'help':
default:
echo "Migration CLI" . PHP_EOL;
echo "Usage:" . PHP_EOL;
echo " php migrate.php up" . PHP_EOL;
echo " php migrate.php down [steps]" . PHP_EOL;
echo " php migrate.php status" . PHP_EOL;
echo " php migrate.php make <name>" . PHP_EOL;
echo " php migrate.php fresh [--seed]" . PHP_EOL;
exit(0);
}
} catch (Throwable $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}

+ 5
- 0
public/.htaccess Целия файл

@@ -0,0 +1,5 @@
RewriteEngine On

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

+ 791
- 0
public/css/site.css Целия файл

@@ -0,0 +1,791 @@
:root {
--page-background: #f4efe7;
--surface: rgba(255, 252, 247, 0.88);
--surface-strong: #fffdf8;
--surface-border: rgba(26, 72, 64, 0.12);
--text-primary: #143631;
--text-secondary: #4f655f;
--accent: #1d7a6d;
--accent-strong: #135c52;
--accent-soft: #daf1ec;
--highlight: #ef7c4d;
--shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1);
--shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08);
}

* {
box-sizing: border-box;
}

html {
scroll-behavior: smooth;
}

body {
margin: 0;
min-height: 100vh;
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif;
color: var(--text-primary);
background:
radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%),
linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%);
}

a {
color: inherit;
}

code {
font-family: Consolas, "Courier New", monospace;
}

.page-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}

.container {
width: min(1120px, calc(100% - 2rem));
margin: 0 auto;
}

.site-header {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(14px);
background: rgba(248, 242, 232, 0.78);
border-bottom: 1px solid rgba(20, 54, 49, 0.08);
}

.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 0;
}

.brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
text-decoration: none;
}

.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.95rem;
background: linear-gradient(135deg, var(--accent), var(--highlight));
color: #fff;
font-weight: 700;
letter-spacing: 0.08em;
box-shadow: var(--shadow-soft);
}

.brand-copy {
display: flex;
flex-direction: column;
line-height: 1.1;
}

.brand-copy strong {
font-size: 1rem;
}

.brand-copy small {
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}

.site-nav {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}

.nav-link {
text-decoration: none;
color: var(--text-secondary);
font-weight: 600;
padding: 0.7rem 1rem;
border-radius: 999px;
transition: background-color 160ms ease, color 160ms ease, transform 160ms ease;
}

.nav-link:hover,
.nav-link:focus-visible,
.nav-link.is-active {
color: var(--accent-strong);
background: rgba(29, 122, 109, 0.12);
transform: translateY(-1px);
}

.page-content {
flex: 1;
padding: 3.5rem 0 4rem;
}

.content-stack {
display: grid;
gap: 1.5rem;
}

.section-heading {
max-width: 46rem;
}

.section-heading h1 {
margin: 0.3rem 0 0.8rem;
font-size: clamp(2.4rem, 5vw, 4rem);
line-height: 1;
letter-spacing: -0.04em;
}

.section-heading p {
margin: 0;
color: var(--text-secondary);
line-height: 1.8;
font-size: 1.05rem;
}

.hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
gap: 1.5rem;
align-items: stretch;
}

.hero-copy,
.hero-panel,
.feature-card,
.section-panel,
.employee-card,
.alert,
.empty-state {
background: var(--surface);
border: 1px solid var(--surface-border);
box-shadow: var(--shadow-card);
}

.hero-copy {
padding: 3rem;
border-radius: 2rem;
}

.eyebrow {
display: inline-block;
margin-bottom: 1rem;
padding: 0.4rem 0.75rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
}

.hero h1 {
margin: 0;
font-size: clamp(2.8rem, 6vw, 4.8rem);
line-height: 0.98;
letter-spacing: -0.04em;
}

.hero-text {
max-width: 44rem;
margin: 1.25rem 0 0;
font-size: 1.12rem;
line-height: 1.8;
color: var(--text-secondary);
}

.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin-top: 2rem;
}

.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.9rem 1.35rem;
border-radius: 999px;
text-decoration: none;
font-weight: 700;
}

.button-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25);
}

.button-secondary {
background: rgba(29, 122, 109, 0.08);
color: var(--accent-strong);
}

.hero-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 2rem;
border-radius: 1.8rem;
}

.panel-label {
margin: 0 0 1rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-secondary);
}

.hero-panel code {
display: block;
padding: 1rem 1.1rem;
border-radius: 1.2rem;
background: #173d37;
color: #eefbf6;
line-height: 1.7;
white-space: normal;
}

.route-callout {
margin-top: 1.5rem;
padding: 1rem 1.1rem;
border-radius: 1.2rem;
background: var(--surface-strong);
}

.route-callout span {
display: block;
margin-bottom: 0.45rem;
color: var(--text-secondary);
font-size: 0.92rem;
}

.route-callout a {
color: var(--highlight);
font-weight: 700;
text-decoration: none;
}

.feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
margin-top: 1.5rem;
}

.feature-card {
padding: 1.75rem;
border-radius: 1.6rem;
}

.feature-card h2 {
margin-top: 0;
margin-bottom: 0.8rem;
font-size: 1.25rem;
}

.feature-card p {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
}

.employee-layout {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.5fr);
gap: 1.5rem;
align-items: start;
}

.controls-panel,
.table-shell {
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)),
var(--surface);
}

.controls-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}

.search-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
}

.field-full {
width: 100%;
}

.section-panel {
padding: 1.75rem;
border-radius: 1.8rem;
}

.panel-header {
margin-bottom: 1.5rem;
}

.panel-header h2 {
margin: 0 0 0.45rem;
font-size: 1.45rem;
}

.panel-header p {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
}

.employee-form {
display: grid;
gap: 1.25rem;
}

.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}

.field {
display: grid;
gap: 0.45rem;
font-weight: 600;
}

.field span {
font-size: 0.96rem;
}

.input {
width: 100%;
padding: 0.95rem 1rem;
border: 1px solid rgba(20, 54, 49, 0.16);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.92);
color: var(--text-primary);
font: inherit;
}

.input:focus {
outline: 2px solid rgba(29, 122, 109, 0.22);
border-color: rgba(29, 122, 109, 0.45);
}

.field-error {
color: #a43d1f;
font-size: 0.88rem;
font-weight: 600;
}

.form-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.85rem;
}

.button {
border: 0;
cursor: pointer;
}

.htmx-indicator {
display: none;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-flex;
}

.inline-indicator {
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 600;
}

.alert,
.empty-state {
padding: 1rem 1.15rem;
border-radius: 1.2rem;
}

.alert-success {
background: rgba(218, 241, 236, 0.92);
color: var(--accent-strong);
}

.alert-error {
background: rgba(239, 124, 77, 0.14);
color: #8f3518;
}

.empty-state p {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
}

.empty-state p + p {
margin-top: 0.45rem;
}

.employee-cards {
display: grid;
gap: 1rem;
}

.employee-card {
padding: 1.15rem;
border-radius: 1.3rem;
}

.employee-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.8rem;
}

.employee-card-top h3 {
margin: 0;
font-size: 1.05rem;
}

.employee-card-top span {
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(29, 122, 109, 0.09);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 700;
}

.employee-card p {
margin: 0 0 1rem;
color: var(--text-secondary);
}

.employee-meta {
display: grid;
gap: 0.75rem;
margin: 0;
}

.employee-meta div {
display: grid;
gap: 0.2rem;
}

.employee-meta dt {
color: var(--text-secondary);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}

.employee-meta dd {
margin: 0;
font-weight: 600;
}

.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}

.stat-card {
padding: 1rem;
border-radius: 1.3rem;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(20, 54, 49, 0.08);
}

.stat-card span {
display: block;
color: var(--text-secondary);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}

.stat-card strong {
display: block;
margin-top: 0.45rem;
font-size: 1.7rem;
line-height: 1;
}

.summary-feature {
margin-top: 1rem;
padding: 1.15rem;
border-radius: 1.3rem;
background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12));
}

.summary-label {
display: block;
color: var(--text-secondary);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}

.summary-feature h3 {
margin: 0.55rem 0 0.3rem;
font-size: 1.35rem;
}

.summary-feature p {
margin: 0;
color: var(--text-secondary);
}

.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
padding: 0.9rem 1rem;
border: 1px solid rgba(20, 54, 49, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.58);
}

.table-pill {
display: inline-flex;
align-items: center;
padding: 0.5rem 0.8rem;
border-radius: 999px;
background: rgba(29, 122, 109, 0.12);
color: var(--accent-strong);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.04em;
}

.table-caption {
color: var(--text-secondary);
font-size: 0.92rem;
}

.directory-panel .tabulator-host {
min-height: 38rem;
}

.tabulator-host .tabulator {
border: 1px solid var(--surface-border);
border-radius: 1.35rem;
overflow: hidden;
background: rgba(255, 255, 255, 0.82);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 18px 35px rgba(20, 54, 49, 0.08);
}

.tabulator-host .tabulator-header {
border-bottom: 1px solid rgba(20, 54, 49, 0.08);
background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08));
}

.tabulator-host .tabulator-header .tabulator-col {
min-height: 3.25rem;
background: transparent;
border-right: 1px solid rgba(20, 54, 49, 0.06);
}

.tabulator-host .tabulator-header .tabulator-col:last-child {
border-right: 0;
}

.tabulator-host .tabulator-header .tabulator-col .tabulator-col-content {
padding: 0.9rem 0.95rem 0.85rem;
}

.tabulator-host .tabulator-header .tabulator-col .tabulator-col-title {
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-strong);
}

.tabulator-host .tabulator-col,
.tabulator-host .tabulator-cell {
border-right: 1px solid rgba(20, 54, 49, 0.06);
}

.tabulator-host .tabulator-row .tabulator-cell:last-child {
border-right: 0;
}

.tabulator-host .tabulator-row {
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid rgba(20, 54, 49, 0.06);
transition: background-color 160ms ease, transform 160ms ease;
}

.tabulator-host .tabulator-row:nth-child(even) {
background: rgba(248, 242, 232, 0.82);
}

.tabulator-host .tabulator-row:hover {
background: rgba(218, 241, 236, 0.72);
}

.tabulator-host .tabulator-row.tabulator-selected {
background: rgba(29, 122, 109, 0.18);
}

.tabulator-host .tabulator-cell {
padding: 0.95rem 0.95rem;
font-size: 0.96rem;
line-height: 1.4;
}

.tabulator-host .tabulator-row .tabulator-cell:first-child {
font-weight: 700;
color: var(--text-primary);
}

.tabulator-host .tabulator-footer {
padding: 0.55rem 0.7rem;
background: rgba(255, 255, 255, 0.88);
border-top: 1px solid rgba(20, 54, 49, 0.08);
}

.tabulator-host .tabulator-footer .tabulator-paginator {
font-family: inherit;
}

.tabulator-host .tabulator-footer .tabulator-page {
margin: 0 0.2rem;
padding: 0.45rem 0.7rem;
border: 1px solid rgba(20, 54, 49, 0.1);
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.9);
color: var(--text-secondary);
font-weight: 700;
}

.tabulator-host .tabulator-footer .tabulator-page.active,
.tabulator-host .tabulator-footer .tabulator-page:hover {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border-color: transparent;
color: #fff;
}

.tabulator-host .tabulator-footer .tabulator-page:disabled {
opacity: 0.45;
}

.tabulator-host .tabulator-placeholder {
padding: 2.5rem 1rem;
color: var(--text-secondary);
font-size: 1rem;
font-weight: 600;
}

.site-footer {
margin-top: auto;
border-top: 1px solid rgba(20, 54, 49, 0.08);
background: rgba(255, 252, 247, 0.72);
}

.footer-inner {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 0 2rem;
color: var(--text-secondary);
font-size: 0.95rem;
}

.footer-inner p {
margin: 0;
}

@media (max-width: 860px) {
.header-inner,
.footer-inner {
flex-direction: column;
align-items: flex-start;
}

.hero,
.feature-grid,
.employee-layout {
grid-template-columns: 1fr;
}

.controls-header,
.table-toolbar {
flex-direction: column;
align-items: flex-start;
}

.hero-copy,
.hero-panel {
padding: 2rem;
}

.form-grid {
grid-template-columns: 1fr;
}

.stats-grid {
grid-template-columns: 1fr;
}

.page-content {
padding-top: 2rem;
}
}

@media (max-width: 560px) {
.container {
width: min(100% - 1.25rem, 1120px);
}

.site-nav {
width: 100%;
}

.nav-link {
width: 100%;
text-align: center;
}

.hero h1 {
font-size: 2.5rem;
}
}

+ 23
- 0
public/index.php Целия файл

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

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();
$router = new Router();

require_once __DIR__ . '/../routes/web.php';

$dispatcher = new Dispatcher($router, $app);
$request = Request::capture();
$response = $dispatcher->dispatch($request);

$response->send();

+ 65
- 0
public/js/app.js Целия файл

@@ -0,0 +1,65 @@
window.employeeDirectory = function () {
return {
search: '',
table: null,

init() {
this.search = this.$root.querySelector('#employee-search')?.value ?? '';
this.initTable();

document.body.addEventListener('employees-changed', () => {
this.reloadTable();
});
},

initTable() {
const tableElement = document.getElementById('employee-table');

if (!tableElement || typeof Tabulator === 'undefined') {
return;
}

this.table = new Tabulator(tableElement, {
ajaxURL: '/employees/data',
ajaxParams: {
search: this.search,
},
layout: 'fitColumns',
responsiveLayout: 'collapse',
pagination: true,
paginationMode: 'local',
paginationSize: 8,
movableColumns: true,
placeholder: 'No employees found.',
columns: [
{ title: 'Name', field: 'full_name', minWidth: 180 },
{ title: 'Email', field: 'email', minWidth: 220 },
{ title: 'Department', field: 'department', minWidth: 140 },
{ title: 'Job Title', field: 'job_title', minWidth: 180 },
{ title: 'Start Date', field: 'start_date', hozAlign: 'left', minWidth: 130 },
],
});
},

applySearch() {
if (!this.table) {
return;
}

this.table.setData('/employees/data', {
search: this.search,
});
},

reloadTable() {
if (!this.table) {
this.initTable();
return;
}

this.table.setData('/employees/data', {
search: this.search,
});
},
};
};

+ 14
- 0
routes/web.php Целия файл

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

declare(strict_types=1);

use App\Controllers\HomeController;
use App\Controllers\EmployeeController;

$router->get('/', [HomeController::class, 'index']);
$router->get('/users/{id}', [HomeController::class, 'user']);
$router->get('/employees', [EmployeeController::class, 'index']);
$router->get('/employees/create', [EmployeeController::class, 'create']);
$router->get('/employees/summary', [EmployeeController::class, 'summary']);
$router->get('/employees/data', [EmployeeController::class, 'data']);
$router->post('/employees', [EmployeeController::class, 'store']);

+ 145
- 0
tests/run.php Целия файл

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

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Core\App;
use Core\Database;
use Core\Dispatcher;
use Core\MigrationManager;
use Core\Request;
use Core\Router;

$tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true);
mkdir($tempMigrationPath, 0777, true);

$migrationFile = $tempMigrationPath . '/20260509_120000_create_projects_table.php';
file_put_contents($migrationFile, <<<'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 projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS projects');
}
};
PHP
);

$memoryDatabase = new Database([
'dsn' => 'sqlite::memory:',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
]);

$migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath);
$ran = $migrationManager->runPending();

if ($ran !== ['20260509_120000_create_projects_table.php']) {
echo "FAIL: migration manager did not apply the expected migration\n";
exit(1);
}

$projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'");

if ($projectTable === null) {
echo "FAIL: migration up() did not create the projects table\n";
exit(1);
}

$rolledBack = $migrationManager->rollback();

if ($rolledBack !== ['20260509_120000_create_projects_table.php']) {
echo "FAIL: migration manager did not roll back the expected migration\n";
exit(1);
}

$projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'");

if ($projectTableAfterRollback !== null) {
echo "FAIL: migration down() did not remove the projects table\n";
exit(1);
}

$createdMigrationPath = $migrationManager->make('create_tasks_table');

if (!file_exists($createdMigrationPath)) {
echo "FAIL: migration manager did not create a migration file\n";
exit(1);
}

$router = new Router();
$app = new App();

(new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending();

require_once __DIR__ . '/../routes/web.php';

$router->get('/hello/{name}', function (string $name) {
return 'Hello, ' . $name;
});

$request = new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/hello/Daniel',
]);

$response = (new Dispatcher($router, $app))->dispatch($request);

if ($response->status() !== 200) {
echo "FAIL: expected status 200\n";
exit(1);
}

if ($response->content() !== 'Hello, Daniel') {
echo "FAIL: unexpected response content\n";
exit(1);
}

$employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/employees',
]));

if ($employeePage->status() !== 200) {
echo "FAIL: expected employee page status 200\n";
exit(1);
}

if (strpos($employeePage->content(), 'Add Employee') === false) {
echo "FAIL: employee page did not render form content\n";
exit(1);
}

$employeeData = (new Dispatcher($router, $app))->dispatch(new Request([
'search' => '',
], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/employees/data',
]));

if ($employeeData->status() !== 200) {
echo "FAIL: expected employee data status 200\n";
exit(1);
}

if (strpos($employeeData->content(), '[') === false) {
echo "FAIL: employee data endpoint did not return JSON array content\n";
exit(1);
}

echo "PASS: migration manager and route dispatch work\n";

Loading…
Отказ
Запис

Powered by TurnKey Linux.