liteflow/core

LitePHP - A lightweight PHP framework

Maintainers

Package info

github.com/Lazycoder229/liteflow

pkg:composer/liteflow/core

Transparency log

Statistics

Installs: 1

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-06 00:05 UTC

This package is auto-updated.

Last update: 2026-08-06 00:21:48 UTC


README

LiteFLOW is a lightweight, zero-runtime-dependency PHP framework (PHP 8.1+) for building server-rendered web apps and JSON APIs. It ships its own IoC container, router, PDO-based ORM, template engine, validator, auth guards, mailer, queue, and CLI — no Composer packages required at runtime.

Design choices worth knowing up front:

  • Zero runtime dependencies. Routing, ORM, validation, templating, mailing, and JWTs are all hand-rolled — nothing to composer require to get a working app.
  • Security-by-default. CSRF protection, security headers, mass-assignment protection, and SSRF-guarded outbound HTTP are all on out of the box, not opt-in.
  • Convention over configuration, but every convention (table names, view paths, middleware groups) can be overridden.
  • Not Laravel. Method names look familiar (Route::get, Model::where, $request->validate()) but the internals, defaults, and edge-case behavior are LiteFLOW's own — don't assume Laravel semantics apply.

Installation

LiteFLOW core is normally installed for you by the app template, not added by hand:

composer create-project liteflow/app sagana-market

To add the core package to an existing project:

composer require liteflow/core

Requirements: PHP 8.5, the PDO and Fileinfo extensions.

Your app's public/index.php must define APP_BASE_PATH and require the framework's autoload.php before anything else runs — the autoloader, config loader, and storage paths all depend on it:

<?php
// public/index.php
define('APP_BASE_PATH', dirname(__DIR__));

require APP_BASE_PATH . '/vendor/liteflow/core/autoload.php';
require APP_BASE_PATH . '/vendor/liteflow/core/Core/Bootstrap/app.php';

Bootstrap/app.php runs the entire boot sequence in a fixed order (storage → env → config → cache → error handler → container → session → auth → routes → middleware/kernel) and ends by calling $kernel->handle().

Quickstart

1. Define a route in app/Routes/web.php:

use Core\Facades\Route;
use App\Controllers\ListingController;

Route::get('/listings', [ListingController::class, 'index'])->name('listings.index');

2. Write the controller in app/Controllers/ListingController.php:

namespace App\Controllers;

use Core\Controller;
use Core\Http\Request;
use App\Models\Listing;

class ListingController extends Controller
{
    public function index(Request $request): void
    {
        $listings = Listing::latest()->limit(20)->get();

        $this->view('listings.index', ['listings' => $listings]);
    }
}

3. Add the view at app/views/listings/index.lites:

@extends('layouts.app')

@section('content')
    <h1>Fresh from Sagana Market</h1>
    @foreach($listings as $listing)
        <p>{{ $listing->title }} — ₱{{ $listing->price }}</p>
    @endforeach
@endsection

That's a full request/response cycle: routed, validated container-injected controller, hydrated model, rendered template.

Table of Contents

  1. Application Bootstrap & Container
  2. Routing
  3. Controllers
  4. HTTP: Request & Response
  5. Validation
  6. Database: Query Builder
  7. Database: Models (ORM)
  8. Database: Migrations & Schema Builder
  9. Database: Seeders & Factories
  10. Authentication
  11. Authorization (Gate)
  12. Middleware
  13. Views: The .lites Template Engine
  14. View Components
  15. Sessions, Flash Data & CSRF
  16. File Uploads
  17. Mail
  18. Queue & Background Jobs
  19. Events
  20. Cache
  21. Outbound HTTP Client
  22. Support Utilities (Arr, Str, Collection, Hash)
  23. Exceptions & Error Handling
  24. CLI Reference (php lite)
  25. Global Helper Functions
  26. Configuration Reference
  27. FAQ / Common Gotchas

Application Bootstrap & Container

Boot sequence

Core/Bootstrap/app.php runs once per HTTP request, in this fixed order: Storage → Env → Config → Cache → Error handler → Container → Facade wiring → Session → Auth → Routes → Kernel/Middleware → $kernel->handle(). Each step assumes the ones before it already ran, so nothing in this list is safely reorderable from application code.

CLI commands use a lighter counterpart, Core/Bootstrap/container.php, which only builds a Container with Core\Database bound as a singleton — no Request, Response, Session, or Router.

Core\Container

A reflection-based IoC container with auto-wiring (constructor type-hints are resolved recursively) and circular-dependency detection.

Method Signature Description
bind bind(string $abstract, callable $resolver): void Register a resolver; a new instance is built on every make().
singleton singleton(string $abstract, callable $resolver): void Like bind(), but the resolver runs once and the result is cached.
instance instance(string $abstract, object $object): void Register an already-built object.
alias alias(string $alias, string $abstract): void Map a short string name to a class name for make('name') lookups.
has has(string $abstract): bool True if bound, instantiated, or the class exists and is autowireable.
make make(string $abstract): mixed Resolve an instance, auto-wiring constructor dependencies via reflection. Throws RuntimeException on circular dependencies or unresolvable scalar parameters.
call call(callable $callback, array $params = []): mixed Invoke a callable, auto-injecting typed (non-builtin) parameters from the container and filling the rest from $params in order.
$container->singleton(PaymentGateway::class, fn() => new StripeGateway(env('STRIPE_KEY')));

$gateway = app(PaymentGateway::class); // resolved once, reused every call

The app() helper

app() is the global entry point into the container:

app();                    // returns the Container itself
app(Request::class);      // resolves and returns a Request instance
app($container);          // (bootstrap only) registers the container globally

Calling app(SomeClass::class) anywhere in your controllers/models/services works without threading $container through every function signature — it's backed by a static variable inside the helper, set once during bootstrap.

Core\App

A thin convenience wrapper around the container, primarily used internally:

public function make(string $abstract): mixed
public function router(): Router
public function kernel(): Kernel
public function run(): mixed   // resolves Kernel and calls handle()

Core\Support\ServiceProvider

Extend this to group related bindings and boot-time logic:

class MarketServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->container->singleton(PricingService::class, fn() => new PricingService());
    }

    public function boot(): void
    {
        Gate::define('manage-listing', fn($user, $listing) => $user['id'] === $listing->farmer_id);
    }
}

Core\Support\Facade

Base class for building a static proxy over a container-resolved service:

class Pricing extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return PricingService::class;
    }
}

Pricing::calculateTotal($cart); // → app(PricingService::class)->calculateTotal($cart)

Routing

Routes are registered via the Route facade (Core\Facades\Route), which proxies to the current Router instance.

Registering routes

use Core\Facades\Route;

Route::get('/listings', [ListingController::class, 'index']);
Route::post('/listings', [ListingController::class, 'store']);
Route::put('/listings/{id}', [ListingController::class, 'update']);
Route::patch('/listings/{id}', [ListingController::class, 'update']);
Route::delete('/listings/{id}', [ListingController::class, 'destroy']);

Each registration accepts either a [Controller::class, 'method'] array or a Closure. Route parameters use {name} syntax and match any characters except /.

Only controller actions can be route-cached. A route registered with a Closure throws when Route::cache()/->cache() is called (see CLI Referenceroute:cache) — convert closures to controller methods before deploying to production with route caching enabled.

Route parameters & type coercion

Route parameters are matched positionally against the controller method's (or closure's) typed parameters. Scalar type hints (int, float, bool, string) are coerced automatically; non-builtin type hints are resolved from the container instead of consuming a route parameter:

Route::get('/listings/{id}', [ListingController::class, 'show']);

public function show(Request $request, int $id): void   // $id auto-cast to int

Naming & generating URLs

Route::get('/listings/{id}', [ListingController::class, 'show'])->name('listings.show');

route('listings.show', ['id' => 42]);          // helper — "/listings/42"
app(Router::class)->url('listings.show', [42]); // equivalent, direct call

Router::url() throws if the name isn't registered. Named routes are also persisted into the route cache, so route() keeps working after a cache boot without re-parsing web.php.

Route middleware

Route::get('/account', [AccountController::class, 'edit'])
    ->middleware('auth');

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:5,1');   // parametric middleware: max 5 attempts per 1 minute

Middleware strings support name:param1,param2 syntax; parameters are passed positionally to the middleware's constructor.

Route groups

Route::group('/admin', ['auth', 'admin'], function () {
    Route::get('/dashboard', [Admin\DashboardController::class, 'index']);
    Route::get('/farmers', [Admin\FarmerController::class, 'index']);
});

Groups nest: prefixes concatenate and middleware arrays merge. Note that group middleware is attached to each Route object directly (via array_merge into $route->middleware) — there is currently no separate "group name" tagging mechanism that the Kernel's groupMiddleware['web'|'api'] stacks key off of; those stacks are applied globally by default (see Middleware).

Fallback routes

Route::fallback(function () {
    return response()->view('errors.404', [], 404);
});

Invoked when no route matches. If no fallback is registered, the Kernel renders errors.404 itself.

Route caching

$cache = new RouteCache(storage_path('cache/data/routes_myapp.cache'));
$router->cache($cache);              // write compiled route table (HMAC-signed)
$router->bootFromCache($cache);      // read it back, skipping web.php entirely

Bootstrap/app.php does this automatically: in production (APP_ENV=production or APP_DEBUG falsy), it tries bootFromCache() first and only falls back to re-require-ing app/Routes/web.php on a cache miss, then writes a fresh cache. The cache file is HMAC-SHA256-signed with APP_KEY; a tampered file is detected, deleted, and rejected rather than trusted.

Controllers

Controllers extend Core\Controller, which supplies a set of protected helpers — you don't call Response/Request directly in most cases.

namespace App\Controllers;

use Core\Controller;
use Core\Http\Request;

class ListingController extends Controller
{
    public function store(Request $request): void
    {
        $data = $this->validate([
            'title' => 'required|string|max:120',
            'price' => 'required|numeric|min:0',
        ]);

        Listing::create($data + ['farmer_id' => auth_id()]);

        $this->redirectRoute('listings.index');
    }
}
Method Signature Description
view view(string $view, array $data = []): never Renders a .lites template and sends it. Automatically merges flashed _errors and _old into $data.
json json(array $data, int $status = 200): never Sends a JSON response.
redirect redirect(string $url): never 302 redirect to an absolute path/URL.
redirectRoute redirectRoute(string $name, array $params = []): never Redirect to a named route.
back back(): never Redirect to the referring page — validated against the current request's own host (open-redirect safe), falling back to /.
request request(): Request Returns the current Request instance.
validate validate(array $rules): array Shortcut for $this->request()->validate($rules).
route route(string $name, array $params = []): string Shortcut for generating a named-route URL.
abort abort(int $code = 404, string $message = ''): never Sends an error page (app/Views/errors/{code}.php if present, else plain text) and exits.

All of these are protected, so they're only usable from inside a Controller subclass — instantiate helpers directly (app(Response::class)) if you need them elsewhere.

Controller constructors are resolved through the container, so you can type-hint services:

class ListingController extends Controller
{
    public function __construct(private ListingService $service) {}
}

HTTP: Request & Response

Core\Http\Request

One Request instance per HTTP request, bound into the container and injectable into any controller method.

Method Description
method(): string HTTP verb. Honors _method spoofing (PUT/PATCH/DELETE) on POST requests.
path(): string Request path, no query string.
url(): string Full URL (scheme + host + path).
safeRedirectBack(string $default = '/'): string Validated Referer-based redirect target — same-host-and-port only, else $default. Open-redirect safe.
query(?string $key = null, mixed $default = null): mixed $_GET access.
input(?string $key = null, mixed $default = null): mixed Merged GET+POST+JSON body.
all(): array The full merged input array ($_GET, $_POST, then JSON body — later sources win on key collision).
only(array $keys): array / except(array $keys): array Filtered subset of all().
has(string $key): bool Presence check across all().
json(?string $key = null, mixed $default = null): mixed Parsed JSON body (only when Content-Type: application/json). Body is capped at 2 MB; larger bodies throw a RuntimeException (413).
isJson(): bool / isAjax(): bool Content negotiation helpers.
bearerToken(): ?string Authorization: Bearer <token> value, or null.
header(string $key, mixed $default = null): mixed Request header lookup (Content-TypeHTTP_CONTENT_TYPE under the hood).
ip(): string Client IP — only trusts X-Forwarded-For/X-Client-IP when the direct peer is a configured trusted proxy (app.trusted_proxies), and walks a multi-hop X-Forwarded-For chain from the right to find the first non-trusted entry.
file(string $key): ?UploadedFile / hasFile(string $key): bool Upload access — see File Uploads.
setParams(array $params): void / params(): array / param(int $index = 0, mixed $default = null): mixed Route parameters (positional legacy access via param()).
validate(array $rules): array Runs the Validator; on failure, redirects back (web) or sends a 422 JSON response (API) — never returns on failure. Returns the validated subset on success.

Core\Http\Response

Built up over the request lifecycle, then sent exactly once via send().

Method Description
setStatusCode(int $code): static / status(): int HTTP status.
withHeader(string $key, string $value): static / withHeaders(array $headers): static Queue response headers (fluent).
setContent(string $content): static / getContent(): string Set/read the response body.
send(): never Emits status + headers + body and exits. All other response methods below call this internally.
json(mixed $data, int $code = 200): never JSON response (JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES).
html(string $content, int $code = 200): never Raw HTML response.
view(string $view, array $data = []): never Renders and sends a .lites template — merges ViewFactory::getShared() under the explicit $data.
redirect(string $url, int $code = 302): never Location redirect.
back(string $fallback = '/'): never Same-host-validated redirect to the referring page.
download(string $filePath, string $fileName = ''): never Streams a file as an attachment. Only files under storage_path() are allowed — the resolved real path must be inside the storage root or a RuntimeException is thrown.
noContent(): never 204 with no body.
abort(int $code, string $message = ''): never Minimal error response with a default message table for common codes.

Core\Http\JsonResponse

Structured JSON response shortcuts for APIs — every method ends the request via the underlying response()->json():

JsonResponse::success($data, 'Listing created', 201);
JsonResponse::created($data);
JsonResponse::error('Insufficient stock', 400);
JsonResponse::notFound();
JsonResponse::unauthorized();
JsonResponse::forbidden();
JsonResponse::validationError($errors);
JsonResponse::paginated($items, $meta);
JsonResponse::noContent();

Core\Http\FormRequest

Extend for reusable, self-validating request objects with an authorization gate:

class StoreListingRequest extends FormRequest
{
    public function authorize(): bool
    {
        return Auth::check();
    }

    public function rules(): array
    {
        return [
            'title' => 'required|string|max:120',
            'price' => 'required|numeric|min:0',
        ];
    }
}

Call $request->validateForm() to run both authorize() (throwing ForbiddenException on false) and validation, then ->validated() to retrieve the checked data.

Core\Http\Paginator

Wraps a result set with page metadata:

$paginator = Listing::query()->paginate(perPage: 15, page: (int) $request->query('page', 1));
Method Description
items(): array Current page's records.
total(), perPage(), currentPage(), lastPage() Counts.
hasMore(), hasPrev(), nextPage(), prevPage() Navigation helpers.
from(), to() 1-based item range on the current page.
toArray(): array JSON-ready shape (data, total, per_page, current_page, last_page, from, to, has_more).
links(string $pageParam = 'page'): string Bootstrap-compatible <nav> pagination HTML. All $_GET values used to build links are HTML-escaped.

Validation

Quick validation from a controller

$data = $this->validate([
    'title'       => 'required|string|max:120',
    'price'       => 'required|numeric|min:0',
    'category_id' => 'required|integer|exists:categories,id',
    'photo'       => 'nullable|file|mimes:jpg,png,webp',
]);

Rules are pipe-delimited strings ('required|min:3') or arrays mixing strings and Rule objects. On failure, Request::validate() never returns: it flashes _errors/_old and redirects back for web requests, or sends a 422 with an errors object for JSON/AJAX requests.

Standalone usage

use Core\Validation\Validator;

$validator = new Validator($data, ['email' => 'required|email']);

if ($validator->fails()) {
    $errors = $validator->errors();        // array<string, string[]>
} else {
    $clean = $validator->validated();       // only fields with no errors
}

Or via the Factory, which throws instead of branching:

use Core\Validation\Factory;

$clean = Factory::validate($data, $rules); // throws ValidationException on failure

Built-in string rules

Rule Params Notes
required Fails on null, '', or [].
nullable Marks the field as optional (pairs with other rules).
sometimes Skips remaining rules entirely if the key is absent from input.
string Letters only (spaces ignored) — not a generic "is a string" check; see Gotchas.
integer FILTER_VALIDATE_INT.
numeric is_numeric().
boolean Accepts true/false/0/1/'0'/'1'/'true'/'false'.
array is_array().
email FILTER_VALIDATE_EMAIL.
url FILTER_VALIDATE_URL.
min:N int Numbers compared by value; strings/arrays by length/count.
max:N int Same size semantics as min.
in:a,b,c list Strict in_array check.
confirmed Requires a matching {field}_confirmation input.
regex:pattern PCRE pattern ReDoS-guarded — backtrack limit capped at 100,000 steps per call.
date Y-m-d format by default (class-based Date rule accepts a custom format).
after:field_or_date field name or literal date
before:field_or_date field name or literal date
digits_between:min,max two ints Digit-count range (non-digits stripped first).
unique:table,column table, column (column optional, defaults to field name) Table/column validated against a strict identifier allowlist before querying.
exists:table,column table, column (optional)
file Requires a genuine is_uploaded_file() entry.
mimes:jpg,png,... extension list Detected via finfo magic bytes, not client-supplied type/filename.
image Shorthand for mimes:jpg,jpeg,png,gif,webp,svg.

Class-based rules

For cases needing constructor state, implement Core\Validation\Rule or use the shipped ones directly:

'avatar' => ['required', new \Core\Validation\Rules\Mimes(['jpg', 'png'], maxBytes: 2 * 1024 * 1024)],

Mimes (class-based) additionally enforces a max file size and accepts either an UploadedFile instance or a raw $_FILES entry — useful outside the string-rule pipeline (e.g. inside a FormRequest).

Every Rule implements:

interface Rule
{
    public function passes(string $field, mixed $value, Validator $validator): bool;
    public function message(string $field): string;
}

Database: Query Builder

Core\Database\QueryBuilder is a fluent, parameterized SQL builder for MySQL. Every identifier (table/column) passed through it is validated against an allowlist regex (^[a-zA-Z_][a-zA-Z0-9_]*$, dot-notation permitted for table.column) before being backtick-quoted — user input never reaches SQL as a raw identifier.

$listings = Listing::query()
    ->where('status', 'active')
    ->where('price', '<=', 500)
    ->orWhereGroup(fn($q) => $q->where('featured', true)->where('stock', '>', 0))
    ->orderBy('created_at', 'DESC')
    ->limit(20)
    ->get();

Filtering

Method Signature
select select(string ...$columns): static
where / orWhere where(string $column, mixed $operatorOrValue, mixed $value = null): static — 2-arg form is col = value; 3-arg form takes an explicit operator (=,!=,<>,<,>,<=,>=,LIKE,NOT LIKE).
whereGroup / orWhereGroup whereGroup(callable $callback, string $boolean = 'AND'): static — parenthesized nested conditions.
whereNull / whereNotNull whereNull(string $column): static
whereIn / whereNotIn whereIn(string $column, array $values): static — empty $values produces an always-false (whereIn) or no-op (whereNotIn) condition rather than invalid SQL.
whereBetween whereBetween(string $column, mixed $min, mixed $max): static
whereLike whereLike(string $column, string $value): static
join / leftJoin / rightJoin join(string $table, string $first, string $operator, string $second): static
groupBy / having Standard SQL aggregation clauses.
orderBy / latest / oldest orderBy(string $column, string $direction = 'ASC'): static
limit / offset Pagination primitives.
with with(string ...$relations): static — eager-load relation methods (see Models).

Execution

Method Returns
get(): array object[] — hydrated model instances.
first(): ?object Single result or null. Respects with().
firstOrFail(): object Throws RuntimeException if empty.
count(string $column = '*'): int, sum, avg, min, max Aggregates.
paginate(int $perPage = 15, int $page = 1): array ['data', 'total', 'per_page', 'current_page', 'last_page', 'from', 'to'].

Eager loading (N+1 avoidance)

with('posts') batches relation loading into one extra query per relation instead of one per row:

  • hasMany/hasOne relations are batched via a single WHERE foreign_key IN (...) query, then grouped back onto each parent.
  • belongsTo relations are batched per unique foreign-key value (not per row).
  • belongsToMany relations fall back to one query per model (pivot-table batching isn't implemented).

Database: Models (ORM)

namespace App\Models;

use Core\Database\Model;

class Listing extends Model
{
    protected static string $table    = 'listings';
    protected static array  $fillable = ['title', 'price', 'category_id', 'farmer_id'];
}

Table name resolution

If $table is left blank, the table name is derived from the class's short name converted to snake_case and pluralized (Listinglistings, Categorycategories). Pluralization handles English irregulars, -y, -f/-fe, -o, and -s/-x/-z/-ch/-sh endings.

Mass assignment — deny by default

protected static array $fillable = [];      // columns allowed through fill()/create()/update()
protected static array $guarded  = ['*'];   // '*' = block everything not explicitly $fillable

A model with no $fillable accepts nothing via mass assignment. You must declare $fillable explicitly to accept any attacker-controlled input via fill(), create(), or update(). Direct property access ($model->role = 'admin') and DB hydration bypass this check entirely — it only governs the fill()-based entry points.

Serialization

protected static array $hidden = ['password_hash']; // excluded from toArray()/toJson(), still readable via ->password_hash

Query proxies (static shortcuts)

Every QueryBuilder filtering/ordering method is proxied statically on the model, so Listing::where(...) is shorthand for Listing::query()->where(...):

Listing::where('status', 'active')->orderBy('price')->get();
Listing::whereIn('category_id', [1, 2, 3])->get();
Listing::with('farmer')->limit(10)->get();

Direct finders

Method Description
all(): static[] All rows, unfiltered.
find(int|string $id): ?static By primary key.
findOrFail(int|string $id): static Throws RuntimeException if missing.
count(): int Unfiltered row count (use ::where(...)->count() for filtered).
create(array $data): static new static($data) + save().
firstOrCreate(array $search, array $extra = []): static
updateOrCreate(array $search, array $data = []): static

Persistence

$listing = new Listing(['title' => 'Kalamansi (1kg)', 'price' => 60]);
$listing->save();           // INSERT — sets id, created_at, updated_at

$listing->update(['price' => 55]);   // fill() + save()
$listing->delete();
Listing::destroy($id);

$timestamps (default true) auto-manages created_at/updated_at on save().

Relations

class Farmer extends Model
{
    public function listings(): QueryBuilder { return $this->hasMany(Listing::class, 'farmer_id'); }
}

class Listing extends Model
{
    public function farmer(): ?object { return $this->belongsTo(Farmer::class, 'farmer_id'); }
}

class Order extends Model
{
    public function items(): QueryBuilder { return $this->hasMany(OrderItem::class, 'order_id'); }
}
Method Description
hasMany(string $related, string $foreignKey): QueryBuilder One-to-many.
belongsTo(string $related, string $foreignKey, string $ownerKey = 'id'): ?object Many-to-one.
hasOne(string $related, string $foreignKey): ?object One-to-one.
belongsToMany(string $related, string $pivotTable, string $foreignKey, string $relatedKey): array Many-to-many via pivot table (all identifier args validated).
attach(string $pivotTable, string $foreignKey, string $relatedKey, int|string $relatedId, array $extra = []): void Insert a pivot row (INSERT IGNORE); $extra array keys are validated as identifiers too.
detach(string $pivotTable, string $foreignKey, string $relatedKey, int|string $relatedId): void Remove a pivot row.

Transactions

Listing::transaction(function () {
    $order = Order::create([...]);
    OrderItem::create(['order_id' => $order->id, ...]);
});

transaction() wraps beginTransaction()/commit(), rolling back automatically if the callback throws (and re-throwing after rollback).

Soft deletes

class Listing extends Model
{
    use \Core\Database\SoftDeletes;
}

$listing->delete();          // sets deleted_at, doesn't remove the row
$listing->restore();
$listing->forceDelete();     // real DELETE
$listing->trashed(): bool;

Listing::all();              // excludes soft-deleted rows automatically
Listing::withTrashed()->get();
Listing::onlyTrashed()->get();

Requires a deleted_at column ($table->softDeletes() in a migration). All static finders (all, find, findOrFail, count) are overridden by the trait to route through the soft-delete-aware query().

Database: Migrations & Schema Builder

// database/migrations/2026_01_15_000000_create_listings_table.php
use Core\Database\Migration;
use Core\Database\Schema\Blueprint;

return new class extends Migration {
    public function up(): void
    {
        $this->schemaCreate('listings', function (Blueprint $table) {
            $table->id();
            $table->foreignId('farmer_id')->constrained('farmers');
            $table->string('title');
            $table->text('description')->nullable();
            $table->decimal('price', 8, 2);
            $table->enum('status', ['draft', 'active', 'sold_out'])->default('draft');
            $table->softDeletes();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        $this->dropTable('listings');
    }
};

Run with php lite migrate (see CLI Reference).

Blueprint column methods

All accept a string $name (validated against ^[a-zA-Z_][a-zA-Z0-9_]*$) and return static for chaining. $table->lastCol tracks the most recently defined column so modifiers below apply to it.

Column type Method
Primary key id(string $name = 'id'), uuid(string $name = 'id')
Strings string($name, int $length = 255), char($name, int $length = 1), text($name), longText($name), json($name)
Numbers integer($name, bool $unsigned = false), bigInteger(...), tinyInteger($name), float($name, $total=8, $places=2), decimal($name, $total=10, $places=2), boolean($name)
Enum enum($name, array $values) — values are SQL-literal-escaped.
Dates timestamp($name), timestamps() (adds created_at/updated_at), date($name), dateTime($name)
Soft deletes softDeletes(string $col = 'deleted_at')
Foreign keys foreignId($name) then ->constrained(string $table, string $referencedCol = 'id') — adds ON DELETE CASCADE.

Modifiers (apply to the last-defined column): nullable(), default(mixed $value), unique(), index(), unsigned().

toSql(string $table, bool $ifNotExists = true): string compiles the full CREATE TABLE statement; getColumns(): array returns the raw column-definition map for Migration::createTable().

Migration base class

Method Description
up() / down() Abstract — your migration logic and its reverse.
schemaCreate(string $table, callable $callback): void Builds via Blueprint, then createTable().
createTable, dropTable, addColumn, dropColumn, addIndex, addUniqueIndex Raw-SQL fallbacks for cases the schema builder doesn't cover.

Database: Seeders & Factories

Seeders

class DatabaseSeeder extends \Core\Database\Seeder
{
    public function run(): void
    {
        $this->call(FarmerSeeder::class);
        $this->call(ListingSeeder::class);
    }
}

class FarmerSeeder extends \Core\Database\Seeder
{
    public function run(): void
    {
        FarmerFactory::new()->count(5)->create();
    }
}

Run via php lite db:seed (all seeders, expects a DatabaseSeeder class) or php lite db:seed --class=FarmerSeeder.

Factories

class ListingFactory extends \Core\Database\Factory
{
    protected string $model = Listing::class;

    public function definition(): array
    {
        return [
            'title' => ucfirst($this->faker->word()) . ' bundle',
            'price' => $this->faker->number(20, 500),
        ];
    }
}

ListingFactory::new()->create();                 // persists 1
ListingFactory::new()->count(10)->create();       // persists 10
ListingFactory::new()->state(['status' => 'active'])->make(); // array, no DB write

The built-in $this->faker is minimal — name(), uniqueEmail(), word(), sentence(), paragraph(), number($min, $max). There is no third-party Faker dependency.

Authentication

LiteFLOW ships three independent auth guards. Pick the one matching your context — they don't share state with each other.

Core\Auth\Auth — session-based ("web guard")

// Bootstrap/app.php already calls this from config/auth.php:
Auth::configure(App\Models\Farmer::class, usernameField: 'email', passwordField: 'password_hash');

// In a login controller:
if (Auth::attempt(['email' => $email, 'password' => $password])) {
    return $this->redirectRoute('dashboard');
}
Method Description
configure(string $model, string $usernameField = 'email', string $passwordField = 'password'): void Call once at bootstrap.
attempt(array $credentials): bool Looks up by username field, verifies via password_verify(), stores the user (password field stripped) in the session on success. Timing-attack resistant: runs password_verify() against a dummy hash even when no user is found, so "no such user" and "wrong password" take equal time.
login(array $userData): void Establish a session directly, bypassing password verification (for OAuth callbacks, magic links, etc.).
logout(): void Destroys the session.
user(): ?array, id(): int|string|null Current authenticated user data / id.
check(): bool, guest(): bool Auth state checks.

Core\Auth\JwtGuard — stateless JWT (HS256, zero dependencies)

$token   = jwt_issue(['sub' => $farmer->id, 'role' => 'farmer'], ttl: 3600);
$payload = jwt_verify($token);   // null on invalid/expired, no throw
Method Description
issue(array $claims, int $ttl = 3600): string Stamps iat/nbf/exp automatically.
verify(string $token): array Throws RuntimeException on any failure (malformed, wrong alg, bad signature, expired, not-yet-valid). Only HS256 is ever accepted — alg: none is rejected before signature checking.
decode(string $token): ?array Non-throwing wrapper around verify().

Secret is APP_JWT_SECRET (falls back to APP_KEY) and must be ≥32 characters.

Core\Auth\TokenGuard — API bearer tokens

TokenGuard::configure(App\Models\Farmer::class, tokenColumn: 'api_token');

// Issuing (e.g. on registration):
$issued = TokenGuard::issue();          // ['token' => plaintext, 'hash' => sha256]
$farmer->api_token = $issued['hash'];   // store the HASH only
$farmer->save();
return response()->json(['token' => $issued['token']]); // show plaintext once

// In middleware:
$user = TokenGuard::fromRequest($request); // null if missing/invalid

Tokens are only ever accepted via the Authorization: Bearer <token> header — not a query-string fallback, since query strings leak into access logs and Referer headers.

Core\Auth\PasswordReset — forgot-password flow

PasswordReset::configure(App\Models\Farmer::class);

$token = PasswordReset::createToken($email);   // mail this to the user
Mailer::send(new ResetPasswordMail($token));

// On the reset form submit:
$ok = PasswordReset::reset($email, $token, $newPassword);

Only a SHA-256 hash of the token is ever persisted. Requires a password_resets table (email, token, created_at). On success, the session is regenerated (session-fixation defense) and the row is deleted (one-time use).

Authorization (Gate)

use Core\Auth\Gate;

Gate::define('manage-listing', fn(array $user, Listing $listing) => $user['id'] === $listing->farmer_id);
Gate::role('admin', ['manage-listing', 'manage-farmers']);

Gate::allows('manage-listing', $listing);   // bool
Gate::denies('manage-listing', $listing);   // inverse
Gate::authorize('manage-listing', $listing); // throws ForbiddenException on deny
Gate::hasRole('admin');                      // checks $user['role'] directly

Resolution order: no user → false; user's role field listed under Gate::role() for this ability → true; else fall through to the define() callback if registered; no match → false (fail closed). By default the "current user" is Auth::user(); override with Gate::userResolver(fn() => ...) for CLI/queue contexts with no session.

Middleware

Middleware implements Core\Middleware\Middleware:

class VerifyFarmerVerified implements \Core\Middleware\Middleware
{
    public function handle(Request $request, Response $response, callable $next): mixed
    {
        if (!(auth()['is_verified'] ?? false)) {
            return $response->redirect(route('verification.pending'));
        }
        return $next($request, $response);
    }
}

Registration

// Global — every request
$kernel->global([SecurityHeadersMiddleware::class]);

// Group — applied when a route belongs to that group
$kernel->group('web', [VerifyCsrfToken::class]);

// Alias — short name usable in Route::middleware('name')
$kernel->alias('verified', VerifyFarmerVerified::class);

Or register via attribute (auto-discovered from app/Middleware):

use Core\Middleware\Attributes\RegisterMiddleware;

#[RegisterMiddleware(group: 'web', alias: 'verified')]
class VerifyFarmerVerified implements Middleware { /* ... */ }

Kernel::autoDiscover() scans the middleware directory on boot and wires up any class carrying #[RegisterMiddleware], or falls back to folder-name convention (Global/, Api/, else web).

Shipped middleware

Class Purpose
SecurityHeadersMiddleware Global by default. Emits CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS (HTTPS only), COOP/CORP. Extend and override $cspDirectives to customize.
VerifyCsrfToken On the web group by default. Skips GET/HEAD/OPTIONS and bearer-token JSON API requests. Accepts the token from _csrf_token/_token body fields or X-CSRF-TOKEN/X-XSRF-TOKEN headers. Rotates the token after every successful verification (5-second grace window for the previous token).
CorsMiddleware Reads config('cors'). Never emits Allow-Credentials: true alongside a wildcard-matched origin, even if configured that way.
ThrottleMiddleware ->middleware('throttle:60,1') = 60 requests/minute. Atomic (file-lock-based) counter — no race condition under concurrent requests. Redirects back (web) or returns JSON 429 (API/AJAX) on limit.

Pipeline & resolution order

For a matched route, the effective middleware stack is: globalgroup (based on $route->group, currently unset by default route registration — see note in Routing) → route-specific (with aliases resolved). Core\Middleware\Pipeline::run() wraps them in reverse via array_reduce and executes them nested, standard "onion" middleware semantics.

Views: The .lites Template Engine

Core\View\Compiler compiles .lites files (found under app/views/) into plain PHP, caches the compiled output under storage/cache/views/, and requires it. Files are only recompiled when the source changes (dev mode) or never once cached (VIEW_CACHE=true, production mode).

Layouts & sections

{{-- app/views/layouts/app.lites --}}
<!DOCTYPE html>
<html>
<body>
    @yield('content')
</body>
</html>
{{-- app/views/listings/index.lites --}}
@extends('layouts.app')

@section('content')
    <h1>Listings</h1>
@endsection

{{-- Inline form: --}}
@section('title', 'Sagana Market')

Echoing

Syntax Compiles to
{{ $value }} htmlspecialchars((string)($value), ENT_QUOTES, 'UTF-8') — always escaped.
{!! $rawHtml !!} Raw, unescaped output — only use for trusted content.
{ url('/') }, { asset('x.css') }, { route(...) }, { env(...) }, { config(...) }, { old(...) }, { session(...) }, { auth(...) } Strict single-brace form, whitelisted to specific helpers only (avoids colliding with raw CSS/JS in the template).

Control flow

@if(...) @elseif(...) @else @endif, @foreach(...) @endforeach, @for(...) @endfor, @while(...) @endwhile — all compile to the equivalent PHP alternate-syntax control structures.

Includes & comments

@include('partials.listing-card', ['listing' => $listing])
{{-- this comment is stripped entirely --}}

Included/child views automatically receive the parent's data via $__parent unless you pass an explicit second argument (which is merged on top).

Stacks (append-only sections)

Unlike @section (last write wins), @push/@stack append, useful for per-page <meta>/<script> contributions across an @extends chain:

@push('scripts')<script src="/js/map.js"></script>@endpush
...
@stack('scripts')

CSRF & method spoofing helpers

<form method="POST" action="/listings">
    @csrf                {{-- <input type="hidden" name="_csrf_token" value="..."> --}}
    @method('PUT')       {{-- <input type="hidden" name="_method" value="PUT"> --}}
</form>

Security notes

  • @php/@endphp are disabled entirely. Any raw-PHP directive throws a compile-time RuntimeException — business logic belongs in controllers/services, not templates. This closes an RCE vector where a writable template file would otherwise mean arbitrary code execution.
  • View names are restricted to [a-zA-Z0-9._\-\/] with no .. segments, and the resolved file path is confirmed (via realpath()) to stay inside app/views/ before being required — both for the primary view and for @extends layout targets.
  • Compiled cache files are written atomically (LOCK_EX + rename()).

View Components

For reusable UI fragments with typed constructor props (nav bars, headers, badges) rather than @include-style array passing:

namespace App\View\Components;

use Core\View\Components\Component;

class OrderStatusBadge extends Component
{
    public function __construct(protected string $status) {}

    protected function view(): string { return 'components.order-status-badge'; }

    protected function data(): array { return ['status' => $this->status]; }
}
echo new OrderStatusBadge($order->status); // __toString() renders it

Component::render() merges ViewFactory::getShared() under the component's own data(). ViewFactory::share(['appName' => app_name()]) (typically called once in bootstrap) makes that value available to every component without passing it explicitly each time.

Shipped layout components: Header, Sidebar, and the HeaderActions\{Cart,Logout,Notif} trio — these are ready-made building blocks for a typical dashboard shell and can be used as a reference implementation when building your own.

Sessions, Flash Data & CSRF

Core\Session (static facade — no instantiation needed)

Method Description
start(): void Idempotent; called automatically wherever session data is touched.
get, set, has, forget, flush Standard key-value access on $_SESSION.
flash(string $key, mixed $value): void / getFlash(...) / hasFlash(...) One-request-lifetime data (e.g. success banners). Cleanup is lottery-swept, not eager, so calling getFlash() twice in the same request is safe.
csrfToken(): string / verifyCsrf(string $token): bool / rotateCsrfToken(): void Backing implementation for VerifyCsrfToken middleware.
regenerate(bool $deleteOld = true): void Regenerate session ID (called automatically after login/password reset).
destroy(): void Full logout — clears data and expires the cookie.
setUser(array $user): void / user(): ?array / isLoggedIn(): bool Backing store for Auth.

Session cookies default to HttpOnly, SameSite=Lax, and Secure when the connection is HTTPS (trusted-proxy-aware — X-Forwarded-Proto is only honored from configured app.trusted_proxies). A lottery-based garbage collector (default 2% of requests) prunes expired session files without a per-request filesystem scan.

Flash + old-input helpers (Blade-style, in views)

flash('success', 'Listing created!');   // set
flash('success');                        // read
old('title', '');                        // repopulate a form field after a failed validate()
errors('title');                         // first error message for a field, or null
hasError('title');                       // bool

File Uploads

$file = $request->file('photo');

if ($file && $file->isValid() && $file->mimeIn(['image/jpeg', 'image/png']) && $file->maxSize(2_000_000)) {
    $path = $file->store('listings');              // random filename, real-MIME extension
    // or: $file->storeAs('listings', 'cover.jpg');
}
Method Description
isValid(): bool error === UPLOAD_ERR_OK and genuinely came from an upload.
getOriginalName(): string Client-supplied filename — never use in filesystem paths directly.
getMimeType(): string Real MIME from finfo magic-byte detection — the only type ever used for security decisions.
getExtension(): string Derived from the real MIME type, not the client filename — defeats double-extension tricks like shell.php.jpg.
mimeIn(array $mimes): bool, maxSize(int $bytes): bool Validation helpers.
store(string $directory = ''): string Random UUID-like filename + real extension.
storeAs(string $directory, string $filename): string $filename is basename()-stripped and $directory is validated segment-by-segment ([a-zA-Z0-9_-] only, no ..) before any directory is created — closing a traversal window that a post-hoc realpath() check alone would miss.

By default, image/svg+xml and text/html are excluded from the recognized-MIME whitelist (UploadedFile::MIME_TO_EXT) — both can carry executable script, so an "image upload" feature can't accidentally become a stored-XSS vector. If you deliberately need SVG uploads, sanitize server-side and serve from a cookie-less origin.

Mail

class OrderConfirmationMail extends \Core\Mail\Mailable
{
    public function __construct(private Order $order) {}

    public function build(): void
    {
        $this->to($this->order->customer_email)
             ->subject('Your Sagana Market order is confirmed')
             ->view('emails.order-confirmation', ['order' => $this->order]);
    }
}

mail_send(new OrderConfirmationMail($order));                 // synchronous
Mailer::queue(new OrderConfirmationMail($order), delay: 0);   // via the job queue

Mailable fluent builders: to(), cc(), bcc(), subject(), view(string $template, array $data = []) (renders a view into $body, containment-checked against the views root), text(string $body), html(string $body).

Mailer talks raw SMTP over a stream socket — no PECL/Composer mail dependency. Every address (to/cc/bcc/from) is validated with FILTER_VALIDATE_EMAIL, and free-text header values (subject, from-name) are stripped of \r/\n before being interpolated — both closing SMTP/header-injection vectors for any form (contact forms, "email a friend") that feeds user input into a Mailable.

Configure via config/mail.php / .env: MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_ENCRYPTION (tls/ssl/none), MAIL_FROM_ADDRESS, MAIL_FROM_NAME.

Queue & Background Jobs

class SendHarvestReminder extends \Core\Queue\Job
{
    public int $tries = 3;
    public int $retryAfter = 60;

    public function __construct(private int $farmerId) {}

    public function handle(): void
    {
        // ... look up farmer, send reminder
    }

    public function failed(\Throwable $e): void
    {
        log_message('ERROR', "Reminder failed for farmer {$this->farmerId}");
    }
}

SendHarvestReminder::dispatch($farmerId);
SendHarvestReminder::dispatch($farmerId, delay: 3600, queue: 'reminders');

Jobs are database-backed (jobs / failed_jobs tables — create via php lite queue:migrate). Every serialized job payload is HMAC-SHA256 signed with APP_KEY before being stored and verified before unserialize() is ever called on it — this prevents PHP object-injection if an attacker gained write access to the jobs table through some other vector.

Run a worker: php lite queue:work [--queue=name] [--once]. QueueWorker::processNext() atomically claims the oldest available job (SELECT ... FOR UPDATE), retries with retryAfter-second backoff up to tries attempts, then buries permanently-failed jobs into failed_jobs (calling the job's failed() hook first). A pcntl_alarm-based timeout (default 60s) aborts jobs that hang, when the pcntl extension is available.

Events

A minimal synchronous pub/sub dispatcher — no queued/deferred listeners:

use Core\Events\EventDispatcher;

EventDispatcher::listen('order.placed', function (array $payload) {
    Mailer::queue(new OrderConfirmationMail($payload['order']));
});

EventDispatcher::once('farmer.verified', fn($payload) => /* runs once */ null);

event('order.placed', ['order' => $order]);   // helper — fires listeners synchronously

forget(string $event) removes all listeners for an event; flushAll() clears everything; hasListeners()/listeners() are for inspection/testing.

Cache

File-based cache with TTL support, under Core\Cache\Cache (static facade):

Cache::put('featured-listings', $listings, ttl: 300);
Cache::get('featured-listings');
Cache::remember('featured-listings', 300, fn() => Listing::where('featured', true)->get());
Cache::rememberForever('site-settings', fn() => Settings::all());
Cache::increment('page-views:' . $listing->id);   // atomic, file-lock-guarded
Cache::forget('featured-listings');
Cache::flush();

Every cache entry is HMAC-signed (APP_KEY) on write and verified before unserialize() on read — a file planted in the cache directory via some other vulnerability can't be used for object injection. increment()/decrement() hold an exclusive lock for the full read-modify-write cycle, so it's genuinely safe for rate-limiting-style counters under concurrent load (this is what ThrottleMiddleware uses internally).

Outbound HTTP Client

use Core\Http\Client;

$res = Client::get('https://api.example-logistics.com/rates', ['zip' => '4200']);
$res = Client::post('https://api.example-logistics.com/quote', ['weight_kg' => 2.5]);
$res = Client::new()->withToken($apiKey)->timeout(5)->post('/orders', $payload);

$res->status();  // int
$res->ok();      // bool, 2xx
$res->json();    // ?array
$res->throw();   // throws RuntimeException if failed(), returns $this otherwise

Built on PHP streams — no curl dependency. SSRF guard is on by default: it rejects non-http(s) schemes and any hostname that resolves to a loopback/private/link-local/reserved address (including the 169.254.169.254 cloud-metadata address, and IPv4 addresses embedded in IPv6 literals like ::ffff:169.254.169.254). The guard resolves and pins the exact IP it validated for the actual connection (no re-resolution at connect time, closing a DNS-rebinding gap), while still sending the correct Host header and TLS SNI. Redirects are followed manually (max 3 hops), re-validating the target on every hop.

Call ->trusted() to disable the guard only for URLs you control yourself (internal services, config-driven endpoints) — never for a URL sourced from user input (webhook config, "import from URL" features, etc.).

Support Utilities (Arr, Str, Collection, Hash)

Core\Support\Arr

Dot-notation array helpers: get, set, has, forget, only, except, where, flatten, pluck, keyBy, groupBy, chunk, first, last, sortBy, wrap, unique, collapse, toDot.

Arr::get($config, 'database.connections.mysql.host');

Core\Support\Str

Case conversion (camel, studly, snake, kebab, title), checks (startsWith, endsWith, contains), transforms (limit, words, slug, excerpt, mask), random generation (random(int $length), uuid()), padding, and replace helpers.

Also reachable as global helpers: str_uuid(), str_random($length), str_slug($value).

Core\Support\Collection

Fluent array wrapper implementing Countable, IteratorAggregate, JsonSerializable:

collect($listings)
    ->filter(fn($l) => $l->price < 200)
    ->map(fn($l) => $l->title)
    ->sortBy('price')
    ->values()
    ->toArray();

Covers transformation (map, filter, reject, each, flatMap, flatten), reduction (reduce, sum, avg, min, max), lookup (first, last, contains, find), keying (pluck, keyBy, groupBy), slicing (take, skip, slice, chunk), sorting, set operations (merge, diff, intersect), and toArray()/toJson().

Core\Support\Hash

$hash = Hash::make('secret');            // bcrypt, cost 12
Hash::check('secret', $hash);            // bool — throws if $hash isn't a valid-looking bcrypt hash
Hash::needsRehash($hash);
Hash::setCost(14);                        // 4–31

Also reachable via the bcrypt($value) helper.

Exceptions & Error Handling

Core\Exceptions\Handler::register() (called during bootstrap) installs a global exception handler, error-to-exception converter, and shutdown handler for fatal errors — every Throwable from anywhere in the app renders consistently instead of leaking a raw PHP error page.

Condition Debug mode (APP_DEBUG=true) Production
JSON/AJAX request Full JSON body: message, file, line, trace {"message": "..."} generic status text only
HTML request Full dark-themed debug page with stack trace app/views/errors/{status}.php if present, else plain "500 SERVER ERROR"

Every exception is also logged via log_message('ERROR', ...) regardless of debug mode.

Core\Exceptions\ForbiddenException — thrown by Gate::authorize() and FormRequest::authorize() failures; carries HTTP code 403 by default.

Core\Exceptions\ValidationException — thrown by Validation\Factory::validate(); wraps a Validator instance (->errors(), ->getValidator()).

Fail-fast production guard: Bootstrap/app.php refuses to boot at all if APP_ENV=production and APP_DEBUG=true are set simultaneously — the single most common way a misconfigured deploy leaks internals publicly.

CLI Reference (php lite)

Run php lite (or php lite list) with no arguments for a live, grouped listing. Every command below is invoked as php lite <command> [args] from the app root.

Scaffolding (make:*)

Command Description
make:controller Name [--resource] New controller; --resource generates the full CRUD method set. Supports Admin/Name for a subdirectory + namespace.
make:model Name [--timestamps] New Model subclass.
make:service Name [--resource] New plain service class.
make:resource Name Generates model + service + controller together, pre-wired to each other.
make:view name.dot.path New .lites view file.
make:middleware Name New middleware class.
make:migration name New timestamped migration file under database/migrations.
make:seeder Name New Seeder subclass.
make:mail Name New Mailable subclass.
make:job Name New Job subclass.

Database

Command Description
migrate Runs all pending migrations in filename order; auto-creates the configured database if it doesn't exist yet; tracks applied migrations in a migrations table.
migrate:rollback Rolls back the last migration batch.
db:seed [--class=Name] Runs DatabaseSeeder (or a specific seeder class).

Routing / Cache

Command Description
route:cache Compiles and HMAC-signs the route table for production (closures will throw — convert to controller actions first).
route:clear Deletes the route cache file.
cache:clear Clears application cache (data, views, routes).

Queue

Command Description
queue:migrate Creates the jobs and failed_jobs tables.
queue:work [--queue=name] [--once] Starts a worker loop (or processes exactly one job with --once).
queue:failed Lists all failed jobs.

Environment & keys

Command Description
env:init Creates .env from .env.example.
key:generate Generates a new APP_KEY.
jwt:secret Generates a new APP_JWT_SECRET.

Global Helper Functions

These are always available (autoloaded via composer.json's files entry) — no use statement needed.

Category Helpers
Container app(mixed $abstract = null): mixed
Paths base_path(), storage_path(), public_path(), app_path(), config_path(), framework_path(), sessions_path(), log_path(), uploads_path(), cache_path()
Env / Config env(string $key, mixed $default = null), config(string $key, mixed $default = null), app_name()
URLs url(string $path = '', array $params = []), asset(string $path = ''), current_url(), current_path(), is_active(string $path, string $class = 'active'), route(string $name, array $params = [])
Response response(): Response, view($view, $data = []), redirect($url, $code = 302), back(), abort($code, $message = '')
Request request(?string $key = null, $default = null), query(string $key, $default = null)
Session / Auth session(?string $key = null, $default = null), flash($key, $value = null, $set = false), old($key, $default = ''), auth(): ?array, auth_id(), is_logged_in(): bool, auth_user(): ?array
CSRF csrf_token(), csrf_field()
HTML / strings e(string $value) (HTML-escape), str_limit($value, $limit = 100, $end = '...'), litstr($value), str_uuid(), str_random($length = 16), str_slug($value)
Date now(string $format = 'Y-m-d H:i:s')
Debug dump(...$vars), dd(...$vars) — both no-ops unless APP_DEBUG=true
Logging log_message(string $level, string $message, array $context = []) — writes to storage/logs/app-{date}.log; log values are sanitized against CR/LF injection.
Collections collect(array $items = []): Collection
Hashing bcrypt(string $value): string
Cache cache(?string $key = null, $default = null)
Events event(string $name, array $payload = []), listen(string $name, callable $callback)
Arrays data_get(array $array, string $key, $default = null)
Views view_path(string $path = '')
Auth/Gate gate(): Gate
Pagination paginate(array $items, int $total, int $perPage, int $page): Paginator
Mail mail_send(Mailable $mailable): void
JWT jwt_issue(array $claims, int $ttl = 3600), jwt_verify(string $token): ?array
Frontend build vite(string|array $entrypoints): string — Vite dev-server/manifest integration; all injected values are HTML-escaped.
View errors errors(?string $key = null), hasError(string $key), navLink($href, $label, $activeClass, $inactiveClass)

Configuration Reference

Config files ship as framework defaults under Core/Config/defaults/ and are merged with an optional app-level config/ directory (arrays merge recursively; anything else overwrites). Every default reads from .env via env()/Env::get().

app.php

Key .env var Default Notes
name APP_NAME Lite
env APP_ENV production
debug APP_DEBUG false Never true in production — bootstrap refuses to boot if both are set.
url APP_URL http://localhost:3000 Used by url()/asset()/redirects.
timezone APP_TIMEZONE UTC
locale APP_LOCALE en
trusted_proxies APP_TRUSTED_PROXIES [] Comma-separated IP list; gates trust of X-Forwarded-* headers.

auth.php

Key .env var Default
model AUTH_MODEL App\Models\Users
username_field email
password_field password_hash
jwt_secret APP_JWT_SECRET falls back to APP_KEY
jwt_ttl JWT_TTL 3600
token_column api_token

database.php

Key .env var Default
default DB_CONNECTION mysql
connections.mysql.host DB_HOST 127.0.0.1
connections.mysql.port DB_PORT 3306
connections.mysql.database DB_DATABASE lite
connections.mysql.username DB_USERNAME root
connections.mysql.password DB_PASSWORD ''
connections.mysql.persistent DB_PERSISTENT false — keep off outside CLI workers to avoid connection-pool exhaustion.

cors.php

Key .env var Default
allowed_origins CORS_ALLOWED_ORIGINS http://localhost:3000
allowed_methods CORS_ALLOW_METHODS GET,HEAD,PUT,PATCH,POST,DELETE
allowed_headers CORS_ALLOW_HEADERS Content-Type,Authorization
exposed_headers CORS_EXPOSE_HEADERS ''
allow_credentials CORS_ALLOW_CREDENTIALS false
max_age CORS_MAX_AGE 86400
allow_private_network CORS_ALLOW_PRIVATE_NETWORK false

mail.php

driver (MAIL_DRIVER), host (MAIL_HOST), port (MAIL_PORT), username/password (MAIL_USERNAME/MAIL_PASSWORD), encryption (MAIL_ENCRYPTION), from.address/from.name (MAIL_FROM_ADDRESS/MAIL_FROM_NAME).

session.php

name (SESSION_NAME, default lite_session), lifetime (SESSION_LIFETIME, minutes, default 120), domain (SESSION_DOMAIN), samesite (SESSION_SAMESITE, default Lax), secure (SESSION_SECURE_COOKIE).

view.php

cache (VIEW_CACHE) — false in dev (auto-recompile on change), true in production (compile once, trust the cache).

cache.php

driver (CACHE_DRIVER, default file), path (defaults to storage_path('cache/data')).

FAQ / Common Gotchas

Model::raw() is protected, not public. This is intentional — it exists so subclasses can expose specific, hard-coded raw-SQL methods, not so app code can interpolate arbitrary SQL. Use QueryBuilder's chained methods instead; they're validated end-to-end.

The string validation rule checks "letters only," not "is a PHP string." 'title' => 'string' will reject "Kalamansi 1kg"-style titles containing digits. If you just need type coercion for a text field, skip the string rule (all $_POST/$_GET/JSON scalars already arrive as strings) or write a custom Rule.

Mass assignment is deny-by-default. A Model with no $fillable declared silently drops every key passed to fill()/create()/update() — nothing throws. If your create() call isn't persisting fields you expect, check $fillable first.

Route groups don't currently tag $route->group. Route::group($prefix, $middleware, $callback) merges the group's middleware directly onto each route, but doesn't set the Route::$group property that Kernel::resolveMiddleware() checks for groupMiddleware['web'|'api']. In practice this is why SecurityHeadersMiddleware and VerifyCsrfToken are registered globally in Bootstrap/app.php rather than relying on the 'web' group default.

@php/@endphp are permanently disabled in .lites templates — this is a deliberate security boundary, not a missing feature. Move logic into controllers/services.

CSRF tokens rotate after every successful verified request, with a 5-second grace window for the previous token (covers concurrent tabs/requests). If you see intermittent 419 errors on rapid double-submits from automated tests, add a short delay or capture the fresh token between requests.

Cache::increment() treats an expired key as "just created." Its TTL is only (re)applied when the key is missing or has expired — repeated increments on a live key preserve the original expiry rather than sliding it forward.

Route-cached apps ignore app/Routes/web.php entirely on a cache hit. If you add a new route and it 404s in production, check whether route:cache needs to be re-run (php lite route:cache) — the file isn't re-parsed once a valid cache exists.