Kaynağa Gözat

Add framework guide and fix session/migration/error-handler bugs

Documents the whole framework in docs/framework.md, including a
worked tutorial. Building and verifying that tutorial end-to-end
surfaced three real bugs, now fixed:

- MigrateCommand re-ran every migration file on every invocation
  instead of skipping ones already logged in the migrations table,
  so a second `migrate` always failed once tables existed.
- Router's 404/400/500 handlers sent a 200 OK regardless of the
  error, since the returned string was never given the matching
  status code.
- Several views (nav login state, product order errors) and
  OrderProductController read/wrote the raw $_SESSION superglobal
  directly, bypassing the session driver's key prefix, so they
  silently never saw values written via session()->put().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Daniel Covington 1 hafta önce
ebeveyn
işleme
17e2750f25
8 değiştirilmiş dosya ile 865 ekleme ve 13 silme
  1. +1
    -1
      app/Http/Controllers/Products/OrderProductController.php
  2. +822
    -0
      docs/framework.md
  3. +12
    -1
      framework/Database/Command/MigrateCommand.php
  4. +18
    -3
      framework/Routing/Router.php
  5. +6
    -2
      readme.md
  6. +2
    -2
      resources/views/includes/large-feature.advanced.php
  7. +2
    -2
      resources/views/includes/small-feature.advanced.php
  8. +2
    -2
      resources/views/products/view.advanced.php

+ 1
- 1
app/Http/Controllers/Products/OrderProductController.php Dosyayı Görüntüle

@@ -12,7 +12,7 @@ class OrderProductController

// use $data to create a database record...

$_SESSION['ordered'] = true;
session()->put('ordered', true);

return redirect($router->route('show-home-page'));
}


+ 822
- 0
docs/framework.md Dosyayı Görüntüle

@@ -0,0 +1,822 @@
# Framework Guide

This is a small, hand-rolled MVC framework (`framework/`) that the application code (`app/`) is built on top of. It has no Laravel/Symfony-style magic beyond what's in this repo — every feature below is implemented in a few dozen lines you can go read directly (the file path is given for each piece, so you can always jump to the real source and see exactly what it does).

If you're new to this codebase, read **[Architecture](#architecture)** and then the **[tutorial](#tutorial-build-a-guestbook-feature)** first — it walks through building one small feature end-to-end and touches almost everything else in this guide along the way. Everything after that is reference material you can jump to as needed.

## Contents

- [Architecture](#architecture)
- [Tutorial: build a guestbook feature](#tutorial-build-a-guestbook-feature)
- [Bootstrapping & request lifecycle](#bootstrapping--request-lifecycle)
- [The container](#the-container)
- [Configuration](#configuration)
- [Routing](#routing)
- [Controllers](#controllers)
- [Views](#views)
- [Database: connections & query builder](#database-connections--query-builder)
- [Models & relationships](#models--relationships)
- [Migrations](#migrations)
- [Validation](#validation)
- [Sessions & CSRF](#sessions--csrf)
- [Caching](#caching)
- [Filesystem](#filesystem)
- [Email](#email)
- [Queue & background jobs](#queue--background-jobs)
- [Logging](#logging)
- [Error handling](#error-handling)
- [CLI commands](#cli-commands)
- [Testing](#testing)
- [Helper function reference](#helper-function-reference)

---

## Architecture

```
framework/ the framework itself, namespace Framework\...
app/ your application code, namespace App\...
Http/Controllers/ one class per route handler
Models/ Framework\Database\Model subclasses
routes.php route definitions
commands.php CLI commands to register
config/ one file per subsystem, each returns an array
database/migrations/ numbered migration files
resources/views/ templates (four possible engines, see below)
public/index.php the web entry point
command.php the CLI entry point
```

Everything is wired together through **providers** (`framework/Provider/*`, listed in `config/providers.php`). Each provider's `bind()` method registers one or more services into the container. `Framework\Support\DriverProvider` is a base class used by most of them (database, cache, session, filesystem, email, queue, logging) — it reads `config/<name>.php`, picks the driver named by `'default'`, and binds it under the container alias `<name>` (e.g. `app('cache')`, `app('database')`).

A request touches these pieces roughly in order:

```
browser request
public/index.php ─── boots the app, then calls run()
Router (app/routes.php) ─── finds the matching route by method + path
your Controller ─── the route's handler method runs
│ (reads input, talks to Models, calls validate()/csrf helpers)
a View (optional) ─── renders HTML using data the controller passed in
Response ─── sent back to the browser (HTML, JSON, or a redirect)
```

The tutorial below walks through exactly this path for a real feature, top to bottom.

## Tutorial: build a guestbook feature

This builds a tiny "leave a message" page from scratch: a form at `/guestbook` where visitors type a name and a message, a list of everything anyone's posted, and validation on the form. It uses a migration, a model, a route, two controllers, and a view — i.e. almost everything in this guide, in the order you'd actually touch them.

### 1. Add a database table (a migration)

Create `database/migrations/011_CreateMessagesTable.php` (the leading number just has to sort after the existing files):

```php
<?php

use Framework\Database\Connection\Connection;

class CreateMessagesTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('messages');
$table->id('id');
$table->string('name');
$table->string('body');
$table->dateTime('created_at')->default('CURRENT_TIMESTAMP');
$table->execute();
}
}
```

Run it:

```
php command.php migrate
```

You should see `Migrating: CreateMessagesTable` printed, and a new `messages` table will exist (in `database/database.sqlite` by default). See [Migrations](#migrations) for the full field-type list and how to alter/drop columns later.

### 2. Add a model

Create `app/Models/Message.php`. This is what lets you write `Message::all()` and `$message->save()` instead of hand-writing SQL:

```php
<?php

namespace App\Models;

use Framework\Database\Model;

class Message extends Model
{
protected string $table = 'messages';
}
```

That's the whole class — table name is the only thing it *needs* to know. See [Models & relationships](#models--relationships) for accessors, casts, and relationships you can add later.

### 3. Add routes

Open `app/routes.php` and add two routes: one to show the page, one to handle the form submission.

```php
use App\Http\Controllers\Guestbook\ShowGuestbookController;
use App\Http\Controllers\Guestbook\PostGuestbookController;

$router->add('GET', '/guestbook', [ShowGuestbookController::class, 'handle'])
->name('show-guestbook');

$router->add('POST', '/guestbook', [PostGuestbookController::class, 'handle'])
->name('post-guestbook');
```

Following the existing app's convention, every route points at its own small controller class (one action per class) rather than one big controller with many methods. See [Routing](#routing) for route parameters (`{id}`) and named-route generation.

### 4. Add the controllers

`app/Http/Controllers/Guestbook/ShowGuestbookController.php` — fetches all messages and renders the view:

```php
<?php

namespace App\Http\Controllers\Guestbook;

use App\Models\Message;

class ShowGuestbookController
{
public function handle()
{
return view('guestbook', [
'messages' => Message::all(),
'csrf' => csrf(),
]);
}
}
```

`app/Http/Controllers/Guestbook/PostGuestbookController.php` — validates the form and saves a new message:

```php
<?php

namespace App\Http\Controllers\Guestbook;

use App\Models\Message;
use Framework\Routing\Router;

class PostGuestbookController
{
public function handle(Router $router)
{
secure(); // reject the request if $_POST['csrf'] doesn't match the session token

$data = validate($_POST, [
'name' => ['required'],
'body' => ['required', 'min:3'],
], 'guestbook_errors');

$message = new Message();
$message->name = $data['name'];
$message->body = $data['body'];
$message->save();

return redirect($router->route('show-guestbook'));
}
}
```

A few things worth noticing here, because they trip people up the first time:

- `handle()` only takes a `Router $router` parameter *when it needs one* — the container inspects the method signature and only supplies what's type-hinted (see [The container](#the-container)). `ShowGuestbookController` doesn't need the router, so it doesn't ask for it.
- If `validate()` fails, it throws an exception you never catch yourself — the framework catches it centrally, stores the errors in the session, and redirects back to the form (see [Error handling](#error-handling)). Your controller code only has to handle the *success* path.
- `secure()` must be called before you touch `$_POST` for anything real — it's the CSRF check, and it throws (redirecting back) if the hidden `csrf` field doesn't match.

### 5. Add the view

Create `resources/views/guestbook.advanced.php` (the `.advanced.php` extension picks the Blade-like engine — see [Views](#views)):

```blade
@extends('layout')

<div class="container mx-auto px-8 py-8">
<h1 class="text-3xl font-bold">Guestbook</h1>

@if(session()->has('guestbook_errors'))
<ol class="text-red-500">
@foreach(session('guestbook_errors') as $field => $errors)
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
@endforeach
</ol>
@endif

<form method="post" action="/guestbook">
<input type="hidden" name="csrf" value="{{ $csrf }}" />
<input name="name" placeholder="Your name" />
<input name="body" placeholder="Your message" />
<button type="submit">Post</button>
</form>

<ul>
@foreach($messages as $message)
<li><strong>{{ $message->name }}</strong>: {{ $message->body }}</li>
@endforeach
</ul>
</div>
```

`@extends('layout')` wraps this in `resources/views/layout.advanced.php` (the shared page shell). `{{ }}` escapes output — always use it for anything a user typed, like `$message->name` and `$message->body` — and `{!! !!}` is only for HTML you trust (see [Views](#views)).

Notice the errors are read with `session()->has(...)` / `session(...)`, **not** `isset($_SESSION['guestbook_errors'])`. That matters: `session()->put(...)` namespaces every key with `config('session.native.prefix')` (`framework_` by default), so it's actually stored as `$_SESSION['framework_guestbook_errors']`. Reading it back through the `session()` helper strips that prefix for you automatically — reading the raw superglobal directly would silently never find it. See the [Sessions & CSRF](#sessions--csrf) section for more on this.

### 6. Try it

```
php command.php serve
```

Visit `http://127.0.0.1:8000/guestbook`. Submitting the form with an empty name should redirect back with a validation error printed at the top; filling it in should add a new `<li>` to the list. If something goes wrong, `APP_ENV=dev` gets you a full stack trace in the browser instead of a blank error (see [Error handling](#error-handling)).

That's the whole loop: **migration → model → route → controller → view**. Every feature in this app (and the reference sections below) is a variation on those same five steps.

## Bootstrapping & request lifecycle

Both entry points do the same three things:

```php
// public/index.php
$app = \Framework\App::getInstance();
$app->bind('paths.base', fn() => __DIR__ . '/..');
$app->prepare()->run()->send();
```

- `App::getInstance()` — `App` is a singleton (`framework/App.php`).
- `prepare()` — loads `.env` via `vlucas/phpdotenv`, then runs every provider listed in `config/providers.php`.
- `run()` — builds the `Router` from `app/routes.php` (once, cached on the container), dispatches the current request, and returns a `Framework\Http\Response`.
- `send()` — writes headers/status/body for that response.

`command.php` (the CLI entry point) does the same `bind('paths.base', ...) → prepare()`, but instead of `run()->send()` it hands control to a Symfony Console `Application` loaded with the commands from `app/commands.php`.

`server.php` is the router script for PHP's built-in server (`php -S`) — it serves static files directly out of `public/` and otherwise defers to `public/index.php`.

## The container

`Framework\Container` (`framework/Container.php`) is intentionally minimal:

```php
$app->bind('cache', fn($app) => new SomeCacheThing(...));
$app->resolve('cache'); // == app('cache')
$app->has('cache'); // bool
```

Bindings are **lazy singletons** — the factory closure only runs the first time you `resolve()` that alias, and the result is cached after that.

`Container::call($callable, $parameters = [])` is what powers automatic dependency injection for route handlers (see [Controllers](#controllers)). For each parameter of the target method/function it will:
1. use `$parameters[$name]` if you passed it explicitly,
2. use the parameter's default value if it has one,
3. otherwise try to `resolve()` an alias matching the parameter's **type name** as a string.

That third step means **only classes that have been explicitly bound under their own class name** can be auto-injected — there's no reflection-based auto-wiring of arbitrary classes. Concretely, `Router::class` is bound during `App::dispatch()`, which is why every controller can type-hint `Router $router` and get it for free:

```php
class ShowProductController
{
public function handle(Router $router) // auto-injected
{
$parameters = $router->current()->parameters();
// ...
}
}
```

## Configuration

Every file in `config/` returns a plain array. Access values with the `config()` helper, using dot notation:

```php
config('database.default'); // 'sqlite'
config('cache.file.seconds'); // 31536000
config('handlers.exceptions'); // App\Exceptions\Handler::class
```

`Framework\Support\Config` (`framework/Support/Config.php`) lazily `require`s `config/<first-segment>.php` the first time you ask for a key in it, then walks the rest of the dot-path.

Config files read secrets/environment-specific values via `env('KEY', 'default')`, which checks `$_SERVER`, then `$_ENV`, then `getenv()`, in that order (`framework/helpers.php`). `.env` (loaded by `App::configure()`) is where those values actually come from locally; in Docker they're set directly as container environment variables (see `docker-compose.yml`), which take priority since `Dotenv::createImmutable()` never overwrites variables that are already set.

The convention every driver-backed config file follows (`database.php`, `cache.php`, `session.php`, etc.) is:

```php
return [
'default' => env('DB_CONNECTION', 'sqlite'),
'sqlite' => ['type' => 'sqlite', 'path' => ...],
'mysql' => ['type' => 'mysql', 'host' => ...],
];
```

`DriverProvider` reads `'default'`, looks up that key, and passes the whole sub-array (which must include `'type'`) to the matching driver factory.

## Routing

Routes are declared in `app/routes.php`, which receives the `Router` and returns nothing:

```php
return function(Router $router) {
$router->add('GET', '/', [ShowHomePageController::class, 'handle'])
->name('show-home-page');

$router->add('GET', '/products/view/{product}', [ShowProductController::class, 'handle'])
->name('view-product');

$router->add('POST', '/products/order/{product}', [OrderProductController::class, 'handle'])
->name('order-product');

$router->errorHandler(404, fn() => 'whoops!');
};
```

- **Handler** is `[ControllerClass::class, 'methodName']` (or any callable) — dispatched through the container via `Container::call()`, so it gets constructor-free dependency injection (see above).
- **Parameters**: `{name}` is required, `{name?}` is optional. Read them in the handler via `$router->current()->parameters()` (an associative array; missing optional params come back as `false`).
- **Naming**: `->name('some-name')` lets you generate the path with `$router->route('some-name', ['product' => 5])`, which substitutes `{product}`/`{product?}` placeholders and strips any that are left over.
- **Error handlers**: `errorHandler(404, ...)`, `errorHandler(400, ...)` (method not allowed on a known path), and `errorHandler(500, ...)` are configurable per-app; each defaults to a plain string if you don't set one. The router sends the matching HTTP status code (404/400/500) along with whatever the handler returns — a plain `fn() => 'whoops!'` 404 handler still serves a real `404`. Return a full `Response` yourself only if you need to customize headers or the status further.
- Unhandled exceptions thrown inside a route handler are caught in `Router::dispatch()` and passed to the exception handler configured at `config('handlers.exceptions')` (see [Error handling](#error-handling)) before falling back to the 500 handler.

## Controllers

Controllers are plain classes with one public method per action — no base class required. A handler returns either:
- a `Framework\Http\Response` (built via `response()`),
- a `View` (from the `view()` helper — cast to string automatically), or
- a plain string/array (wrapped into a Response by `App::dispatch()`; arrays go through `response()->json()` implicitly only if you call `->json()` yourself — plain scalars just become the HTML body).

```php
class RegisterUserController
{
public function handle(Router $router)
{
secure(); // verify CSRF ($_POST['csrf'] against the session token)

$data = validate($_POST, [
'name' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'min:10'],
], 'register_errors');

$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = password_hash($data['password'], PASSWORD_BCRYPT);
$user->save();

return redirect($router->route('show-home-page'));
}
}
```

Common response helpers (`framework/helpers.php` / `Framework\Http\Response`):

```php
redirect($url); // 302-style redirect response
response()->json(['ok' => true]); // JSON response
response()->status(404)->content('Not found');
response()->header('X-Foo', 'bar');
```

## Views

`view($template, $data = [])` (from `framework/helpers.php`) resolves a template by trying every registered path × engine combination until a file exists, e.g. `resources/views/home.advanced.php`. Four engines are registered by `ViewProvider` (`framework/Provider/ViewProvider.php`):

| Extension | Engine | What it does |
|----------------|------------------|---------------|
| `.basic.php` | `BasicEngine` | `{key}` → naive `str_replace` against `$data`. No PHP execution. |
| `.advanced.php`| `AdvancedEngine` | A tiny Blade-like template language (compiled to a cached PHP file under `storage/framework/views/`). |
| `.php` | `PhpEngine` | Plain PHP templates — `$data` is `extract()`-ed, output is buffered. |
| `.svg` | `LiteralEngine` | Returns file contents verbatim (used for e.g. `resources/images/rocket.svg`). |

Views resolve against two paths: `resources/views/` and `resources/images/` (the latter is why `@includes('rocket')` finds an SVG).

### Advanced engine syntax

This is what most of the app's views use. It compiles a small directive set into real PHP, then caches the compiled file (recompiled only if the source is newer):

```blade
@extends('layout')
@includes('includes/large-feature')

@foreach($products as $i => $product)
<div class="@if($i % 2 === 0) bg-gray-50 @endif">
<h2>{{ $product->name }}</h2>
<p>{!! $product->description !!}</p>
</div>
@endforeach
```

- `{{ $expr }}` — escaped output (`htmlspecialchars($expr, ENT_QUOTES)`).
- `{!! $expr !!}` — raw, unescaped output.
- `@if(...) / @endif`, `@foreach(...) / @endforeach` — control structures.
- `@extends('layout')` — renders the current template's output as `$contents` inside `resources/views/layout.advanced.php`. The layout template does `{!! $contents !!}` wherever the child content should go.
- `@includes('some/view', [...])` and `@escape(...)` are **macros**, not built-in directives — any `@something(...)` not otherwise matched compiles to `$this->something(...)` on the engine, which forwards to `Manager::useMacro()`. `ViewProvider` registers `escape` and `includes` by default:

```php
$manager->addMacro('escape', fn($value) => htmlspecialchars($value, ENT_QUOTES));
$manager->addMacro('includes', fn(...$params) => print view(...$params));
```

You can register your own macros the same way from a provider (`$app->resolve('view')->addMacro('name', fn(...) => ...)`).

The plain `.php` engine (`PhpEngine`) supports `@extends`-equivalent behavior too, but as real PHP method calls (`$this->extends('layout')`) since there's no compilation step — it's just `extract()` + `include` + output buffering.

## Database: connections & query builder

`app('database')` resolves to a `Framework\Database\Connection\Connection` (either `MysqlConnection` or `SqliteConnection`, chosen by `config('database.default')`). Get a query builder from it with `->query()`, or start from a `Model` (see next section) — most app code does the latter.

```php
$connection = app('database');

$connection->query()->from('products')->select()->all(); // array of assoc arrays
$connection->query()->from('products')->where('id', 5)->first(); // one row, or null
$connection->query()->from('products')->insert(['name', 'description'], [
'name' => 'Space Tour',
'description' => '...',
]);
$connection->query()->from('orders')->where('id', $id)->update(['quantity'], ['quantity' => 3]);
$connection->query()->from('orders')->where('id', $id)->delete();
```

Query builder notes (`framework/Database/QueryBuilder/QueryBuilder.php`):
- `where($column, $value)` (implicit `=`) or `where($column, $comparator, $value)` for `<`, `>`, `<=`, etc. Multiple `where()` calls are AND-ed together.
- `take($limit, $offset = 0)` for pagination; `first()` internally uses `take(1)`.
- All values are bound as named PDO placeholders (`:column`) — never interpolated into SQL, so this is safe against SQL injection as long as you always go through `where()`/`insert()`/`update()`.
- `getLastInsertId()` wraps `PDO::lastInsertId()`.

## Models & relationships

`Framework\Database\Model` is a thin ActiveRecord-style base class. Table name comes from either `protected string $table = '...'` or a `#[TableName('...')]` attribute:

```php
#[TableName('users')]
class User extends Model
{
public function profile(): mixed
{
return $this->hasOne(Profile::class, 'user_id');
}

public function orders(): mixed
{
return $this->hasMany(Order::class, 'user_id');
}
}

class Order extends Model
{
protected string $table = 'orders';

public function user(): mixed
{
return $this->belongsTo(User::class, 'user_id');
}
}
```

### Querying

`Model::query()` (or any static method — `User::where(...)`, `Product::all()`, etc. via `__callStatic`) returns a `ModelCollector`, which wraps the query builder and turns rows back into model instances:

```php
User::find(5); // by id, or null
User::where('email', $email)->first(); // Model|null
Product::all(); // Model[]
Product::where('id', '>', 10)->take(5)->all();
```

### Reading/writing attributes

Attributes are accessed dynamically via `__get`/`__set` — there's no need to declare properties:

```php
$user = new User();
$user->name = 'Chris'; // tracked as "dirty"
$user->email = 'chris@example.com';
$user->password = password_hash('secret1234', PASSWORD_BCRYPT);
$user->save(); // INSERT since there's no id yet; sets $user->id after
```

`save()` does an `UPDATE ... WHERE id = ?` if `$this->attributes['id']` is already set, otherwise an `INSERT` (only dirty attributes are written). `delete()` removes the row by id.

**Accessors/mutators** — define `get{Field}Attribute($value)` / `set{Field}Attribute($value)` to transform data on the way in/out:

```php
class Product extends Model
{
protected string $table = 'products';

public function getNameAttribute($value): string
{
return ucwords($value);
}

public function setDescriptionAttribute(string $value)
{
// truncate long descriptions before they're stored
return mb_strwidth($value, 'UTF-8') <= 50
? $value
: rtrim(mb_strimwidth($value, 0, 50, '', 'UTF-8')) . '...';
}
}
```

**Casts** — `protected array $casts = ['is_confirmed' => fn($v) => (bool) $v];` applies a callable to the raw value every time that attribute is read.

### Relationships

`hasOne`, `hasMany`, and `belongsTo` all return a `Relationship`, which resolves eagerly when accessed as a property (`Model::__get` detects the method and calls `first()`/`all()` on it for you):

```php
$user->profile; // Profile|null — resolves hasOne immediately
$user->orders; // Order[] — resolves hasMany immediately
$order->user; // User|null — resolves belongsTo immediately
```

You can also call the relationship method directly (`$user->orders()`) if you want the underlying `ModelCollector`/`Relationship` object instead of the eagerly-resolved value — useful if you want to chain more query constraints before fetching.

## Migrations

Files in `database/migrations/` are numbered (`001_CreateOrdersTable.php`, `002_...`) and named after a class with a `migrate(Connection $connection)` method. `MigrateCommand` (`framework/Database/Command/MigrateCommand.php`) globs the directory, checks which class names are already logged in the `migrations` table, and runs everything else **in filename order** — so it's safe to run repeatedly as you add new migration files; it only ever applies the ones it hasn't seen before.

```php
class CreateOrdersTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('orders');
$table->id('id');
$table->int('quantity')->default(1);
$table->float('price')->nullable();
$table->bool('is_confirmed')->default(false);
$table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
$table->text('notes');
$table->execute();
}
}
```

Field types (`framework/Database/Migration/Field/*`): `id()`, `int()`, `float()`, `bool()`, `string()`, `text()`, `dateTime()`. Each supports `->nullable()` and `->default(...)` (not `text()`, which can't be nullable). To alter an existing table:

```php
class ChangeQuantity
{
public function migrate(Connection $connection)
{
$table = $connection->alterTable('orders');
$table->int('quantity')->nullable()->alter(); // MODIFY / rebuild existing column
$table->execute();
}
}

class DropPrice
{
public function migrate(Connection $connection)
{
$connection->alterTable('orders')->dropColumn('price')->execute();
}
}
```

Run migrations with:

```
php command.php migrate # run any migration files not yet logged as applied
php command.php migrate --fresh # drop all tables first, then run every migration file
```

Use `--fresh` when you want a clean slate (it drops everything, including data) — day to day, plain `migrate` after pulling new migration files is the normal workflow.

**MySQL vs SQLite**: both connections support the same migration DSL, but they compile very differently under the hood (`MysqlMigration` vs `SqliteMigration`). MySQL can `ALTER`/`DROP COLUMN` directly. SQLite can't do either natively, so `SqliteMigration` rebuilds the table instead — it creates a new table with the desired final schema (using `PRAGMA table_info` to reconstruct unaffected columns), copies the surviving rows across, drops the old table, and renames the new one into place. This all happens transparently; you write the same migration file either way.

## Validation

`validate($data, $rules, $sessionName = 'errors')` (`framework/helpers.php`) runs `Framework\Validation\Manager::validate()`. On success it returns only the keys mentioned in `$rules`; on failure it throws `ValidationException` (caught centrally — see [Error handling](#error-handling)) and clears any previous errors from the session if it now passes.

```php
$data = validate($_POST, [
'email' => ['required', 'email'],
'password' => ['required', 'min:10'],
], 'login_errors');
```

Built-in rules (`framework/Validation/Rule/*`), registered by `ValidationProvider`: `required`, `email` (just checks for `@`), `min:N` (string length). Rules with a `:` take comma-separated params (`min:10` → `params = ['10']`).

Add a custom rule by implementing `Framework\Validation\Rule\Rule` (`validate()` + `getMessage()`) and registering it from a provider:

```php
$app->resolve('validator')->addRule('alpha', new AlphaRule());
```

On a `ValidationException`, `ExceptionHandler::showValidationException()` puts the errors array into the session under `$sessionName` (via `session()->put(...)`, so it's namespaced with the session prefix — see [Sessions & CSRF](#sessions--csrf)) and redirects back to `HTTP_REFERER`. That only works if a `Referer` header is actually present on the request — a real browser form submission sends one automatically, but a bare `curl -d ...` or API call won't, so don't rely on it outside a normal HTML form flow.

Read the errors back out with the `session()` helper, not the raw `$_SESSION` superglobal (see the callout in [Sessions & CSRF](#sessions--csrf) for why that distinction matters):

```blade
@if(session()->has('errors'))
@foreach(session('errors') as $field => $errors)
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
@endforeach
@endif
```

## Sessions & CSRF

`session()` (no args) returns the session driver (`app('session')`); `session($key, $default)` reads a value directly. Only one driver exists — `NativeDriver`, a thin wrapper over PHP's native `$_SESSION`, namespaced with a configurable prefix (`config('session.native.prefix')`, default `framework_`) so framework session keys don't collide with raw `$_SESSION` usage elsewhere in the app.

```php
session()->put('user_id', $user->id);
session('user_id'); // read
session()->has('user_id');
session()->forget('user_id');
session()->flush(); // clears everything under this driver's prefix
```

> **Gotcha:** `session()->put('user_id', ...)` doesn't store to `$_SESSION['user_id']` — it stores to `$_SESSION['framework_user_id']` (prefix from `config('session.native.prefix')`). Anything written through the `session()` helper has to be *read back* through the `session()` helper too (`session('user_id')` / `session()->has('user_id')`); reading the raw `$_SESSION['user_id']` superglobal directly will silently come back empty. The reverse is also true: code that writes straight to `$_SESSION[...]` (bypassing `session()->put()`) won't be visible to `session()->get(...)`. Pick one approach per key and stick to it — mixing them for the same key is the easiest way to end up debugging a value that "isn't there" when it actually is, just under a different key. (This app used to mix the two — the nav partials and the product page checked `$_SESSION[...]` directly and would never see values written via `session()->put()`. That's fixed now; every view and controller in this app goes through the `session()` helper consistently.)

CSRF helpers:

```php
csrf(); // generates a token, stores it in session('token'), returns it — put in a hidden form field
secure(); // throws if $_POST['csrf'] doesn't match session('token'); call this at the top of any state-changing handler
```

## Caching

`app('cache')` gives you the driver named by `config('cache.default')` (default `memory`, an in-process array that doesn't survive the request — mostly useful in tests). `file` persists JSON blobs to `storage/framework/cache/`; `memcache` talks to a real Memcached server.

```php
$cache = app('cache');

if (!$cache->has($key)) {
$cache->put($key, $expensiveValue, 3600); // seconds; omit to use config('cache.<driver>.seconds')
}

$cache->get($key, $default);
$cache->forget($key);
$cache->flush();
```

## Filesystem

`app('filesystem')` wraps [League Flysystem](https://flysystem.thephpleague.com/). Only `local` is actually implemented (`LocalDriver`, rooted at `config('filesystem.local.path')`, i.e. `storage/app`); `s3`/`ftp` config stubs exist but have no matching driver class yet — add one the same way `LocalDriver` was added if you need it.

```php
$fs = app('filesystem');

$fs->put('avatars/1.png', $binaryContents);
$fs->get('avatars/1.png');
$fs->exists('avatars/1.png');
$fs->delete('avatars/1.png');
$fs->list('avatars', recursive: true);
```

## Email

`app('email')` gives a fluent message builder for the configured driver (`config('email.default')`, currently only `postmark`, via SwiftMailer + `wildbit/swiftmailer-postmark`):

```php
app('email')
->to('someone@example.com')
->subject('Welcome')
->text('Thanks for signing up.')
// ->html('<p>...</p>') // text, html, or both (multipart)
->send();
```

Requires `EMAIL_TOKEN`, `EMAIL_FROM_NAME`, `EMAIL_FROM_EMAIL` in `.env`.

## Queue & background jobs

`app('queue')->push($closure, ...$params)` serializes a closure (via `opis/closure`) and its arguments into the `jobs` table (`config('queue.database.table')`) and returns the new job's id:

```php
app('queue')->push(function($name) {
app('email')->to('someone@example.com')->text("Hello {$name}")->send();
}, 'Chris');
```

Run the worker (a blocking loop that polls for jobs, `sleep(1)` between successful runs) with:

```
php command.php queue:work
```

On failure, the job's `attempts` is incremented and it stays eligible for retry until it hits `config('queue.database.attempts')` (default 3), after which `shift()` stops returning it. There's only a `database` driver right now (`DatabaseDriver`), which is why `10_CreateJobsTable.php` exists as a migration — the queue is backed by your app's own database.

## Logging

`app('logging')` is a [Monolog](https://github.com/Seldaek/monolog) logger (`StreamDriver`) writing to `config('logging.stream.path')` (default `storage/app.log`) at `config('logging.stream.minimum')` level and above.

```php
app('logging')->info('Something happened');
app('logging')->warning('...');
app('logging')->error('...');
```

## Error handling

Uncaught exceptions inside a route handler are caught by `Router::dispatch()` and passed to whatever class `config('handlers.exceptions')` points to (`App\Exceptions\Handler` by default, which just extends and delegates to `Framework\Support\ExceptionHandler`):

- **`ValidationException`** → errors are flashed to the session and the response redirects back to `HTTP_REFERER` (see [Validation](#validation)).
- **Anything else, when `APP_ENV=dev`** → re-thrown into a registered [Whoops](https://github.com/filp/whoops) pretty-page handler for a full interactive stack trace in the browser.
- **Anything else in any other environment** → falls through to the router's plain-text 500 handler (`Router::dispatchError()`).

To add your own reporting (e.g. send to an error tracker) without losing the built-in behavior, override `showThrowable()` in `App\Exceptions\Handler` and call `parent::showThrowable($throwable)`:

```php
class Handler extends ExceptionHandler
{
public function showThrowable(Throwable $throwable)
{
// report($throwable) to your monitoring service here...
return parent::showThrowable($throwable);
}
}
```

## CLI commands

`command.php` boots the app the same way `public/index.php` does, then hands off to a Symfony Console `Application` loaded with every class listed in `app/commands.php`:

```
php command.php migrate [--fresh]
php command.php serve [--host=...] [--port=...] # PHP's built-in server, via server.php
php command.php queue:work
```

Add your own by writing a Symfony `Command` class anywhere (`framework/Support/Command/ServeCommand.php` is a good template) and appending it to the array in `app/commands.php`.

## Testing

Tests extend `Framework\Testing\TestCase` (a thin `PHPUnit\Framework\TestCase` wrapper adding `assertExceptionThrown(Closure $risky, string $exceptionType)`, which returns `[$exception, $result]`).

**Simulating a full request** without a real HTTP call — set the superglobals the router reads, then run the app directly:

```php
class RoutingTest extends TestCase
{
public function testHomePageIsShown()
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/';

$this->assertStringContainsString('Take a trip on a rocket ship', app()->run()->content());
}

public function testRegistrationErrorsAreShown()
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/register';
$_SERVER['HTTP_REFERER'] = '/register';
$_POST['email'] = 'foo';
$_POST['csrf'] = csrf();

$response = new TestResponse(app()->run());

$this->assertTrue($response->isRedirecting());
$this->assertEquals('/register', $response->redirectingTo());
}
}
```

`Framework\Testing\TestResponse` wraps a `Response` and adds `isRedirecting()`, `redirectingTo()`, and `follow()` (re-dispatches the app against the redirect target, repeatedly, useful for chaining through multiple redirects in a test).

**Real browser tests** (`tests/BrowserTest.php`) use `symfony/panther` + Firefox against a real running server. `Framework\Testing\ServerExtension` (registered in `phpunit.xml` under `<extensions>`) automatically starts `php command.php serve` before the suite if nothing is already listening on `APP_HOST:APP_PORT`, and stops it after.

## Helper function reference

All defined in `framework/helpers.php`, globally available, no imports needed:

| Helper | Purpose |
|---|---|
| `app($alias = null)` | No args: the `App` singleton. With an alias: `App::resolve($alias)`, e.g. `app('database')`. |
| `view($template, $data = [])` | Resolve and render a template — returns a `View` (stringable). |
| `validate($data, $rules, $sessionName = 'errors')` | Run validation; throws `ValidationException` on failure. |
| `response()` | `app('response')` — a fresh `Framework\Http\Response`. |
| `redirect($url)` | `response()->redirect($url)`. |
| `csrf()` | Generate + store + return a CSRF token. |
| `secure()` | Assert `$_POST['csrf']` matches the session token; throws on mismatch. |
| `dd(...$params)` | `var_dump` then `die`. |
| `basePath()` | `app('paths.base')`. |
| `env($key, $default = null)` | Read an environment variable ($_SERVER → $_ENV → getenv → default). |
| `config($key = null, $default = null)` | No args: the `Config` service. With a dotted key: a config value. |
| `session($key = null, $default = null)` | No args: the session driver. With a key: a stored value. |

+ 12
- 1
framework/Database/Command/MigrateCommand.php Dosyayı Görüntüle

@@ -48,15 +48,26 @@ class MigrateCommand extends Command
$connection = app('database');
}

$applied = [];

if (!$connection->hasTable('migrations')) {
$output->writeln('Creating migrations table');
$this->createMigrationsTable($connection);
} else {
$applied = array_column(
$connection->query()->from('migrations')->select(['name'])->all(),
'name'
);
}

foreach ($paths as $path) {
[$prefix, $file] = explode('_', $path);
[$class, $extension] = explode('.', $file);

if (in_array($class, $applied)) {
continue;
}

require $path;

$output->writeln("Migrating: {$class}");
@@ -69,7 +80,7 @@ class MigrateCommand extends Command
->from('migrations')
->insert(['name'], ['name' => $class]);
}
return Command::SUCCESS;
}



+ 18
- 3
framework/Routing/Router.php Dosyayı Görüntüle

@@ -2,6 +2,7 @@

namespace Framework\Routing;

use Framework\Http\Response;
use Framework\Routing\Exception;
use Throwable;

@@ -89,19 +90,33 @@ class Router
public function dispatchNotAllowed()
{
$this->errorHandlers[400] ??= fn() => 'not allowed';
return $this->errorHandlers[400]();
return $this->respondWithStatus(400, $this->errorHandlers[400]());
}

public function dispatchNotFound()
{
$this->errorHandlers[404] ??= fn() => 'not found';
return $this->errorHandlers[404]();
return $this->respondWithStatus(404, $this->errorHandlers[404]());
}

public function dispatchError()
{
$this->errorHandlers[500] ??= fn() => 'server error';
return $this->errorHandlers[500]();
return $this->respondWithStatus(500, $this->errorHandlers[500]());
}

/**
* Error handlers default to returning a plain string, so if that's what
* we got, wrap it in a Response with the correct status code. A handler
* that already returned its own Response is left alone.
*/
private function respondWithStatus(int $status, mixed $result): Response
{
if ($result instanceof Response) {
return $result;
}

return response()->status($status)->content($result);
}

public function route(string $name, array $parameters = []): string


+ 6
- 2
readme.md Dosyayı Görüntüle

@@ -25,13 +25,17 @@ This starts two services:

The default database connection is **SQLite** (`database/database.sqlite`), configured via `DB_CONNECTION` in `.env` / `docker-compose.yml`. On first boot the `app` container automatically creates the SQLite file with the correct permissions and runs migrations. To switch to MySQL instead, set `DB_CONNECTION=mysql` in `docker-compose.yml`.

To run migrations manually:
To run migrations manually (e.g. after adding a new migration file):

```
docker compose exec app php command.php migrate
docker compose exec app php command.php migrate --fresh # drops all tables first
docker compose exec app php command.php migrate --fresh # drops all tables first, for a clean slate
```

### phpLiteAdmin

A SQLite admin UI is available at http://localhost:8081, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose port 8081 outside your local machine.

## Framework guide

This app is built on a small custom MVC framework in `framework/`. See [docs/framework.md](docs/framework.md) for how routing, controllers, views, models/migrations, validation, sessions, caching, queues, and the CLI commands all work.

+ 2
- 2
resources/views/includes/large-feature.advanced.php Dosyayı Görüntüle

@@ -12,10 +12,10 @@
</div>
<ol class="text-white flex flex-row space-x-2">
<li><a class="underline" href="/">Home</a></li>
@if(isset($_SESSION['user_id']))
@if(session()->has('user_id'))
<li><a class="underline" href="/log-out">Log out</a></li>
@endif
@if(!isset($_SESSION['user_id']))
@if(!session()->has('user_id'))
<li><a class="underline" href="/register">Register</a></li>
@endif
</ol>


+ 2
- 2
resources/views/includes/small-feature.advanced.php Dosyayı Görüntüle

@@ -9,10 +9,10 @@
</a>
<ol class="text-white flex flex-row space-x-2">
<li><a class="underline" href="/">Home</a></li>
@if(isset($_SESSION['user_id']))
@if(session()->has('user_id'))
<li><a class="underline" href="/log-out">Log out</a></li>
@endif
@if(!isset($_SESSION['user_id']))
@if(!session()->has('user_id'))
<li><a class="underline" href="/register">Register</a></li>
@endif
</ol>


+ 2
- 2
resources/views/products/view.advanced.php Dosyayı Görüntüle

@@ -15,9 +15,9 @@
action="{{ $orderAction }}"
class="flex flex-col w-full space-y-4 max-w-xl"
>
@if(isset($_SESSION['errors']))
@if(session()->has('errors'))
<ol class="list-disc text-red-500">
@foreach($_SESSION['errors'] as $field => $errors)
@foreach(session('errors') as $field => $errors)
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach


Yükleniyor…
İptal
Kaydet

Powered by TurnKey Linux.