Features & API
Every framework call you'll need, with copy-paste-ready syntax. Bookmark this page.
Routing
Convention-based. No route table. The URL maps directly to a class and method.
URL Resolves to
───────────────────────────────── ─────────────────────────────────────────
/ App\Controllers\Home::index()
/about App\Controllers\About::index()
/contact/submit App\Controllers\Contact::submit()
/product/view/id/42 App\Controllers\Product::view()
└─ $this->url->param['id'] = "42"
/api/v1/customer/login App\Api\V1\Customer::login()
/admin/orders/refund/id/9001 App\Admin\Orders::refund()
└─ $this->url->param['id'] = "9001"
If App\Controllers\Foo doesn't exist, the dispatcher falls back to Demo\Controllers\Foo — that's how the bundled demo works. Create the App class and it shadows the demo automatically.
Controllers
Extend the right base for the kind of endpoint:
// Page controller (HTML + layout)
namespace App\Controllers;
final class Home extends \BC\Core\Mvc\Controller {
protected string $title = 'Welcome';
protected string $description = 'Landing page.';
protected string $layout = 'layouts/app'; // optional, this is the default
public function index(): string {
return $this->view->render('index', ['name' => 'World']);
}
}
// JSON API endpoint (no layout, uniform envelope)
namespace App\Api\V1;
final class Ping extends \BC\Core\Mvc\ApiController {
public function index(): string {
return $this->ok(['pong' => true]);
}
}
// Admin (auth-gated by AdminController::boot)
namespace App\Admin;
final class Dashboard extends \BC\Core\Mvc\AdminController {
protected string $layout = 'layouts/admin';
public function index(): string {
return $this->view->render('index');
}
}
Inside any controller you have: $this->c, $this->url, $this->request, $this->response, $this->view, $this->sanitize, $this->csrf.
Request
Immutable value object. Captured once at boot.
$req = $this->request;
$req->method // "GET" | "POST" | …
$req->path // "/contact/submit"
$req->host // "example.com"
$req->https // bool
$req->ajax // true if X-Requested-With: XMLHttpRequest
$req->ip // "1.2.3.4"
$req->userAgent // string
$req->contentType // "application/json; charset=utf-8"
$req->get // array — raw $_GET
$req->post // array — raw $_POST
$req->files // array — raw $_FILES
$req->json // ?array — decoded JSON body, or null
$req->rawBody // string — body text for PUT/POST/DELETE
$req->header('X-Api-Key') // ?string, case-insensitive lookup
URL-routed values (slug-style URLs):
$this->url->param['id'] // for /product/view/id/42 → "42" (string)
$this->url->get['q'] // for ?q=hello → "hello"
$this->url->currentClass // "product"
$this->url->currentFunction // "view"
$this->url->fullPath // "/product/view/id/42"
$this->url->isApi / isAdmin // bool
Sanitise input
Never trust $_POST directly. Wrap and use a typed view:
$name = $this->sanitize->wrap($this->request->post['name'] ?? '')->safe;
$email = $this->sanitize->wrap($this->request->post['email'] ?? '')->email;
$age = $this->sanitize->wrap($this->request->post['age'] ?? '')->int;
$active = $this->sanitize->wrap($this->request->post['active'] ?? '')->bool;
$price = $this->sanitize->wrap($this->request->post['price'] ?? '')->decimal;
$slug = $this->sanitize->wrap($this->request->post['slug'] ?? '')->slug;
$url = $this->sanitize->wrap($this->request->post['url'] ?? '')->url;
$ipv4 = $this->sanitize->wrap($this->request->post['ip'] ?? '')->ipv4;
$plain = $this->sanitize->wrap($this->request->post['note'] ?? '')->escaped;
$raw = $this->sanitize->wrap($this->request->post['raw'] ?? '')->raw; // be careful
// Whole-array clean (for forms with many fields):
$clean = $this->sanitize->clean($this->request->post);
$name = ($clean['name'] ?? null)?->safe ?? '';
$email = ($clean['email'] ?? null)?->email ?? '';
Invalid emails return '' — perfect for "is this a real email?" gating. Same for url, ipv4.
Escape output (inside templates)
Every variable interpolated into HTML must go through one of these. Pick by context.
<p><?= e($user->name) ?></p>
<a title="<?= eAttr($tip) ?>"> …
<script>var u = <?= eJs($user) ?>;</script>
<a href="/p/<?= eUrl($slug) ?>"> …
Auto-escaping is by helper, not by syntax. Forgetting a helper IS a bug — the linter rule "any echo from request data must use e()" is a good thing to enforce in review.
Database
PDO under the hood, MySQL 8 with utf8mb4, real prepared statements. Connection is lazy — opens on first query.
$db = $this->c->db();
// SELECT one row (object or null):
$user = $db->row(
"SELECT * FROM users WHERE id = :id LIMIT 1",
['id' => 42]
);
echo $user->email;
// SELECT many rows:
$active = $db->all(
"SELECT id, email FROM users WHERE active = :a ORDER BY id DESC",
['a' => 1]
);
foreach ($active as $row) { /* … */ }
// SELECT a single scalar (e.g. COUNT):
$total = $db->value("SELECT COUNT(*) FROM users");
// INSERT — returns last insert id (int):
$id = $db->execute(
"INSERT INTO users (email, name) VALUES (:e, :n)",
['e' => 'a@b.c', 'n' => 'Alice']
);
// UPDATE / DELETE — returns affected row count (int):
$affected = $db->execute(
"UPDATE users SET name = :n WHERE id = :id",
['n' => 'Alicia', 'id' => $id]
);
// Transaction: auto-commit on return, rollback on throw:
$db->transaction(function ($db) {
$db->execute("INSERT INTO orders …", […]);
$db->execute("INSERT INTO order_items …", […]);
});
// Cached row — looks in cache first; one round-trip on miss:
$row = $db->rowCached($sql, $params, $this->c->cache(), 'user:42', ttl: 600);
NEVER concatenate user input into SQL. The framework gives you no concatenation API. Always use named placeholders + a params array.
Models
Thin base on top of Database — single-table CRUD with mass-assignment whitelist.
namespace App\Models;
final class User extends \BC\Core\Mvc\Model {
protected string $table = 'users';
protected string $primaryKey = 'id';
protected array $fillable = ['email', 'name']; // anything else is silently dropped
}
$users = new User();
$row = $users->find(42); // ?object
$rows = $users->where('role', 'admin'); // object[]
$id = $users->create(['email' => 'a@b.c', 'name' => 'A']);
$users->update(42, ['name' => 'Updated']);
$users->delete(42);
Mass-assignment protection: $user->create($_POST) can never set role unless role is in $fillable. Add only fields you actually want users to set.
Cache
Drivers: redis, apcu, file, null. Set via CACHE_DRIVER in .env or cache.driver in config. Auto-fallback to file on init failure.
$cache = $this->c->cache();
$cache->set('user.42', $userRow, ttl: 600); // seconds; 0 = no expiry
$row = $cache->get('user.42'); // mixed or null
$row = $cache->get('user.42', $default); // miss returns $default
$cache->has('user.42'); // bool
$cache->delete('user.42');
$cache->clear(); // wipe this app's prefix
// Atomic counter (Redis/APCu — file emulates):
$hits = $cache->increment('page.hits', 1);
Redis keys are auto-prefixed with cache.prefix (default bc:) so multiple apps can share a Redis instance.
CSRF
Auto-verified on all non-GET requests. Use csrf_field() in forms; for fetch/XHR, set the X-Csrf-Token header.
<!-- In a form template -->
<form method="POST" action="/contact/submit">
<?= csrf_field() ?>
<input name="email">
<button>Send</button>
</form>
// In JavaScript fetch:
fetch('/api/foo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Csrf-Token': '<?= csrf_token() ?>',
},
body: JSON.stringify({ … }),
});
// After login / privilege change — rotate token:
$this->csrf->rotate();
Sessions
Cookie: HttpOnly, SameSite=Lax, Secure on HTTPS, session.use_strict_mode=1.
$_SESSION['flash'] = 'Saved!'; // standard PHP — already started by Boot
// Rotate session ID at privilege transition (login, logout, password change):
\BC\Core\Security\Session::regenerate();
// Logout — fully destroy session + cookie:
\BC\Core\Security\Session::destroy();
Authentication
Session-based. Requires a users table with email, password_hash, role.
$auth = $this->c->auth();
if ($auth->attempt($email, $password)) {
$this->redirect('/dashboard');
}
if ($auth->isLoggedIn()) { … }
if ($auth->hasRole('admin')) { … }
$auth->logout();
// Hashing for user registration:
$hash = \BC\Core\Security\Auth::hash($password);
Password timing is equalised on missing users — username enumeration via timing is blocked.
Views & themes
Plain PHP. Cascade: app/Views/<theme>/<class>/ → app/Views/<theme>/ → fallback themes → demo → core/Views/.
// From a controller:
return $this->view->render('index', ['title' => 'Hi']);
// From inside another template:
<?= view('partials/header') ?>
<?= view('partials/footer') ?>
// Switch theme for the rest of this request (e.g. A/B variant):
$this->c->view->setTheme('b');
// Config-driven theme (.env):
// VIEW_THEME=dev → app/Views/dev/ searched first
// VIEW_DEMO=false → bundled demo hidden completely
Drop a single file at app/Views/default/home/index.php and it shadows just that demo page — everything else still cascades through demo.
Global helpers
Available in every template and controller (no use needed).
e($value) // HTML text escape
eAttr($value) // HTML attribute escape
eJs($value) // JS literal (JSON-encoded, safe)
eUrl($value) // URL segment encode
view('name', $data) // render another view from a template
csrf_field() //
csrf_token() // the token string itself
url('foo/bar') // "/foo/bar" — site-rooted
app() // the Container singleton
Logger
Appends to logs/<channel>.log. Auto-rotates at 100 MB.
$log = $this->c->logger;
$log->debug('only when env=dev …');
$log->info('Order #1234 created');
$log->warning('Slow API response from upstream');
$log->error('Stripe webhook failed: ' . $exception->getMessage());
// Separate file per channel:
$log->info('Login OK', channel: 'auth'); // → logs/auth.log
$log->warning('Slow query', channel: 'db'); // → logs/db.log
Profiler
Append ?_speed=1 on any URL (when your IP is in security.safe_ips) for a timing breakdown.
$p = $this->c->profiler;
$p->mark('users.query');
$rows = $db->all("…");
$p->end('users.query');
// Same name across loops aggregates — total time across N calls:
foreach ($items as $item) {
$p->mark('item.render');
/* … */
$p->end('item.render');
}
AB testing
Declare experiments in config; variants stick per session and feed into the cache key.
// config/config.php
'ab' => [
'hero_copy' => ['original' => 50, 'new' => 50],
'checkout_button' => ['green' => 70, 'orange' => 30],
],
// Use it anywhere:
if ($this->c->ab->variant('hero_copy') === 'new') { /* … */ }
// Force variant via URL — honoured only when IP is in safe_ips:
// /?ab=hero_copy:new
Redirects & responses
// 302 redirect + exit:
$this->redirect('/login');
// 301 permanent:
$this->redirect('/new-home', 301);
// API success / error envelope:
return $this->ok($data, 'Saved', 200);
return $this->fail('Validation failed', 422, ['email' => 'required']);
// JSON one-shot from a page controller:
$this->response->json(['ok' => true]);
API endpoints
JSON in, JSON out. Uniform envelope {success, data, message, statusCode}.
namespace App\Api\V1;
final class Customer extends \BC\Core\Mvc\ApiController {
public function login(): string {
$body = $this->request->json; // decoded array or null
if (!is_array($body)) {
return $this->fail('Invalid JSON', 400);
}
$email = $this->c->sanitize->wrap($body['email'] ?? '')->email;
if ($email === '') {
return $this->fail('email required', 422);
}
// …auth…
return $this->ok(['token' => '…'], 'Logged in');
}
}
Reached at POST /api/v1/customer/login with Content-Type: application/json.
Admin
Routes under /admin/*. Gated by AdminController::boot() — requires role = 'admin' in session.
namespace App\Admin;
final class Orders extends \BC\Core\Mvc\AdminController {
public function index(): string {
$rows = $this->c->db()->all("SELECT * FROM orders ORDER BY id DESC");
return $this->view->render('index', ['rows' => $rows]);
}
public function refund(): string {
$id = $this->sanitize->wrap($this->url->param['id'] ?? '')->int;
// …refund logic…
$this->redirect('/admin/orders');
}
}
Config & .env
Two layers. .env for secrets and per-environment values; config/config.php for shape + defaults.
# .env (gitignored; copy from .env.example)
BC_ENV=prod
DB_HOST=db.internal
DB_NAME=myapp
DB_USER=app
DB_PASS=correct-horse-battery
CACHE_DRIVER=redis
REDIS_HOST=redis.internal
VIEW_THEME=default
// Read in code:
$env = $this->c->config->get('env'); // "prod"
$host = $this->c->config->get('db.host'); // dot-path
$ttl = $this->c->config->get('cache.ttl', 600); // with default
// Or read .env directly (before container is built):
\BC\Core\Env::get('STRIPE_SECRET', '');
Composer / packages
Composer is supported but not required. The framework autoloader auto-includes vendor/autoload.php if present, so any PSR-4 package is callable the moment composer require finishes.
# Install all declared dependencies (after cloning the repo):
composer install
# Add a new package:
composer require stripe/stripe-php
composer require paypal/paypal-server-sdk
composer require braintree/braintree_php
composer require guzzlehttp/guzzle
# Remove a package:
composer remove stripe/stripe-php
# Regenerate the autoload classmap (also done automatically by install/update):
composer dump-autoload --optimize
Use the package immediately — no extra wiring:
// app/Controllers/Checkout.php
namespace App\Controllers;
use BC\Core\Mvc\Controller;
final class Checkout extends Controller {
public function index(): string {
// Stripe (after `composer require stripe/stripe-php`):
$stripe = new \Stripe\StripeClient(
\BC\Core\Env::get('STRIPE_SECRET')
);
$session = $stripe->checkout->sessions->create([…]);
// Braintree (after `composer require braintree/braintree_php`):
$gateway = new \Braintree\Gateway([
'environment' => \BC\Core\Env::get('BRAINTREE_ENV', 'sandbox'),
'merchantId' => \BC\Core\Env::get('BRAINTREE_MERCHANT_ID'),
'publicKey' => \BC\Core\Env::get('BRAINTREE_PUBLIC_KEY'),
'privateKey' => \BC\Core\Env::get('BRAINTREE_PRIVATE_KEY'),
]);
return $this->view->render('index', ['session' => $session]);
}
}
How autoload resolves a class:
BC\Core\…→ handled by framework autoloader (core/Autoload.php) — instant.App\…→ framework autoloader — instant.Demo\…→ framework autoloader — instant.- Everything else → falls through to Composer's autoloader (
vendor/autoload.php) — handles allvendor/*packages.
Tip: in production, run composer install --no-dev --optimize-autoloader --classmap-authoritative for the fastest possible autoload (no filesystem lookups; one in-memory class map).
Already-listed suggestions in composer.json — view with composer suggests:
ext-redis phpredis for cache.driver=redis
ext-apcu APCu for cache.driver=apcu
ext-gd GD for image-resize feature
stripe/stripe-php Stripe payments
paypal/rest-api-sdk-php PayPal REST SDK (classic)
paypal/paypal-server-sdk PayPal Server SDK (newer)
braintree/braintree_php Braintree payments
phpmailer/phpmailer Alternative SMTP transport
guzzlehttp/guzzle Alternative HTTP client
monolog/monolog Drop-in for the built-in Logger
nesbot/carbon Rich date/time helpers
Middleware
Configured pipeline runs in order before the Dispatcher. Each entry must implement MiddlewareInterface.
// config/config.php
'middleware' => [
\BC\Core\Http\Middleware\MaintenanceMode::class, // 503 when flag set
\BC\Core\Http\Middleware\RateLimit::class, // throttle per IP/user
\BC\Core\Http\Middleware\VerifyCsrf::class, // token check for non-GET
],
// Write your own:
namespace App\Http\Middleware;
final class LogRequest implements \BC\Core\Http\MiddlewareInterface {
public function __construct(private \BC\Core\Container $c) {}
public function handle(\BC\Core\Http\Request $r, callable $next): string {
$this->c->logger->info("$r->method $r->path from $r->ip");
return $next($r); // pass downstream
}
}
Short-circuit by returning a string instead of calling $next() — useful for early redirects, blocked IPs, or maintenance windows.
Rate limiter
Built-in RateLimit middleware throttles every request based on rate_limit.* config. For per-action limits (e.g. login attempts), use the service directly:
$rl = $this->c->rateLimiter();
if (!$rl->attempt('login:' . $this->request->ip, max: 5, windowSeconds: 60)) {
return $this->fail('Too many attempts. Try again in a minute.', 429);
}
// proceed with login…
$rl->reset('login:' . $this->request->ip); // after successful login
Backed by the configured cache driver. Redis gives atomic counters across multiple web nodes.
Pagination
$page = $this->c->db()->paginate(
"SELECT * FROM posts WHERE published_at <= NOW() ORDER BY id DESC",
params: [],
perPage: 20,
currentPage: (int) ($this->url->get['p'] ?? 1),
);
return $this->view->render('index', [
'rows' => $page->rows,
'total' => $page->total,
'lastPage' => $page->lastPage,
'paginator' => $page, // pass into the view
]);
// In the view:
<?php foreach ($paginator->rows as $row): ?>
<li><?= e($row->title) ?></li>
<?php endforeach; ?>
<?= $paginator->html('p') ?> // "‹ Prev 1 2 [3] 4 5 Next ›"
Block caching
Cache an expensive chunk of a view. Hits return in <0.1 ms; misses run the producer + store the result. The profiler shows block.<name>.hit / .miss with ?_speed=1.
// Functional form:
<?= block('home.features.v1', 600, function () use ($features) {
ob_start();
foreach ($features as $f) echo '<li>' . e($f[0]) . '</li>';
return ob_get_clean();
}) ?>
// Imperative form — start/end pair:
<?php if (!cache_start('home.features.v1', 600)): ?>
<section class="features">
<!-- expensive content built here -->
</section>
<?php cache_end(); endif; ?>
Suffix your key (.v1, .v2) — bumping the suffix invalidates without flushing the whole cache.
HTTP client
Outbound HTTP without Composer. Fluent, retries on 5xx with exponential backoff.
$http = $this->c->http();
// Simple GET:
$resp = $http->get('https://api.example.com/things')->send();
if ($resp->ok()) {
$data = $resp->json();
}
// POST JSON with retry:
$resp = $http->post('https://api.example.com/orders')
->withHeader('Authorization', 'Bearer ' . $token)
->withTimeout(8)
->withRetries(3)
->withJson(['amount' => 4200])
->send();
// One-shot JSON helper:
$data = $http->json('GET', 'https://api.example.com/health');
For richer features (HTTP/2 multiplex, signed URLs, middleware stack), composer require guzzlehttp/guzzle.
File uploads & Image processing
Safe by default: MIME-sniffed, extension allowlist, randomised filename, stored outside web-executable scope.
// In a controller — assume your form has <input name="avatar" type="file">
$upload = new \BC\Core\Files\Upload(
$this->request->files['avatar'] ?? [],
allow: ['png', 'jpg', 'jpeg', 'webp'],
maxBytes: 4 * 1024 * 1024,
);
if (!$upload->valid()) {
return $this->fail($upload->error(), 422);
}
$savedPath = $upload->store(); // /…/storage/uploads/<random>.jpg
// Resize / re-encode via GD:
\BC\Core\Files\Image::open($savedPath)
->resize(800, 800)
->saveAs($savedPath . '.thumb.webp', quality: 82);
The uploads folder ships with an .htaccess denying .php/.phtml/.cgi execution — a renamed PHP file inside storage/uploads/ is served as text, not executed.
Mailer
Transports: log (default — writes to logs/mail.log), sendmail (PHP mail()), smtp (raw socket with STARTTLS + AUTH).
// .env
MAIL_TRANSPORT=smtp
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER=postmaster@yourdomain
SMTP_PASS=secret
SMTP_ENCRYPTION=tls
MAIL_FROM_ADDRESS=hello@yourdomain.com
MAIL_FROM_NAME=Your Site
// In a controller:
$message = (new \BC\Core\Mail\Message())
->to('user@example.com')
->subject('Welcome to ' . $this->c->config->get('site.name'))
->html($this->view->render('emails/welcome', ['name' => 'Alice']))
->text('Hi Alice — welcome!');
$ok = $this->c->mailer()->send($message);
Render emails from views — same template engine, same cascade. Or composer require phpmailer/phpmailer for DKIM-signing, embedded images, etc.
Queue
Drivers: sync (runs inline; default in dev), database (jobs table), redis (lists). Workers via bin/bc queue:work.
// Define a job — app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;
final class SendWelcomeEmail extends \BC\Core\Queue\Job {
public function __construct(public int $userId) {}
public function handle(): void {
$user = (new \App\Models\User())->find($this->userId);
// …send the email…
}
public function maxTries(): int { return 3; }
public function retryAfter(): int { return 60; }
}
// Dispatch from anywhere:
$this->c->queue()->push(new \App\Jobs\SendWelcomeEmail($newUserId));
// Run a worker:
// bin/bc queue:work # default queue, 1s sleep
// bin/bc queue:work emails 2 # 'emails' queue, 2s sleep between polls
DB driver needs the jobs table — see core/Queue/DatabaseQueue.php docblock for SQL. Redis driver requires cache.driver = redis.
Scheduler / Cron
Declare tasks in app/schedule.php. Run bin/bc schedule:run every minute from a SINGLE crontab line — the scheduler decides which tasks are due.
// app/schedule.php
return function (\BC\Core\Schedule\Scheduler $s): void {
$s->call(static fn() => \App\Jobs\CleanOrphans::run())
->hourly()
->name('clean.orphans')
->withoutOverlapping();
$s->call([\App\Reports\Digest::class, 'send'])
->dailyAt('06:00');
$s->call(static fn() => \BC\Core\Container::instance()->cache()->clear())
->cron('*/15 * * * *'); // raw 5-field cron expression
};
# /etc/crontab — ONE line for the whole framework:
* * * * * cd /var/www/yourapp && bin/bc schedule:run >/dev/null 2>&1
Fluent shortcuts: everyMinute, everyFiveMinutes, hourly, daily, dailyAt('HH:MM'), weekly, monthly, or cron('5-field expr').
i18n
File-based translations under app/lang/<locale>/<file>.php. Each file returns a flat array. Look up with t('file.key').
// app/lang/en/common.php
return [
'welcome' => 'Welcome',
'hello' => 'Hello, :name',
'sign_in' => 'Sign in',
];
// Anywhere — template, controller, mailer:
echo t('common.welcome'); // "Welcome"
echo t('common.hello', ['name' => 'Alice']); // "Hello, Alice"
// Switch locale per request:
$this->c->translator()->setLocale('es');
Assets / Tailwind
Pluggable compiler. Default: Tailwind via the standalone CLI (no Node required).
# One-off, downloads the platform binary (~30 MB, MIT, by Tailwind Labs):
bin/bc assets:install tailwind
# One-shot build:
bin/bc assets:build
# Watch mode for dev:
bin/bc assets:build --watch
# In your layout:
<link rel="stylesheet" href="<?= asset('app.css') ?>">
The asset() helper reads public/build/manifest.json and returns a content-hashed URL like /public/build/a1b2c3-app.css. When no build exists yet, it falls back to /public/css/site.css.
Source lives at resources/css/app.css:
@import "tailwindcss";
/* your customisations… */
Swap compilers via ASSETS_COMPILER=tailwind|none in .env. Implementing your own: class XCompiler implements \BC\Core\Assets\CompilerInterface { … } and register in CompilerManager::make().
CLI — bin/bc
bin/bc # list commands
bin/bc serve [host:port] # PHP built-in dev server (default 127.0.0.1:8080)
bin/bc queue:work [queue] [sleep] # process jobs (long-running)
bin/bc schedule:run # fire due scheduled tasks
bin/bc cache:clear # wipe the configured cache backend
bin/bc make:controller Product # scaffold app/Controllers/Product.php
bin/bc assets:install tailwind # download the Tailwind binary
bin/bc assets:build [--watch] # compile CSS
Add your own command at app/Commands/<Name>Command.php extending \BC\Core\Cli\Command — register in Cli\Kernel::$commands.
Log viewer (key-gated)
Web UI for tailing log files. Disabled by default. When enabled, requires a passphrase — wrong/missing key returns 404, identical to a missing route, so nothing leaks.
# .env
LOG_VIEWER_ENABLED=true
LOG_VIEWER_PATH=/_logs # change for obscurity
LOG_VIEWER_KEY=long-random-passphrase
# Then open:
http://yoursite/_logs?key=long-random-passphrase
# Or via header:
curl -H "X-Log-Viewer-Key: long-random-passphrase" http://yoursite/_logs
Renders its own self-contained HTML — works even when the rest of the app is broken (which is when you need it). Auto-discovers every .log in logs/; backward-tails by chunk reads (handles 100 MB+ logs efficiently); colourises ERROR/FATAL/WARNING lines; filter by substring.
Cheat sheet — fastest path to each thing
| I want to… | Call this |
|---|---|
| Get a clean email from POST | $this->sanitize->wrap($_POST['email']??'')->email |
| Fetch a row by id | $db->row("SELECT * FROM t WHERE id=:i",['i'=>$id]) |
| Insert and get the new id | $db->execute("INSERT INTO t …", $params) |
| Cache a value for 10 min | $this->c->cache()->set('k', $v, 600) |
| Render a page | return $this->view->render('index', $data) |
| Redirect | $this->redirect('/path') |
| JSON response | return $this->ok($data) |
| CSRF in a form | <?= csrf_field() ?> |
| Escape in a view | <?= e($value) ?> |
| Profile a block | $p->mark('x'); …; $p->end('x'); |
| Log an event | $this->c->logger->info('msg') |
| Read config | $this->c->config->get('db.host') |
| Send an email | $this->c->mailer()->send($message) |
| Queue a job | $this->c->queue()->push(new SendWelcome($id)) |
| Paginate a query | $db->paginate($sql, $p, 20, $page) |
| Block-cache a fragment | block('home.v1', 600, fn() => ...) |
| HTTP GET | $http->get($url)->send()->json() |
| Translate a string | t('common.welcome') |
| Hashed asset URL | <?= asset('app.css') ?> |
| Resize an image | Image::open($p)->resize(800,800)->saveAs($out) |
| Validate an upload | new Upload($_FILES['avatar'], allow: ['png','jpg']) |
| Rate-limit an action | $rl->attempt('login:'.$ip, 5, 60) |