25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

37KB

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 and then the tutorial 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

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

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

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 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.

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

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

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). 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). 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):

@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).

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 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).

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:

// 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:

$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). 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:

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:

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

Framework\Support\Config (framework/Support/Config.php) lazily requires 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:

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:

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) 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).
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):

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):

@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:
$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.

$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:

#[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:

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:

$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:

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')) . '...';
    }
}

Castsprotected 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):

$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.

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:

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) and clears any previous errors from the session if it now passes.

$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:10params = ['10']).

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

$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) 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 for why that distinction matters):

@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.

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:

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.

$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. 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.

$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):

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:

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 logger (StreamDriver) writing to config('logging.stream.path') (default storage/app.log) at config('logging.stream.minimum') level and above.

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).
  • Anything else, when APP_ENV=dev → re-thrown into a registered 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):

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:

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.

Powered by TurnKey Linux.