liteflow / core
LitePHP - A lightweight PHP framework
Requires
- php: >=8.5
Requires (Dev)
- phpunit/phpunit: ^11.0
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 requireto 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
- Application Bootstrap & Container
- Routing
- Controllers
- HTTP: Request & Response
- Validation
- Database: Query Builder
- Database: Models (ORM)
- Database: Migrations & Schema Builder
- Database: Seeders & Factories
- Authentication
- Authorization (Gate)
- Middleware
- Views: The
.litesTemplate Engine - View Components
- Sessions, Flash Data & CSRF
- File Uploads
- Queue & Background Jobs
- Events
- Cache
- Outbound HTTP Client
- Support Utilities (Arr, Str, Collection, Hash)
- Exceptions & Error Handling
- CLI Reference (
php lite) - Global Helper Functions
- Configuration Reference
- 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
Closurethrows whenRoute::cache()/->cache()is called (see CLI Reference →route: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-Type → HTTP_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/hasOnerelations are batched via a singleWHERE foreign_key IN (...)query, then grouped back onto each parent.belongsTorelations are batched per unique foreign-key value (not per row).belongsToManyrelations 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 (Listing → listings, Category → categories). 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: global → group (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/@endphpare disabled entirely. Any raw-PHP directive throws a compile-timeRuntimeException— 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 (viarealpath()) to stay insideapp/views/before beingrequired — both for the primary view and for@extendslayout 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.
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_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.