Reference

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:

  1. BC\Core\… → handled by framework autoloader (core/Autoload.php) — instant.
  2. App\… → framework autoloader — instant.
  3. Demo\… → framework autoloader — instant.
  4. Everything else → falls through to Composer's autoloader (vendor/autoload.php) — handles all vendor/* 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 pagereturn $this->view->render('index', $data)
Redirect$this->redirect('/path')
JSON responsereturn $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 fragmentblock('home.v1', 600, fn() => ...)
HTTP GET$http->get($url)->send()->json()
Translate a stringt('common.welcome')
Hashed asset URL<?= asset('app.css') ?>
Resize an imageImage::open($p)->resize(800,800)->saveAs($out)
Validate an uploadnew Upload($_FILES['avatar'], allow: ['png','jpg'])
Rate-limit an action$rl->attempt('login:'.$ip, 5, 60)